diff --git a/.agents/skills/lint-creator/SKILL.md b/.agents/skills/lint-creator/SKILL.md new file mode 100644 index 00000000000..e72e83a9aba --- /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 08b0265fafb..99fca0c7116 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 0e6ab04228d..fea437d4ff9 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/workflows/after_release.yml b/.github/workflows/after_release.yml index 9fb93ee27d5..5a833447e9e 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 9918f6be0fc..1c7b72da68e 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++ diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index 4f9724439f3..be39f41b6e5 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 3618d7230f7..7be909c907c 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 diff --git a/.github/workflows/bump_zed_version.yml b/.github/workflows/bump_zed_version.yml index f8fae3de7af..07b17aca32a 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' @@ -86,6 +88,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 @@ -127,6 +132,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 @@ -180,6 +187,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 diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index 82dc9fb545d..48474ac0bd5 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -21,14 +21,12 @@ 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 @@ -38,6 +36,11 @@ jobs: 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 0d7ce3aad3c..c6e79bff848 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 diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index be1d8e66d04..f309dd589b7 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_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index b8003a69b24..55ba214ad30 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 90d1934ffcb..4dbe5e8700a 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 2cf27fea8b0..a701371d76e 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 4866b3c33bc..f6d04265975 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 4e94c613a6b..9bef39ade45 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') diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 043c18ebc7c..b40c55afb14 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') diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6c492135ea6..ace3c0729e7 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 acd904841bb..12d10f10b26 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 c3d04d5780b..f0405af5e94 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -42,6 +42,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 diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index e48ccdb082a..df9fcc98693 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 6e68db7af0f..494a97b0977 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') diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 23efa368d17..9a6f223f38f 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') diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index c1e61822df6..d1a47870e6f 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 @@ -220,21 +222,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 fc1b49424dc..9e86118dd0a 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 00000000000..77f03168c4e --- /dev/null +++ b/.github/workflows/guild_assignment_status.yml @@ -0,0 +1,59 @@ +# 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 + + - 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 00000000000..8aad061e5f8 --- /dev/null +++ b/.github/workflows/guild_new_pr_notify.yml @@ -0,0 +1,120 @@ +# When a guild member opens a PR while already having another open PR in +# zed-industries/zed, post a heads-up to #zed-guild-internal. PRs opened before +# CUTOFF_DATE (the cohort start) don't count as "already open". + +name: Guild New PR Notification + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: guild-new-pr-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +env: + CUTOFF_DATE: "2026-07-01" + +jobs: + notify-slack: + 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 + + - name: Notify on an additional open PR by a guild member + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + // 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 pr = context.payload.pull_request; + const author = pr.user.login; + + const isGuildMember = async (username) => { + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', + username + }); + // 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) { + return false; + } + throw error; + } + }; + + if (!(await isGuildMember(author))) { + console.log(`${author} is not a guild member, skipping`); + return; + } + + const cutoff = process.env.CUTOFF_DATE; + const query = `repo:zed-industries/zed is:pr is:open author:${author} created:>=${cutoff}`; + const result = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 100 }); + const others = result.data.items.filter((item) => item.number !== pr.number); + + if (others.length === 0) { + console.log(`${author} has no other qualifying open PRs, skipping`); + return; + } + + const webhook = process.env.SLACK_WEBHOOK_GUILD_INTERNAL; + if (!webhook) { + core.setFailed('SLACK_WEBHOOK_GUILD_INTERNAL secret is not set'); + return; + } + + // Escape Slack's control characters so PR titles render literally + // instead of being read as links or @-mentions. + const esc = (text) => + (text || '').replace(/&/g, '&').replace(//g, '>'); + + const quips = [ + 'Deep into the queue they wander.', + 'Nevermore just one open PR.', + ]; + const quip = quips[Math.floor(Math.random() * quips.length)]; + const otherList = others + .map((item) => `<${item.html_url}|#${item.number}> ${esc(item.title)}`) + .join('\n• '); + const message = + `${quip} @${author} just opened <${pr.html_url}|${esc(pr.title)}> ` + + `while still having open PR(s):\n• ${otherList}\n` + + 'A gentle nudge to help them land one before spreading thin might be in order.'; + + const response = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `${author} opened another PR while others are still open`, + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: message } }], + }), + }); + if (!response.ok) { + const body = await response.text(); + core.setFailed(`Slack notification failed with status ${response.status}: ${body}`); + return; + } + console.log(`Notified: ${author} has ${others.length} other open PR(s)`); diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml new file mode 100644 index 00000000000..90b47988c3f --- /dev/null +++ b/.github/workflows/guild_stale_assignments.yml @@ -0,0 +1,57 @@ +# 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 + + - 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 00000000000..9f2a7fb4c8c --- /dev/null +++ b/.github/workflows/guild_weekly_shipped.yml @@ -0,0 +1,55 @@ +# 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 + + - 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/hotfix-review-monitor.yml b/.github/workflows/hotfix-review-monitor.yml index 760cd9806c9..c6e4bfe6e53 100644 --- a/.github/workflows/hotfix-review-monitor.yml +++ b/.github/workflows/hotfix-review-monitor.yml @@ -21,12 +21,13 @@ on: permissions: contents: read - pull-requests: read jobs: check-hotfix-reviews: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest + permissions: + pull-requests: read timeout-minutes: 5 env: REPO: ${{ github.repository }} diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml index f658634c06c..3ddd6bdd5c6 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 fbba166a244..524d301aecc 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,47 +41,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 +123,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 +139,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 +185,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 b2d8e96fcea..bee3c7c21f5 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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8115259c0b7..5a3808e0632 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') @@ -723,6 +725,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 @@ -756,6 +760,8 @@ jobs: needs: - upload_release_assets runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: release::validate_release_assets run: | diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index e1c72767d77..cf9f4728aa4 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') diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index b05015677c2..4299bac4013 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: |- diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 3b613f255cc..319c7290dd7 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) diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 688cfdded83..d02b3b23c23 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -12,8 +12,15 @@ 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: >- diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index 3dd9ffeabae..c8d970451dd 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -4,32 +4,48 @@ on: issues: types: [labeled] +permissions: + contents: read + env: PRIORITY_LABELS: '["priority:P0", "priority:P1"]' REPRODUCIBLE_LABEL: 'state:reproducible' FREQUENCY_LABELS: '["frequency:always", "frequency:common"]' + 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" \ --arg repro "$REPRODUCIBLE_LABEL" \ @@ -42,19 +58,52 @@ jobs: exit 0 fi + # 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_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | 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 + if [ -z "$MATCHED_PRIORITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_FREQ" != "true" ]; then echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), 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-frequency $MATCHED_PRIORITY, notifying" + { + echo "notify_reason=confirmed $MATCHED_PRIORITY" + 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 e791cbc7ea4..f30650dc220 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/stale-pr-reminder.yml b/.github/workflows/stale-pr-reminder.yml index 1c3c0aec623..a35430857ec 100644 --- a/.github/workflows/stale-pr-reminder.yml +++ b/.github/workflows/stale-pr-reminder.yml @@ -20,13 +20,14 @@ on: permissions: contents: read - pull-requests: read jobs: check-stale-prs: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + pull-requests: read env: REPO: ${{ github.repository }} # Only surface PRs created on or after this date. Update this if the diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index d14f4aa9245..1d073ba0484 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/Cargo.lock b/Cargo.lock index d3be7e7882d..17acf0e6ed9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +checksum = "ed34c8297a72c9d99fc7493ba6370f45c69fdcd51fdfb469d80f266cf63e5a98" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b37d552feb6981a0109febda6b71fc723678cd065974c2d76279c19c407095" +checksum = "0de0877cd41073fa0a124edb972df0d02e88c70adde11f49cf2231fd63aae738" dependencies = [ "quote", "syn 2.0.117", @@ -354,9 +354,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" dependencies = [ "anyhow", "derive_more", @@ -494,6 +494,7 @@ dependencies = [ "heapless", "html_to_markdown", "http_client", + "idna", "image", "indoc", "itertools 0.14.0", @@ -535,6 +536,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "shlex", "streaming_diff", "task", "telemetry", @@ -548,6 +550,7 @@ dependencies = [ "tree-sitter-md", "ui", "ui_input", + "unicode-script", "unicode-segmentation", "unindent", "url", @@ -2889,7 +2892,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", @@ -3536,7 +3539,7 @@ name = "collections" version = "0.1.0" dependencies = [ "gpui_util", - "indexmap 2.11.4", + "indexmap 2.14.0", "rustc-hash 2.1.1", ] @@ -3906,6 +3909,7 @@ dependencies = [ "lsp", "menu", "project", + "release_channel", "serde_json", "settings", "ui", @@ -4174,36 +4178,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", @@ -4211,9 +4215,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", @@ -4241,9 +4245,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", @@ -4254,24 +4258,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", @@ -4280,9 +4284,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", @@ -4292,15 +4296,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", @@ -4309,9 +4313,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" @@ -4629,7 +4633,7 @@ checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", @@ -4644,7 +4648,7 @@ checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4662,7 +4666,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", @@ -5342,9 +5346,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", ] @@ -5808,6 +5812,7 @@ dependencies = [ "unicode-width", "unindent", "url", + "urlencoding", "util", "uuid", "vim_mode_setting", @@ -6313,8 +6318,8 @@ dependencies = [ "tracing", "url", "util", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", "ztracing", ] @@ -6394,7 +6399,7 @@ dependencies = [ "tracing", "url", "util", - "wasmparser 0.221.3", + "wasmparser 0.252.0", "wasmtime", "wasmtime-wasi", "zlog", @@ -6462,9 +6467,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", @@ -7246,7 +7251,7 @@ dependencies = [ "derive_more", "derive_setters", "gh-workflow-macros", - "indexmap 2.11.4", + "indexmap 2.14.0", "merge", "serde", "serde_json", @@ -7281,7 +7286,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", ] @@ -7367,6 +7372,7 @@ dependencies = [ "async-channel 2.5.0", "buffer_diff", "call", + "client", "collections", "component", "ctor", @@ -8114,7 +8120,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8133,7 +8139,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8245,6 +8251,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" @@ -8997,12 +9014,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.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -9779,6 +9796,7 @@ dependencies = [ "http_client", "log", "partial-json-fixer", + "pretty_assertions", "schemars 1.0.4", "serde", "serde_json", @@ -9796,6 +9814,7 @@ dependencies = [ "async-lock", "aws-config", "aws-credential-types", + "aws-sigv4", "aws_http_client", "base64 0.22.1", "bedrock", @@ -10638,7 +10657,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", @@ -10664,6 +10683,7 @@ dependencies = [ "gpui", "gpui_platform", "html5ever 0.27.0", + "image", "language", "languages", "linkify", @@ -10950,7 +10970,7 @@ dependencies = [ "chrono", "euclid", "htmlize", - "indexmap 2.11.4", + "indexmap 2.14.0", "json5", "lalrpop", "lalrpop-util", @@ -10976,7 +10996,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dugong", - "indexmap 2.11.4", + "indexmap 2.14.0", "manatee", "merman-core", "pulldown-cmark 0.12.2", @@ -11268,8 +11288,9 @@ 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", @@ -11280,7 +11301,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.11.4", + "indexmap 2.14.0", "libm", "log", "num-traits", @@ -12123,7 +12144,7 @@ checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "memchr", ] @@ -12365,9 +12386,9 @@ 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", "cfg-if", @@ -12402,9 +12423,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", @@ -13289,7 +13310,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]] @@ -13476,7 +13497,6 @@ dependencies = [ name = "picker_preview" version = "0.1.0" dependencies = [ - "anyhow", "editor", "gpui", "language", @@ -13604,7 +13624,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", @@ -13985,7 +14005,7 @@ dependencies = [ "dap", "encoding_rs", "extension", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fs", "futures 0.3.32", "fuzzy", @@ -13996,7 +14016,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap 2.11.4", + "indexmap 2.14.0", "itertools 0.14.0", "language", "log", @@ -14088,6 +14108,7 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "log", "markdown_preview", "menu", "notifications", @@ -14125,6 +14146,7 @@ dependencies = [ "lsp", "ordered-float 2.10.1", "picker", + "picker_preview", "project", "release_channel", "semver", @@ -14446,9 +14468,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", @@ -14458,9 +14480,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", @@ -15046,9 +15068,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", @@ -15975,6 +15997,7 @@ dependencies = [ "libc", "log", "nix 0.29.0", + "seccompiler", "serde", "serde_json", "smol", @@ -16048,7 +16071,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", @@ -16292,6 +16315,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" @@ -16438,7 +16470,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", @@ -16452,7 +16484,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", @@ -16522,7 +16554,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", @@ -16549,7 +16581,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", @@ -16562,7 +16594,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", @@ -16704,6 +16736,7 @@ dependencies = [ "agent_skills", "anyhow", "audio", + "client", "cloud_api_types", "codestral", "collections", @@ -17323,7 +17356,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -18419,6 +18452,7 @@ dependencies = [ "serde_json", "settings", "shellexpand", + "shlex", "task", "terminal", "theme", @@ -18490,7 +18524,7 @@ dependencies = [ "clap", "collections", "gpui", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "palette", "serde", @@ -18951,7 +18985,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", @@ -18984,7 +19018,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", @@ -18998,7 +19032,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", @@ -19428,7 +19462,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", @@ -20295,16 +20329,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" @@ -20335,6 +20359,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" @@ -20342,7 +20376,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", @@ -20360,7 +20394,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20377,7 +20411,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", ] @@ -20414,23 +20448,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ "bitflags 2.10.0", - "indexmap 2.11.4", + "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" @@ -20439,7 +20460,7 @@ checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] @@ -20451,7 +20472,7 @@ checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", "serde", ] @@ -20464,10 +20485,23 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "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.10.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "semver", + "serde", +] + [[package]] name = "wasmprinter" version = "0.236.1" @@ -20481,9 +20515,9 @@ 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", @@ -20494,7 +20528,7 @@ dependencies = [ "cfg-if", "encoding_rs", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "libc", "log", "mach2 0.4.3", @@ -20542,16 +20576,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", @@ -20569,9 +20603,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", ] @@ -20588,9 +20622,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", @@ -20603,15 +20637,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", @@ -20636,9 +20670,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", @@ -20652,9 +20686,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", @@ -20662,9 +20696,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", @@ -20674,24 +20708,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", @@ -20702,9 +20736,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", @@ -20713,9 +20747,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", @@ -20730,22 +20764,22 @@ 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", "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", @@ -20774,9 +20808,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", @@ -20822,9 +20856,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", @@ -20908,9 +20942,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", @@ -21034,8 +21068,9 @@ 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", @@ -21063,8 +21098,9 @@ 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", @@ -21074,7 +21110,7 @@ dependencies = [ "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -21095,32 +21131,36 @@ 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", @@ -21172,8 +21212,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", @@ -21181,8 +21222,9 @@ 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", "bytemuck", @@ -21252,9 +21294,9 @@ 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", @@ -21267,9 +21309,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", @@ -21281,9 +21323,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", @@ -21324,9 +21366,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", @@ -22248,7 +22290,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", @@ -22262,7 +22304,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", @@ -22278,7 +22320,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", @@ -22338,7 +22380,7 @@ checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22357,7 +22399,7 @@ checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22376,7 +22418,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22395,7 +22437,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22413,7 +22455,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22431,7 +22473,7 @@ checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22449,7 +22491,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22754,7 +22796,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", - "indexmap 2.11.4", + "indexmap 2.14.0", "indoc", "itertools 0.14.0", "regex", @@ -22965,7 +23007,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.10.0" +version = "1.12.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/Cargo.toml b/Cargo.toml index 80cc6501001..c657ff1aefb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -508,7 +508,7 @@ accesskit = "0.24.0" accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=1.0.1", features = ["unstable"] } +agent-client-protocol = { version = "=1.1.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" @@ -543,6 +543,7 @@ 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" @@ -594,7 +595,7 @@ emojis = "0.6.1" env_logger = "0.11" encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.17.0" +fancy-regex = "0.18.0" fork = "0.4.0" futures = "0.3.32" futures-concurrency = "7.7.1" @@ -743,6 +744,7 @@ 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"] } @@ -809,7 +811,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" @@ -828,8 +830,8 @@ 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", @@ -845,7 +847,7 @@ which = "6.0.0" wasm-bindgen = "0.2.120" 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" @@ -1070,3 +1072,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/assets/icons/folder_share.svg b/assets/icons/folder_share.svg index 36db1414b8c..898af2397c7 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 785b3aa56d7..897bef603a1 100644 --- a/assets/icons/folder_shared.svg +++ b/assets/icons/folder_shared.svg @@ -1,6 +1,6 @@ - - + + diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 72b32734399..a56ced30861 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 5ba458e8b53..31b2c97d74b 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 063c7dbf826..88ada58011f 100644 --- a/assets/icons/square_plus.svg +++ b/assets/icons/square_plus.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/user_arrow_up.svg b/assets/icons/user_arrow_up.svg new file mode 100644 index 00000000000..8320108fa79 --- /dev/null +++ b/assets/icons/user_arrow_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 90a440a6447..58bfc4b10e7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -319,6 +319,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", @@ -1233,6 +1234,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"], @@ -1263,7 +1265,9 @@ }, { "context": "AgentPanel > Terminal", + "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8b4e76dd81f..0bf5b95fe58 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -357,6 +357,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", @@ -1334,6 +1335,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "cmd-n": "agent::NewThread", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 08a9274c317..d373b94f649 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -320,6 +320,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", @@ -1278,6 +1279,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index de4d538d40d..889f89def32 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/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 291d7d5e7a8..9df47fe2412 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/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 3fd1d6c87f8..e24dfb20517 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -39,6 +39,9 @@ "use_key_equivalents": true, "bindings": { "alt-cmd-f": "text_finder::ToProjectSearch", + "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", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 663153a8435..2c4191089ab 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -39,6 +39,9 @@ "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 432bdc7004a..00000000000 --- 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/settings/default.json b/assets/settings/default.json index 74560dd49bb..0e42d27e980 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -73,7 +73,7 @@ "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 editor font size if unset. + // 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, @@ -1025,11 +1025,6 @@ // Default: project_diff "entry_primary_click_action": "project_diff", }, - "message_editor": { - // Whether to automatically replace emoji shortcodes with emoji characters. - // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true, - }, "agent": { // Whether the inline assistant should use streaming tools, when available "inline_assistant_use_streaming_tools": true, @@ -1463,7 +1458,13 @@ // 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": "off", // How to perform a buffer format. This setting can take multiple values: @@ -1902,6 +1903,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 diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index bf5b97607bb..c2b2fe35d4d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -224,6 +224,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( @@ -430,16 +435,7 @@ impl ElicitationStore { &self.elicitations } - fn validate_request( - request: &acp::CreateElicitationRequest, - cx: &App, - ) -> Result<(), acp::Error> { - if !cx.has_flag::() { - return Err( - acp::Error::invalid_params().data("elicitation support requires the ACP beta flag") - ); - } - + fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> { if let acp::ElicitationMode::Url(mode) = &request.mode { url::Url::parse(&mode.url) .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?; @@ -590,7 +586,7 @@ impl ElicitationStore { request: acp::CreateElicitationRequest, cx: &mut Context, ) -> Result<(ElicitationEntryId, Task), acp::Error> { - Self::validate_request(&request, cx)?; + Self::validate_request(&request)?; let (id, response_rx) = self.insert_pending_elicitation(request); cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone())); cx.notify(); @@ -3485,7 +3481,7 @@ impl AcpThread { request: acp::CreateElicitationRequest, cx: &mut Context, ) -> Result<(ElicitationEntryId, Task), acp::Error> { - ElicitationStore::validate_request(&request, cx)?; + ElicitationStore::validate_request(&request)?; let (id, response_rx) = self.elicitations.insert_pending_elicitation(request); self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx); @@ -4127,12 +4123,16 @@ 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(&client_id) { @@ -4637,7 +4637,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(); }); } @@ -4673,7 +4674,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 { @@ -4738,7 +4740,7 @@ mod tests { 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; @@ -5102,6 +5104,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); @@ -7333,7 +7412,7 @@ mod tests { } #[gpui::test] - async fn test_elicitation_requires_acp_beta_flag(cx: &mut TestAppContext) { + async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { cx.update_flags(false, vec![]); @@ -7355,8 +7434,13 @@ mod tests { ) }); - assert!(result.is_err()); - thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty())); + assert!(result.is_ok()); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries(), + [AgentThreadEntry::Elicitation(_)] + )); + }); } #[gpui::test] @@ -9175,6 +9259,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. diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index eaf51978b5d..5f0cf69890d 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -4,7 +4,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; -use language_model::{DisabledReason, LanguageModelProviderId}; +use language_model::DisabledReason; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; @@ -421,26 +421,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 {} diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index d297b5fa98f..0834d7eabba 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 b5e6ab90ab9..93e7b4d846b 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -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). +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,298 @@ 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::Posix).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::Posix).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::Posix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%2Fb.rs") + } + ); + let parsed = + MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Posix).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::Posix).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::Posix).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::Posix).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::Posix), + None + ); + // Invalid escape sequences are also left alone by `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Posix), + None + ); + // Separator escapes are never decoded, so they're not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Posix), + None + ); + // URLs are spec-encoded, not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Posix), + None + ); + // Relative paths are not bare-path mentions. + assert_eq!( + MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Posix), + None + ); + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 3bc33e2a957..cce475268e6 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -129,6 +129,31 @@ 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" + } + } + } + } } impl SandboxWrap { @@ -151,6 +176,15 @@ impl SandboxWrap { /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical /// path) rather than passing a re-resolvable path. A location that can't be /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed. + /// + /// This function has **no filesystem side effects**: it never creates paths. + /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe + /// and by real sandbox construction, and must behave identically. On Linux a + /// writable grant that doesn't exist yet simply can't be captured (bwrap + /// can't bind a missing path), so it's dropped here — 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. fn to_policy(&self) -> sandbox::SandboxPolicy { let protected_paths = self .protected_paths @@ -164,12 +198,11 @@ impl SandboxWrap { .writable_paths .iter() .chain(self.extra_write_paths.iter()) - .filter_map(|path| { - // Create not-yet-existing writable grants (e.g. an approved - // scratch dir) so they can be captured and bound; best-effort. - let _ = std::fs::create_dir_all(path); - sandbox::HostFilesystemLocation::new(path).ok() - }) + // Capture only — never create anything here (see the doc comment): + // materializing an approved-but-missing grant is deferred to + // `Sandbox::new` so it can never happen during the `can_create` + // probe, before the user has approved the grant. + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) .collect(); sandbox::SandboxFsPolicy::Restricted { writable_paths, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 75e52207f27..381af5e2fa3 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -374,7 +374,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)) + }); }) } } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 7d69e03c325..8e17b290548 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -2,7 +2,7 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; 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}; @@ -281,7 +281,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, }, @@ -444,7 +446,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 diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index e8d6589601e..90599fd6551 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -5,10 +5,9 @@ //! caller see the same answer (and so the `target_os` gate lives in one //! place instead of scattered across the agent crate). //! -//! 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 persistently allowed unsandboxed execution (the -//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed` +//! The current policy is: enabled iff the project is local, the platform has an +//! integration, and 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 @@ -19,14 +18,12 @@ //! //! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL) //! have real sandbox integrations; on platforms without one the per-command -//! wrap is a no-op, so commands run with the agent's ambient permissions even -//! when the flag is on. +//! wrap is a no-op, so commands run with the agent's ambient permissions. //! //! Naming note: this module is about agent terminal sandboxing specifically. //! Other agent operations (e.g. file edits) are gated separately. use agent_settings::{AgentSettings, SandboxPermissions}; -use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; @@ -176,12 +173,6 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy SandboxPolicy { fs, network } } -/// 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. /// /// The persistent `allow_unsandboxed` setting turns sandboxing off for the @@ -193,20 +184,19 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { /// 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) + sandboxing_available_for_project(project) && !AgentSettings::get_global(cx) .sandbox_permissions .allow_unsandboxed } -/// 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() +/// Whether sandboxing is *applicable* for this project at all — 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) -> bool { + project.is_local() && cfg!(any( target_os = "macos", target_os = "linux", diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 160c94328d1..233a0e45215 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -198,7 +198,7 @@ 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` — 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. Git metadata paths cannot be requested and will never be made writable while sandboxed. +- `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. Each path must be an existing directory. 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. 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. diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 4998a210b8a..73415f55af4 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -689,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, }; @@ -840,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"); } @@ -884,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, }, @@ -894,7 +902,7 @@ 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, }, @@ -951,7 +959,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, }, @@ -990,7 +998,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, }, @@ -1029,7 +1037,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, }, @@ -1533,7 +1541,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, }, @@ -1577,7 +1585,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, }, @@ -1587,7 +1595,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, }, @@ -1697,7 +1705,7 @@ async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext id: "tool_1".into(), name: "snake_case_PascalCase".into(), raw_input: json!({}).to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1786,7 +1794,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, }, @@ -1936,7 +1944,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, }, @@ -2351,7 +2361,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, }, @@ -2446,7 +2458,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, }, @@ -2632,7 +2644,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, }, @@ -2697,7 +2711,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, }, @@ -2707,7 +2723,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, }, @@ -2811,7 +2829,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, }, @@ -2907,7 +2927,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, }, @@ -3376,7 +3398,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, }, @@ -3786,7 +3808,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, }; @@ -3794,7 +3816,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, }; @@ -3992,7 +4014,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, }, @@ -4005,7 +4027,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, }, @@ -4175,7 +4197,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, }; @@ -4323,7 +4345,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, }; @@ -4428,7 +4450,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, }; @@ -5228,7 +5250,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, }; @@ -5362,7 +5386,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, }; @@ -5509,7 +5535,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, }; @@ -5638,7 +5666,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, }; @@ -5699,7 +5729,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, }; @@ -6285,7 +6317,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, }; @@ -6410,7 +6444,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, }; @@ -6476,7 +6512,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, }; @@ -6583,7 +6621,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, }; @@ -7350,6 +7390,194 @@ async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) ); } +/// 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" + ); +} + /// Approving one pending tool call with "Always for " auto-resolves /// sibling pending authorizations for the same tool in the same turn. #[gpui::test] @@ -7372,7 +7600,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, }, @@ -7451,7 +7679,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, }, @@ -7522,7 +7750,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, }, @@ -7598,7 +7826,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, }, @@ -7668,7 +7896,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, }, @@ -7765,7 +7993,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, }, @@ -7847,7 +8075,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te 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, }, @@ -7914,7 +8142,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, }; @@ -7996,7 +8224,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, }, @@ -8005,7 +8233,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, }; @@ -8015,7 +8243,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(), @@ -8144,7 +8372,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 fb2b277a634..fe5c9f2004c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -35,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::{ @@ -606,7 +607,7 @@ impl AgentMessage { "{}\n", MarkdownCodeBlock { tag: "json", - text: &format!("{:#}", tool_use.input) + text: &format!("{:#}", tool_use.input.to_display_json()) } )); } @@ -1181,7 +1182,7 @@ enum CompletionError { Other(#[from] anyhow::Error), } -pub(crate) enum ThreadModel { +pub enum ThreadModel { Ready(Arc), Unresolved(SelectedModel), Unset, @@ -1357,7 +1358,11 @@ impl Thread { .and_then(|model| model.speed); let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); - let model = model.map_or(ThreadModel::Unset, ThreadModel::Ready); + 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(), @@ -1588,7 +1593,7 @@ impl Thread { .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_use.id.to_string(), 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() @@ -1601,15 +1606,12 @@ impl Thread { 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_use.id, &tool_use.name, title, kind, input.clone()); if let Some(content) = replay_content { stream.update_tool_call_fields( @@ -1630,8 +1632,7 @@ impl Thread { 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( @@ -1845,12 +1846,12 @@ impl Thread { 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. + /// Whether sandboxing is *applicable* for this thread's project (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) + sandboxing_available_for_project(self.project.read(cx)) } /// The directory subtrees the sandbox always grants write access to for this @@ -1946,6 +1947,10 @@ impl Thread { self.model.as_model() } + pub fn thread_model(&self) -> &ThreadModel { + &self.model + } + pub(crate) fn ensure_model( &mut self, default_model: Option<&Arc>, @@ -1977,6 +1982,7 @@ impl Thread { cx.emit(TokenUsageUpdated(new_usage)); } self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.emit(ModelChanged); for subagent in &self.running_subagents { subagent @@ -2395,6 +2401,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, @@ -3394,7 +3414,9 @@ impl Thread { 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(); } @@ -3411,16 +3433,33 @@ impl Thread { })); }; + // 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(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); @@ -3447,12 +3486,12 @@ 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, @@ -3576,7 +3615,7 @@ impl Thread { 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, }; @@ -3652,7 +3691,7 @@ impl Thread { &tool_use.name, title, kind, - tool_use.input.clone(), + tool_use.input.to_display_json(), ); last_message .content @@ -3663,7 +3702,7 @@ impl Thread { acp::ToolCallUpdateFields::new() .title(title.as_str()) .kind(kind) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), None, ); } @@ -3903,12 +3942,12 @@ impl Thread { .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(), - }) + Some(LanguageModelRequestTool::function( + tool_name.to_string(), + tool.description().to_string(), + tool.input_schema(model.tool_input_format()).log_err()?, + tool.supports_input_streaming(), + )) }) .collect::>() } else { @@ -4767,6 +4806,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. @@ -5883,10 +5926,15 @@ 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 { @@ -7643,6 +7691,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, ) @@ -7654,6 +7703,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"); @@ -7694,6 +7747,7 @@ mod tests { event_stream.authorize_sandbox_fallback( None, "probe failed".to_string(), + None, retries, cx, ) @@ -7738,6 +7792,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "user namespaces are disabled".to_string(), + None, 0, cx, ) @@ -7767,7 +7822,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 @@ -7842,7 +7903,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, }; @@ -7850,7 +7911,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, }; @@ -8157,7 +8218,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/tools.rs b/crates/agent/src/tools.rs index 452b4a01d09..85da6bbb214 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -153,12 +153,12 @@ macro_rules! tools { /// 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(), + ) } [ $( diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 308e7b91458..22918c43154 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -5,7 +5,7 @@ use super::tool_permissions::{ 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,13 @@ 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. +/// +/// 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. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. @@ -40,6 +41,13 @@ 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, + + /// 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. + #[serde(default)] + pub reason: Option, } pub struct CreateDirectoryTool { @@ -83,6 +91,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 +123,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 +178,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 +212,100 @@ 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(); + let request = crate::sandboxing::SandboxRequest { + write_paths: vec![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 +352,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 +410,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: outside_path.to_string_lossy().into_owned(), + reason: None, }), event_stream, cx, @@ -342,6 +467,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -404,6 +530,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -463,6 +590,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -545,6 +673,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -561,4 +690,107 @@ 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. + assert_eq!( + details.write_paths, + vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")] + ); + + 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/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 8cf5610531d..4734dd6b36c 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -324,6 +324,77 @@ mod tests { assert_eq!(new_text, "line 1\nmodified line 2\nline 3\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( diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index b6f8c0f1cfc..d47a8712333 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -16,7 +16,7 @@ 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::{Reindenter, compute_indent_delta, compute_rest_indent_delta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::ops::Range; @@ -527,15 +527,34 @@ 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 first_line_delta = compute_indent_delta(buffer_indent, query_indent); + + // 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( + first_line_delta, + matcher + .line_pairs(&range) + .unwrap_or(&[]) + .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 old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); @@ -551,7 +570,7 @@ 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, }); diff --git a/crates/agent/src/tools/edit_session/reindent.rs b/crates/agent/src/tools/edit_session/reindent.rs index 7f08749e475..ed4066e30d2 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 e6a56099a29..3515275f70a 100644 --- a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs @@ -12,10 +12,18 @@ pub struct StreamingFuzzyMatcher { query_lines: Vec, line_hint: Option, incomplete_line: String, - matches: Vec>, + 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)] +struct SearchMatch { + range: Range, + line_pairs: Vec<(u32, u32)>, +} + impl StreamingFuzzyMatcher { pub fn new(snapshot: TextBufferSnapshot) -> Self { let buffer_line_count = snapshot.max_point().row as usize + 1; @@ -34,6 +42,23 @@ impl StreamingFuzzyMatcher { &self.query_lines } + /// Returns the 0-based `(query_row, buffer_row)` line pairs that the + /// search aligned for the match with the given range. Lines that were + /// skipped on either side of the alignment are absent. + pub fn line_pairs(&self, range: &Range) -> Option<&[(u32, u32)]> { + self.matches + .iter() + .find(|search_match| search_match.range == *range) + .map(|search_match| search_match.line_pairs.as_slice()) + } + + fn match_ranges(&self) -> Vec> { + self.matches + .iter() + .map(|search_match| search_match.range.clone()) + .collect() + } + /// Push a new chunk of text and get the best match found so far. /// /// This method accumulates text chunks and processes complete lines. @@ -62,7 +87,11 @@ 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). @@ -72,26 +101,35 @@ impl StreamingFuzzyMatcher { pub fn finish(&mut self) -> Vec> { // 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 self.match_ranges(); } } - 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() + self.match_ranges() } - 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 +205,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 +214,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 +224,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 +237,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 +258,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 +270,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()); } } @@ -831,6 +873,79 @@ 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 matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(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. + matcher.push("}\n\n\n\nfn render_search", None); + let matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(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); diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index eb690cdcdf0..4a8537805db 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 d5c3ac1ba87..31b35c0e539 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 3fce2b04047..c0fb1de7de7 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 96c0fd2aba1..cfe7792c9f1 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -23,12 +23,38 @@ 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. @@ -47,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(&url, AsyncBody::default(), true).await?; + let mut response = http_client + .get(&normalized, AsyncBody::default(), false) + .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 @@ -63,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 { @@ -86,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)), @@ -96,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))); @@ -108,30 +152,25 @@ 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)) } } /// 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 -/// [`FetchTool::build_message`] (defaulting to `https://` when none is given). +/// [`normalize_url`] (defaulting to `https://` when none is given). fn host_pattern_for_url(url: &str) -> Result { - let normalized = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; + let normalized = normalize_url(url); let parsed = url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; let host = parsed @@ -211,36 +250,61 @@ impl AgentTool for FetchTool { // 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. let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx)); - if !unsandboxed { - let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?; - let authorize_host = cx.update(|cx| { - let request = SandboxRequest { - network: NetworkRequest::Hosts(vec![host]), - ..Default::default() + + 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()); + } }; - event_stream.authorize_sandbox(request, String::new(), cx) + } + + 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 } }); - futures::select! { - result = authorize_host.fuse() => result.map_err(|e| e.to_string())?, + + 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()); } }; - } - let fetch_task = cx.background_spawn({ - let http_client = http_client.clone(); - let url = input.url.clone(); - async move { Self::build_message(http_client, &url).await } - }); - - 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()); + 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()); } diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 481f1433fbf..eb08000d3a8 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; @@ -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/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 748642b3cda..5af92b2e451 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; @@ -327,10 +328,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( @@ -393,6 +399,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; + use std::path::PathBuf; use unindent::Unindent; use util::path; @@ -611,21 +618,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/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index ef394c5e8ba..3e7954e5ba6 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -558,8 +558,10 @@ 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() )); @@ -683,6 +685,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, ) @@ -788,6 +791,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, ) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9acc88da755..ec3b196272e 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -753,10 +753,7 @@ fn connect_client_future( ) } -fn client_capabilities_for_agent( - agent_id: &AgentId, - supports_beta_features: bool, -) -> acp::ClientCapabilities { +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()), @@ -766,30 +763,24 @@ fn client_capabilities_for_agent( meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); } - let mut capabilities = acp::ClientCapabilities::new() + acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) - .meta(meta); - - if supports_beta_features { - capabilities = capabilities - .elicitation( - acp::ElicitationCapabilities::new() - .form(acp::ElicitationFormCapabilities::new()) - .url(acp::ElicitationUrlCapabilities::new()), - ) - .session( - acp::ClientSessionCapabilities::new().config_options( - acp::SessionConfigOptionsCapabilities::new() - .boolean(acp::BooleanConfigOptionCapabilities::new()), - ), - ); - } - - capabilities + .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 { @@ -981,10 +972,7 @@ impl AcpConnection { let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities(client_capabilities_for_agent( - &agent_id, - cx.update(|cx| cx.has_flag::()), - )) + .client_capabilities(client_capabilities_for_agent(&agent_id)) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -1300,7 +1288,6 @@ impl AcpConnection { cx: &mut AsyncApp, ) { let id = self.id.clone(); - let apply_boolean_defaults = cx.update(|cx| cx.has_flag::()); let defaults_to_apply: Vec<_> = { let config_opts_ref = config_options.borrow(); config_opts_ref @@ -1333,9 +1320,6 @@ impl AcpConnection { _ => None, } } - acp::SessionConfigKind::Boolean(_) if !apply_boolean_defaults => { - return None; - } acp::SessionConfigKind::Boolean(_) => default_value .as_bool() .map(acp::SessionConfigOptionValue::boolean), @@ -2703,7 +2687,6 @@ mod tests { use super::*; use feature_flags::FeatureFlag as _; - use gpui::UpdateGlobal as _; use settings::Settings as _; fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { @@ -2715,50 +2698,15 @@ mod tests { }); } - fn set_acp_beta_override(value: &str, cx: &mut gpui::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()); - }); - }); - }); - } - #[gpui::test] - async fn client_capabilities_omit_elicitation_without_acp_beta(cx: &mut gpui::TestAppContext) { + async fn client_capabilities_include_elicitation_without_acp_beta( + cx: &mut gpui::TestAppContext, + ) { init_feature_flags_test(cx); - set_acp_beta_override("off", cx); - - let capabilities = cx.update(|cx| { - client_capabilities_for_agent( - &AgentId::new("codex-acp"), - cx.has_flag::(), - ) - }); - - assert!(capabilities.elicitation.is_none()); - } - - #[gpui::test] - async fn client_capabilities_include_elicitation_with_acp_beta(cx: &mut gpui::TestAppContext) { - init_feature_flags_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); - - let capabilities = cx.update(|cx| { - client_capabilities_for_agent( - &AgentId::new("codex-acp"), - cx.has_flag::(), - ) - }); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); let elicitation = capabilities .elicitation - .expect("elicitation should be advertised when acp-beta is enabled"); + .expect("elicitation should always be advertised"); assert!(elicitation.form.is_some()); assert!(elicitation.url.is_some()); @@ -3021,7 +2969,7 @@ mod tests { #[test] fn cursor_client_capabilities_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false); + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3036,7 +2984,7 @@ mod tests { #[test] fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3045,8 +2993,8 @@ mod tests { } #[test] - fn client_capabilities_include_boolean_config_options_when_supported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), true); + fn client_capabilities_include_boolean_config_options() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); assert!( capabilities @@ -3057,13 +3005,6 @@ mod tests { ); } - #[test] - fn client_capabilities_omit_boolean_config_options_when_unsupported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); - - assert!(capabilities.session.is_none()); - } - #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { @@ -3549,69 +3490,7 @@ mod tests { } #[gpui::test] - async fn default_config_options_skip_boolean_defaults_when_acp_beta_is_disabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(false, cx)); - - 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), - ), - ("mode".to_string(), AgentConfigOptionValue::from("manual")), - ]), - ); - let config_options = Rc::new(RefCell::new(vec![ - acp::SessionConfigOption::boolean("web_search", "Web Search", false), - acp::SessionConfigOption::select( - "mode", - "Mode", - "auto", - vec![ - acp::SessionConfigSelectOption::new("auto", "Auto"), - acp::SessionConfigSelectOption::new("manual", "Manual"), - ], - ), - ])); - - 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("mode")); - assert_eq!( - requests[0].value, - acp::SessionConfigOptionValue::value_id("manual") - ); - - let options = config_options.borrow(); - assert!( - matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if !boolean.current_value) - ); - assert!( - matches!(&options[1].kind, acp::SessionConfigKind::Select(select) if select.current_value == acp::SessionConfigValueId::new("manual")) - ); - } - - #[gpui::test] - async fn default_config_options_apply_boolean_defaults_when_acp_beta_is_enabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(true, cx)); - + 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, @@ -3654,19 +3533,6 @@ mod tests { ); } - fn init_settings_with_acp_beta_override(enabled: bool, cx: &mut App) { - let mut store = settings::SettingsStore::test(cx); - store.register_setting::(); - store.update_user_settings(cx, |content| { - content.feature_flags.get_or_insert_default().insert( - AcpBetaFeatureFlag::NAME.to_string(), - if enabled { "on" } else { "off" }.to_string(), - ); - }); - cx.set_global(store); - cx.update_flags(false, Vec::new()); - } - async fn connect_config_defaults_test_agent( cx: &mut gpui::TestAppContext, ) -> ( @@ -4653,13 +4519,6 @@ fn handle_create_elicitation( cx: &mut AsyncApp, ctx: &ClientContext, ) { - if !cx.update(|cx| cx.has_flag::()) { - return respond_err( - responder, - acp::Error::invalid_params().data("elicitation support requires the ACP beta flag"), - ); - } - match args.scope() { acp::ElicitationScope::Session(scope) => { let thread = match session_thread(ctx, &scope.session_id) { @@ -4748,10 +4607,6 @@ fn handle_complete_elicitation( cx: &mut AsyncApp, ctx: &ClientContext, ) { - if !cx.update(|cx| cx.has_flag::()) { - return; - } - let threads = ctx .sessions .borrow() diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 5d884e8a6ce..e1cce89d3b0 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -414,7 +414,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, @@ -432,6 +432,24 @@ pub struct SandboxPermissions { /// tool/prompt in place — see `agent::sandboxing`. pub allow_unsandboxed: bool, 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, +} + +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, + } + } } #[derive(Clone, Debug, Default)] @@ -821,6 +839,7 @@ fn compile_sandbox_permissions( allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), write_paths, + warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true), } } @@ -1098,6 +1117,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] diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b25804d724..3ccf5297380 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -63,6 +63,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 @@ -108,6 +109,7 @@ theme_settings.workspace = true time.workspace = true ui.workspace = true ui_input.workspace = true +unicode-script.workspace = true unicode-segmentation.workspace = true url.workspace = true util.workspace = true @@ -143,6 +145,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/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index ba731ec3b9e..524cb0b0747 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| { @@ -1528,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(); @@ -1577,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); }); @@ -1629,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 7fe2d2c0b9f..8ff5e046a46 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -26,7 +26,7 @@ use zed_actions::{ agent::{ AddSelectionToThread, ConflictContent, LogoutAgent, OpenSettings, ReauthenticateAgent, ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent, - ResolveConflictsWithAgent, ReviewBranchDiff, + ResolveConflictsWithAgent, ReviewBranchDiff, SelectAgent, }, assistant::{ FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle, @@ -85,6 +85,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; @@ -96,9 +97,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"; @@ -422,6 +423,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); @@ -991,6 +1000,7 @@ struct AgentTerminal { working_directory: Option, created_at: DateTime, has_notification: bool, + search_bar: Option>, notification_windows: Vec>, notification_subscriptions: Vec, _subscriptions: Vec, @@ -1911,6 +1921,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>, @@ -1990,6 +2037,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + self.pending_terminal_spawn = Some(terminal_id); let terminal_working_directory = working_directory.clone(); let init_command = Self::terminal_init_command(run_init_command, cx); let terminal_task = self.project.update(cx, |project, cx| { @@ -2174,6 +2222,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], @@ -3177,18 +3226,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 @@ -3949,6 +3987,37 @@ impl AgentPanel { } } + 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, @@ -4467,19 +4536,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())); @@ -5499,6 +5556,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() @@ -5534,15 +5595,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| { @@ -5552,8 +5613,29 @@ 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(); } } @@ -5631,11 +5713,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 @@ -6372,6 +6454,7 @@ impl Render for AgentPanel { .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| { @@ -6396,9 +6479,34 @@ 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::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)); @@ -7256,6 +7364,34 @@ mod tests { }); } + #[gpui::test] + async fn test_explicit_terminal_blocks_redundant_auto_init(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.last_created_entry_kind = AgentPanelEntryKind::Terminal; + assert!( + matches!(panel.base_view, BaseView::Uninitialized), + "precondition: the panel starts uninitialized" + ); + assert!( + panel.pending_terminal_spawn.is_none(), + "precondition: no terminal spawn is in-flight yet" + ); + panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx); + let pending = panel.pending_terminal_spawn; + assert!( + pending.is_some(), + "an explicit new terminal must mark a spawn in-flight" + ); + panel.set_active(true, window, cx); + assert_eq!( + panel.pending_terminal_spawn, pending, + "activating the panel while a terminal spawn is in-flight must not schedule a second (auto-init) terminal" + ); + }); + } + #[gpui::test] async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; @@ -9038,7 +9174,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 @@ -11214,6 +11350,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); diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 897a8536624..a4fb6bfc60e 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 1949a5965a8..91ae944e700 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -35,6 +35,7 @@ pub mod thread_worktree_archive; pub mod threads_archive_view; mod ui; +mod unicode_confusables; use std::rc::Rc; use std::sync::Arc; @@ -47,8 +48,8 @@ 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,7 +66,7 @@ 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::ManageProfilesModal; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; @@ -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"; @@ -857,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); } _ => {} @@ -880,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 @@ -905,6 +941,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { registry.select_commit_message_model(commit_message.as_ref(), cx); registry.select_thread_summary_model(thread_summary.as_ref(), cx); registry.select_inline_alternative_models(inline_alternatives, cx); + registry.set_should_use_fallback(should_use_fallback); }); } diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1fee158f4a1..022bf5c6585 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 d25eae39064..ccc95c2fc4e 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -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 @@ -959,15 +1014,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::Command), + .map(SlashCompletionCandidate::Skill), + ); + candidates.extend( + self.source + .available_local_commands(cx) + .into_iter() + .map(SlashCompletionCandidate::LocalCommand), ); if candidates.is_empty() { return Task::ready(Vec::new()); @@ -1429,7 +1490,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 +1606,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(); diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 4dcd72cbdf9..df4eecaccd5 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -5,7 +5,6 @@ use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ @@ -99,9 +98,8 @@ impl ConfigOptionsView { favorites_only: bool, cx: &mut Context, ) -> bool { - let render_boolean_config_options = should_render_boolean_config_options(cx); let Some(config_id) = self.first_config_option_id_matching(category, |option| { - Self::can_cycle_config_option(option, favorites_only, render_boolean_config_options) + Self::can_cycle_config_option(option, favorites_only) }) else { return false; }; @@ -144,14 +142,10 @@ impl ConfigOptionsView { .map(|option| option.id) } - fn can_cycle_config_option( - option: &acp::SessionConfigOption, - favorites_only: bool, - render_boolean_config_options: bool, - ) -> bool { + fn can_cycle_config_option(option: &acp::SessionConfigOption, favorites_only: bool) -> bool { match &option.kind { acp::SessionConfigKind::Select(_) => true, - acp::SessionConfigKind::Boolean(_) => !favorites_only && render_boolean_config_options, + acp::SessionConfigKind::Boolean(_) => !favorites_only, _ => false, } } @@ -213,7 +207,7 @@ impl ConfigOptionsView { )) } acp::SessionConfigKind::Boolean(boolean) => { - if favorites_only || !should_render_boolean_config_options(cx) { + if favorites_only { None } else { Some(acp::SessionConfigOptionValue::boolean( @@ -545,10 +539,6 @@ impl Render for ConfigOptionSelector { .into_any_element() } acp::SessionConfigKind::Boolean(boolean) => { - if !should_render_boolean_config_options(cx) { - return div().into_any_element(); - } - let option_id = option.id.clone(); let option_name: SharedString = option.name.clone().into(); let option_description: Option = @@ -991,10 +981,6 @@ fn setting_value_for_config_option_value( } } -fn should_render_boolean_config_options(cx: &App) -> bool { - cx.has_flag::() -} - fn options_to_picker_entries( options: &[ConfigOptionValue], favorites: &HashSet, @@ -1110,9 +1096,8 @@ fn count_config_options(option: &acp::SessionConfigOption) -> usize { mod tests { use super::*; use acp_thread::AgentConnection; - use feature_flags::FeatureFlag as _; use fs::FakeFs; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::TestAppContext; use parking_lot::Mutex; use project::{AgentId, Project}; use std::{any::Any, cell::RefCell}; @@ -1178,9 +1163,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1215,41 +1197,7 @@ mod tests { } #[gpui::test] - fn cycling_hidden_boolean_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::ModelConfig), - ])); - let fs: Arc = FakeFs::new(cx.executor()); - - cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", 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!(agent_server.saved_defaults.lock().is_empty()); - assert!(config_options.set_values.borrow().is_empty()); - } - - #[gpui::test] - fn cycling_category_skips_hidden_boolean_config_option(cx: &mut TestAppContext) { + 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) @@ -1268,9 +1216,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1291,15 +1236,15 @@ mod tests { assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( - "model".to_string(), - Some(AgentConfigOptionValue::ValueId("large".to_string())) + "web_search".to_string(), + Some(AgentConfigOptionValue::Boolean(true)) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( - "model".to_string(), - acp::SessionConfigOptionValue::value_id("large") + "web_search".to_string(), + acp::SessionConfigOptionValue::boolean(true) )] ); } @@ -1330,45 +1275,11 @@ mod tests { assert!(!handled); } - #[gpui::test] - fn boolean_config_option_rendering_is_beta_gated(cx: &mut TestAppContext) { - cx.update(|cx| { - init_feature_flag_settings(cx); - - cx.update_flags(false, Vec::new()); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - assert!(!should_render_boolean_config_options(cx)); - - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - assert!(should_render_boolean_config_options(cx)); - }); - } - #[derive(Default)] struct TestAgentServer { saved_defaults: Arc)>>>, } - fn init_feature_flag_settings(cx: &mut App) { - let store = SettingsStore::test(cx); - cx.set_global(store); - SettingsStore::update_global(cx, |store, _| { - store.register_setting::(); - }); - cx.update_flags(false, Vec::new()); - } - - fn set_feature_flag_override(name: &str, value: &str, cx: &mut App) { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |content| { - content - .feature_flags - .get_or_insert_default() - .insert(name.to_string(), value.to_string()); - }); - }); - } - impl AgentServer for TestAgentServer { fn logo(&self) -> IconName { IconName::ZedAssistant diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 350d9c2f912..7b6dd48ca65 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -23,19 +23,18 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AcpBetaFeatureFlag, 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, - TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, + 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, }; @@ -277,7 +276,7 @@ impl Conversation { let session_id = thread.read(cx).session_id().clone(); let subscription = cx.subscribe(&thread, { let session_id = session_id.clone(); - move |this, _thread, event, cx| { + move |this, _thread, event, _cx| { this.updated_at = Some(Instant::now()); match event { AcpThreadEvent::ToolAuthorizationRequested(id) => { @@ -294,15 +293,12 @@ impl Conversation { } } } - AcpThreadEvent::ElicitationRequested(id) - if cx.has_flag::() => - { + AcpThreadEvent::ElicitationRequested(id) => { this.elicitation_requests .entry(session_id.clone()) .or_default() .push(id.clone()); } - AcpThreadEvent::ElicitationRequested(_) => {} AcpThreadEvent::ElicitationResponded(id) => { if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) { elicitations.retain(|elicitation_id| elicitation_id != id); @@ -422,10 +418,6 @@ impl Conversation { response: acp::CreateElicitationResponse, cx: &mut Context, ) -> Option<()> { - if !cx.has_flag::() { - return None; - } - let thread = self.threads.get(&session_id)?.clone(); thread.update(cx, |thread, cx| { thread.respond_to_elicitation(&elicitation_id, response, cx); @@ -758,9 +750,7 @@ enum AuthState { Ok, Unauthenticated { description: Option>, - configuration_view: Option, pending_auth_method: Option, - _subscription: Option, }, } @@ -1160,14 +1150,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; @@ -1449,55 +1432,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; @@ -1661,7 +1606,7 @@ impl ConversationView { }); active.update(cx, |active, cx| { active.sync_elicitation_state_for_entry(index, window, cx); - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); active.sync_generating_indicator(cx); }); } @@ -1688,7 +1633,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); }); } } @@ -1699,10 +1644,9 @@ impl ConversationView { self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } AcpThreadEvent::ToolAuthorizationReceived(_) => {} - AcpThreadEvent::ElicitationRequested(_) if cx.has_flag::() => { + AcpThreadEvent::ElicitationRequested(_) => { self.notify_with_sound("Waiting for input", IconName::Info, window, cx); } - AcpThreadEvent::ElicitationRequested(_) => {} AcpThreadEvent::ElicitationResponded(_) => {} AcpThreadEvent::Retry(retry) => { if let Some(active) = self.thread_view(&session_id) { @@ -1963,7 +1907,6 @@ impl ConversationView { let connection = connected.connection.clone(); let AuthState::Unauthenticated { - configuration_view, pending_auth_method, .. } = &mut connected.auth_state @@ -1974,7 +1917,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(); @@ -2043,7 +1985,6 @@ impl ConversationView { return; } - configuration_view.take(); pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); @@ -2292,7 +2233,6 @@ impl ConversationView { &self, connection: &Rc, description: Option<&Entity>, - configuration_view: Option<&AnyView>, pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, @@ -2305,10 +2245,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( @@ -2383,12 +2321,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), @@ -2405,11 +2338,6 @@ impl ConversationView { } fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { - if !cx.has_flag::() { - self.request_elicitation_form_states.clear(); - return; - } - let Some(store) = self.request_elicitation_store() else { self.request_elicitation_form_states.clear(); return; @@ -2455,10 +2383,6 @@ impl ConversationView { view: WeakEntity, cx: &App, ) -> Vec { - if !cx.has_flag::() { - return Vec::new(); - } - let Some(store) = connection.request_elicitations() else { return Vec::new(); }; @@ -2587,10 +2511,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - let Some(store) = self.request_elicitation_store() else { return; }; @@ -2639,10 +2559,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_request_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), @@ -2656,10 +2572,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_request_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), @@ -3245,7 +3157,6 @@ 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)); @@ -3256,7 +3167,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); }) } @@ -3288,9 +3199,7 @@ impl ConversationView { 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() @@ -3431,9 +3340,7 @@ impl Render for ConversationView { auth_state: AuthState::Unauthenticated { description, - configuration_view, pending_auth_method, - _subscription, }, .. }) => v_flex() @@ -3443,7 +3350,6 @@ impl Render for ConversationView { .child(self.render_auth_required_state( connection, description.as_ref(), - configuration_view.as_ref(), pending_auth_method.as_ref(), window, cx, @@ -3698,7 +3604,7 @@ pub(crate) mod tests { use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; use editor::actions::Paste; - use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _}; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size}; use parking_lot::Mutex; diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs index ae3f3a81b85..3eaaa0652bd 100644 --- a/crates/agent_ui/src/conversation_view/elicitation.rs +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -16,6 +16,7 @@ use ui::{ struct ElicitationOption { value: String, label: SharedString, + description: Option, } enum ElicitationFieldState { @@ -429,6 +430,51 @@ mod tests { ); } + #[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); @@ -773,8 +819,10 @@ fn preview_form_schema() -> acp::ElicitationSchema { .title("Environment") .description("Select the environment this credential should target.") .one_of(vec![ - acp::EnumOption::new("production", "Production"), - acp::EnumOption::new("staging", "Staging"), + 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"), @@ -783,8 +831,10 @@ fn preview_form_schema() -> acp::ElicitationSchema { .property( "scopes", acp::MultiSelectPropertySchema::titled(vec![ - acp::EnumOption::new("profile", "Profile"), - acp::EnumOption::new("repository", "Repository Access"), + 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") @@ -810,6 +860,7 @@ fn single_select_options(schema: &acp::StringPropertySchema) -> Vec Vec Vec { match &schema.items { - acp::MultiSelectItems::Untitled(items) => 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 @@ -857,6 +910,7 @@ fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec Vec::new(), @@ -1310,11 +1364,7 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child( - div() - .mt_0p5() - .child(Checkbox::new(checkbox_id, checkbox_state)), - ) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) .child( v_flex() .gap_0p5() @@ -1412,8 +1462,8 @@ impl<'a> ElicitationCard<'a> { ))) .w_full() .min_h(rems_from_px(28.)) - .items_center() - .gap_2() + .items_start() + .gap_1p5() .rounded_sm() .border_1() .border_color(field_border_color.opacity(0.5)) @@ -1430,8 +1480,8 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child(Checkbox::new(checkbox_id, checkbox_state)) - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child(Self::render_option_content(option)) })) .into_any_element() } @@ -1479,8 +1529,8 @@ impl<'a> ElicitationCard<'a> { .id(option_id) .w_full() .min_h(rems_from_px(28.)) - .items_center() - .gap_2() + .items_start() + .gap_1p5() .rounded_sm() .border_1() .border_color(border_color.opacity(0.5)) @@ -1496,16 +1546,39 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child(Self::render_radio_indicator( - is_selected, - border_color, - control_background, - )) - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .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 { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index fc0f5b96a58..5a2ae6fd725 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -20,12 +20,12 @@ use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; -use crate::completion_provider::AvailableSkill; +use crate::completion_provider::{AvailableSkill, PromptLocalCommand}; use crate::message_editor::SharedSessionCapabilities; use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip}; +use crate::unicode_confusables; use db::kvp::KeyValueStore; use gpui::List; @@ -36,10 +36,11 @@ 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::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; @@ -588,6 +589,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, @@ -923,6 +928,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| { @@ -981,6 +1000,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, @@ -1021,7 +1041,7 @@ 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(); @@ -1109,11 +1129,48 @@ impl ThreadView { } 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, @@ -1250,6 +1307,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 { @@ -1451,13 +1509,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, @@ -2322,27 +2378,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(); } @@ -2455,13 +2491,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); } @@ -2487,26 +2550,18 @@ impl ThreadView { Some(()) } - fn is_waiting_for_confirmation(&self, entry: &AgentThreadEntry, cx: &Context) -> bool { - match entry { - AgentThreadEntry::ToolCall(tool_call) => { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } - AgentThreadEntry::Elicitation(elicitation_id) => { - cx.has_flag::() - && self - .thread - .read(cx) - .elicitation(elicitation_id) - .is_some_and(|(_, elicitation)| { + 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 { .. }) }) - } - _ => false, - } + }) + }) + .unwrap_or(false) } pub fn sync_elicitation_state_for_entry( @@ -2524,11 +2579,6 @@ impl ThreadView { elicitation_id.clone() }; - if !cx.has_flag::() { - self.elicitation_form_states.remove(&elicitation_id); - return; - } - let thread = self.thread.read(cx); let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| { ( @@ -2574,10 +2624,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - let mode = self .thread .read(cx) @@ -2623,10 +2669,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), @@ -2640,10 +2682,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), @@ -4862,7 +4900,7 @@ impl ThreadView { ); }), ) - .child(Divider::vertical().h_4()) + .child(Divider::vertical()) .into_any_element(), ) } @@ -5979,9 +6017,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| this.is_waiting_for_confirmation(entry, cx)); + 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 { @@ -6302,8 +6339,7 @@ impl ThreadView { } AgentThreadEntry::Elicitation(elicitation_id) => { let thread = self.thread.read(cx); - if cx.has_flag::() - && let Some((_, elicitation)) = thread.elicitation(elicitation_id) + if let Some((_, elicitation)) = thread.elicitation(elicitation_id) && should_render_elicitation(elicitation) { let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx); @@ -6395,7 +6431,8 @@ impl ThreadView { primary }; - let needs_confirmation = self.is_waiting_for_confirmation(entry, cx); + let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() + || self.has_pending_request_elicitation(cx); let comments_editor = self.thread_feedback.comments_editor.clone(); @@ -6580,42 +6617,50 @@ impl ThreadView { cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { 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 last_response_index = thread + .read(cx) + .entries() + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); + + let copy_response_button = last_response_index.map(|response_index| { + IconButton::new("copy_agent_response", 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", IconName::ForwardArrow) - .shape(ui::IconButtonShape::Square) + IconButton::new("scroll_to_recent_user_prompt", IconName::UserArrowUp) .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 Most Recent User Message")) .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")) + .icon_color(Color::Muted) + .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 @@ -6643,29 +6688,98 @@ impl ThreadView { }) .flatten(); - let mut container = h_flex() + let feedback_buttons = (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); + })), + ) + }, + ); + + 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)) + .gap_1() + .when_some(last_turn_tokens_label, |this, label| { + this.child(label).child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + }) .when_some(last_turn_clock, |this, label| this.child(label)), ) }, - ); + ) + .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) + .child(scroll_to_top) + .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 { @@ -6675,72 +6789,28 @@ 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; + // 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(); - 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 self.is_thread_feedback_enabled(cx) { + commands.push(PromptLocalCommand::ThumbsUp); + commands.push(PromptLocalCommand::ThumbsDown); } - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() + commands + } + + // 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); } fn render_request_elicitations(&self, cx: &Context) -> Vec { @@ -7020,11 +7090,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, @@ -7328,7 +7408,7 @@ impl ThreadView { 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); @@ -7847,6 +7927,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, @@ -7854,6 +7935,7 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + allow_disabled, cx, )) }) @@ -7867,62 +7949,44 @@ impl ThreadView { reason: &SandboxNotAppliedReason, cx: &Context, ) -> AnyElement { - // (title, optional detail line) - let (title, detail): (SharedString, Option) = match reason { - SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( - "Couldn't create a sandbox".into(), - Some(error.user_facing_message().into()), - ), - 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) - .map(|error| { - SharedString::from(format!( - "Allowed for this thread after the sandbox failed: {}", - error.user_facing_message() - )) - }) - .unwrap_or_else(|| { - "Unsandboxed execution is allowed for the rest of this thread.".into() - }); - ("Ran without sandbox".into(), Some(detail)) - } - }; + // (title, detail line, docs section slug) + let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) = + match reason { + SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( + "Couldn't create a sandbox".into(), + 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 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: {}", + error.user_facing_message() + )) + }) + .unwrap_or_else(|| { + "Unsandboxed execution is allowed for the rest of this thread.".into() + }); + 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() - .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), - ) - }), - ), - ) + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title(title) + .description(detail) + .actions_slot(self.render_sandbox_docs_link( + "sandbox-not-applied-docs-link", + docs_section, + cx, + )) .into_any_element() } @@ -8070,7 +8134,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) @@ -8078,8 +8148,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) @@ -8117,6 +8185,7 @@ impl ThreadView { entry_ix, &tool_call.id, details, + window, cx, )) }, @@ -8225,6 +8294,7 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + self.sandbox_confusables_block_allow(tool_call, cx), cx, )) .into_any() @@ -8531,11 +8601,42 @@ impl ThreadView { .children(tool_output_display) } + /// 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); + let tooltip = format!("Opens {url}"); + // Wrap in a row so the button shrinks to its content width instead of + // stretching to fill the enclosing column. + h_flex() + .child( + Button::new(id, "Learn more") + .label_size(LabelSize::Small) + .color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)) + .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(); @@ -8544,6 +8645,12 @@ impl ThreadView { 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() @@ -8771,10 +8878,186 @@ impl ThreadView { v_flex() .border_t_1() .border_color(self.tool_card_border_color(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) + .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 path in &details.write_paths { + let display = path.display().to_string(); + 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() } @@ -8810,7 +9093,12 @@ 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() } @@ -8878,6 +9166,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 { @@ -8888,6 +9179,7 @@ impl ThreadView { entry_ix, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown( @@ -8898,6 +9190,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::DropdownWithPatterns { @@ -8912,6 +9205,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), } @@ -8926,6 +9220,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); @@ -8980,13 +9275,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, @@ -9312,6 +9608,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(); @@ -9375,13 +9672,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; } @@ -10601,13 +10907,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!( @@ -10708,6 +11018,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."; @@ -10764,7 +11169,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() { @@ -10777,7 +11181,6 @@ impl ThreadView { ConversationView::handle_auth_required( server_view, AuthRequired::new(), - agent_name, connection, window, cx, @@ -11523,6 +11926,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(); @@ -11886,19 +12294,31 @@ 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); + 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 } => { @@ -11922,7 +12342,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, ); @@ -11935,7 +12355,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, ); @@ -12022,6 +12442,7 @@ mod tests { use super::*; use project::{FakeFs, Project}; use serde_json::json; + use std::path::Path; use util::path; use workspace::MultiWorkspace; @@ -12126,6 +12547,118 @@ 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", + "a b.rs": "decoded", + "c d.rs": "", + "e%20f.rs": "", + }), + ) + .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"); + } + + #[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 5a2423cae65..75c21d68b75 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/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index e2ad7ca71ec..bdc79beacc9 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::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, @@ -682,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 }) @@ -710,7 +713,6 @@ mod tests { use agent_client_protocol::schema::v1 as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; - use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{AppContext as _, TestAppContext}; use parking_lot::RwLock; @@ -848,11 +850,8 @@ mod tests { } #[gpui::test] - async fn test_hidden_elicitation_preserves_entry_index(cx: &mut TestAppContext) { + async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; @@ -897,7 +896,6 @@ mod tests { )), cx, ); - cx.update_flags(false, vec![]); }); let view_state = cx.new(|_cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index f262e44f506..f9878d6c5ad 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -5,7 +5,7 @@ use crate::{ completion_provider::{ AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, - SlashCommandCompletion, + PromptLocalCommand, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; @@ -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, @@ -2211,8 +2239,9 @@ 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::v1 as acp; @@ -2909,6 +2938,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 diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 97f45526cdc..6959b5bc4c0 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -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/unicode_confusables.rs b/crates/agent_ui/src/unicode_confusables.rs new file mode 100644 index 00000000000..3b1ea11eb21 --- /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/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 676b43b02b7..cf8d547cc3e 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -19,7 +19,14 @@ 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-4-6" | "claude-opus-4-7" | "claude-opus-4-8" + ) +} pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; @@ -156,10 +163,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!( diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7c9a8c53695..884ed1a1ddd 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -3,9 +3,9 @@ use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; @@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca } } -fn to_anthropic_content(content: MessageContent) -> Option { +fn to_anthropic_content(content: MessageContent) -> Result> { match content { MessageContent::Text(text) => { let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { @@ -77,12 +77,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 +92,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 +152,24 @@ 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 { + Ok(Some(RequestContent::Compaction { 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, + ) => Ok(None), } } @@ -175,7 +180,7 @@ pub fn into_anthropic( max_output_tokens: u64, mode: AnthropicModelMode, cache_mode: AnthropicPromptCacheMode, -) -> crate::Request { +) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -189,11 +194,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)? { + anthropic_message_content.push(content); + } + } let anthropic_role = match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Assistant, @@ -261,21 +267,29 @@ 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 { + Ok(crate::Request { model, messages: new_messages, max_tokens: max_output_tokens, @@ -339,7 +353,7 @@ pub fn into_anthropic( trigger: Some(CompactionTrigger::InputTokens { value }), }], }), - } + }) } pub struct AnthropicEventMapper { @@ -451,7 +465,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, }, ))]; @@ -469,7 +483,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, }, @@ -597,12 +611,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 +631,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. @@ -703,12 +718,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 +738,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Legacy, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -781,7 +797,8 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert_eq!( anthropic_request @@ -813,12 +830,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 +850,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -879,6 +897,7 @@ mod tests { }, AnthropicPromptCacheMode::Automatic, ) + .unwrap() } #[test] @@ -977,7 +996,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Disabled, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&anthropic_request.context_management).unwrap(), diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 9786aa84d16..4193e8185cd 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,43 +1728,72 @@ 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] fn test_nightly_does_update_when_installed_versions_sha_cannot_be_retrieved() { let release_channel = ReleaseChannel::Nightly; 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/models.rs b/crates/bedrock/src/models.rs index a2f299a3c9e..cd035be8198 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -46,17 +46,64 @@ 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, + ClaudeFable5, + #[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" + )] + ClaudeOpus4_8, + #[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" + )] + ClaudeOpus4_7, + #[serde( + rename = "claude-opus-4-6", + alias = "claude-opus-4-6-latest", + alias = "claude-opus-4-6-thinking", + alias = "claude-opus-4-6-thinking-latest" + )] + ClaudeOpus4_6, + #[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" + )] + ClaudeOpus4_5, + #[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" + )] + 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", + alias = "claude-sonnet-4-6-thinking", + alias = "claude-sonnet-4-6-thinking-latest" + )] + ClaudeSonnet4_6, #[default] #[serde( rename = "claude-sonnet-4-5", @@ -66,47 +113,14 @@ pub enum Model { )] ClaudeSonnet4_5, #[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-sonnet-4", + alias = "claude-sonnet-4-latest", + alias = "claude-sonnet-4-thinking", + alias = "claude-sonnet-4-thinking-latest" )] - ClaudeOpus4_1, - #[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" - )] - ClaudeOpus4_5, - #[serde( - rename = "claude-opus-4-6", - alias = "claude-opus-4-6-latest", - alias = "claude-opus-4-6-thinking", - alias = "claude-opus-4-6-thinking-latest" - )] - 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" - )] - ClaudeOpus4_7, - #[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" - )] - ClaudeOpus4_8, - #[serde( - rename = "claude-sonnet-4-6", - alias = "claude-sonnet-4-6-latest", - alias = "claude-sonnet-4-6-thinking", - alias = "claude-sonnet-4-6-thinking-latest" - )] - ClaudeSonnet4_6, + 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 +227,15 @@ 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-4-8") { Ok(Self::ClaudeOpus4_8) } else if id.starts_with("claude-opus-4-7") { Ok(Self::ClaudeOpus4_7) @@ -229,6 +245,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 +262,17 @@ 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::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 +315,17 @@ 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::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 +368,17 @@ 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::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 +423,16 @@ 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::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 +460,17 @@ impl Model { pub fn max_output_tokens(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 + Self::ClaudeFable5 + | 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 +502,17 @@ 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::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 +523,17 @@ 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::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 +557,17 @@ 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::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 +578,17 @@ 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::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 +600,37 @@ 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::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::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::ClaudeOpus4_8 | Self::ClaudeSonnet5 + ) } pub fn thinking_mode(&self) -> BedrockModelMode { @@ -608,14 +656,16 @@ 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::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 +719,16 @@ 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::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 +738,17 @@ 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::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 @@ -711,13 +765,13 @@ impl Model { // EU region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -726,61 +780,199 @@ impl Model { // Australia region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6, + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5, "au", ) => Ok(format!("{}.{}", region_group, model_id)), // Japan region inference profiles ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 + | 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.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_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_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_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_5 | Self::Gpt5_4 | Self::Grok4_3 => MantleProtocol::Responses, + Self::Custom { protocol, .. } => *protocol, + } + } + + pub fn max_token_count(&self) -> u64 { + match self { + 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 GPT-5.5/5.4 on Mantle. + 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_5 | Self::Gpt5_4 | Self::Grok4_3 => true, + Self::Custom { supports_tools, .. } => *supports_tools, + } + } + + pub fn supports_images(&self) -> bool { + match self { + 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_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_5.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Gpt5_4.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Grok4_3.protocol(), MantleProtocol::Responses); + } + #[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::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 +981,61 @@ 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" ); 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,23 +1044,23 @@ 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" ); Ok(()) @@ -856,15 +1069,15 @@ mod tests { #[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" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, + 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,7 +1086,7 @@ 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" ); Ok(()) @@ -882,11 +1095,11 @@ mod tests { #[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 +1108,45 @@ 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::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 +1156,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 +1184,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 +1206,90 @@ 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::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::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-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!(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!(!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::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::ClaudeSonnet5.supports_adaptive_thinking()); + assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.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 +1298,44 @@ 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::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::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 c300ace11ae..5cba96c5a92 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -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, + } } } @@ -1474,7 +1539,6 @@ pub struct DiffChanged { pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - HunksStagedOrUnstaged(Option), } impl EventEmitter for BufferDiff {} @@ -1594,24 +1658,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 +1856,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( @@ -2837,6 +3038,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/client/src/test.rs b/crates/client/src/test.rs index 858bf499cd5..1770bce2302 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -240,7 +240,8 @@ pub fn make_get_authenticated_user_response( ) -> 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(), diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 0473ff3932a..d9cd6255226 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/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index e2f9d69002e..aab5f3b60da 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -95,16 +95,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,8 +111,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 + self.send_authenticated_json_request(request_builder, AsyncBody::default()) + .await } pub fn connect(&self, cx: &App) -> Result>> { @@ -171,11 +161,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 +183,33 @@ 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 = { + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; + build_request(request_builder, body, credentials) + .map_err(ClientApiError::RequestBuildFailed)? + }; + let host = self.cloud_host(); let mut response = self.http_client.send(request).await.map_err(|source| { ClientApiError::ConnectionFailed { @@ -285,16 +286,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)?), - )?; + 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).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -302,16 +301,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)?), - )?; + 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).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -319,16 +316,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)?), - )?; + 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).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } } diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index a06e0268c96..b02f5756cce 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -36,7 +36,8 @@ 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, @@ -71,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); diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index a33a2e8cc1a..adbcffe9be8 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -196,6 +196,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 diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 64de772aec1..c5889ae6663 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/src/auth.rs b/crates/collab/src/auth.rs index 95cb4626b3e..7f0fa5b7a8a 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -67,7 +67,7 @@ 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, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 60e49fc39a6..d8dad9fec13 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -525,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 diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c0ea34bf3aa..431350971ce 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1599,6 +1599,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, @@ -2007,6 +2009,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::>(); @@ -2059,6 +2063,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, diff --git a/crates/collab/tests/integration/channel_tests.rs b/crates/collab/tests/integration/channel_tests.rs index 329478819e4..7b20620082d 100644 --- a/crates/collab/tests/integration/channel_tests.rs +++ b/crates/collab/tests/integration/channel_tests.rs @@ -589,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/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index c4650971d2a..38cac226bad 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -7310,6 +7310,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/test_server.rs b/crates/collab/tests/integration/test_server.rs index 62169c9a938..3434674e630 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, diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 3b8a6e770e4..e1eddf01c28 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -126,7 +126,9 @@ impl CommandPalette { let picker = cx.new(|cx| { // One-shot action; there's nothing to reopen. - let picker = Picker::uniform_list(delegate, window, cx).reopenable(false, cx); + let picker = Picker::uniform_list(delegate, window, cx) + .reopenable(false, cx) + .show_scrollbar(true); picker.set_query(query, window, cx); picker }); diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index b8a321d01b5..3761f19ec4a 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}, }; @@ -56,19 +57,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>>, } @@ -348,6 +349,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 05a3451ea86..865f779b160 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 05082637c27..6eb4a84a46c 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 bffd7e4c4d8..edb8a5e8da0 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 bf374586b35..aa47a73fc38 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)); } } @@ -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 { diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index 14b9fe43679..ba5935c65ed 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 4eb73b6d82e..5741d613485 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,12 +56,13 @@ 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::Floating, @@ -68,9 +70,11 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win 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)), diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 322777da042..8f8ba768754 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -8,7 +8,7 @@ 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::*, @@ -41,6 +41,10 @@ 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, @@ -178,9 +182,11 @@ 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(px(24.)), settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -194,22 +200,54 @@ 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(); + // Approximation of single csv table row height. Will be re-measured on scrolling. + // This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call + let approximate_height = px(24.); + view.list_state + .reset_with_uniform_height(visible_rows, approximate_height); + cx.notify(); + }) + .ok(); + })); } pub fn resolve_active_item_as_csv_editor( @@ -301,7 +339,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 efa3573d7aa..116c8912a38 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 3d0cf50cf1d..d9e7ce6ad9b 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 90500d53d06..335633656af 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use ui::{div, prelude::*}; +use ui::{SpinnerLabel, div, prelude::*}; use crate::CsvPreviewView; @@ -11,12 +11,12 @@ impl Render for CsvPreviewView { 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 +25,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/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 06a26e4696e..e24441d3885 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -171,6 +171,7 @@ impl CsvPreviewView { .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 diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index cafa2a4c1bd..18c185bb0f3 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -3,7 +3,10 @@ use ui::{ IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, }; -use crate::{CsvPreviewView, settings::VerticalAlignment}; +use crate::{ + CsvPreviewView, + settings::{FilterSortOrder, VerticalAlignment}, +}; ///// Settings related ///// impl CsvPreviewView { @@ -18,6 +21,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 +48,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,6 +97,28 @@ 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", + )), + ), ); #[cfg(feature = "dev-tools")] diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 05652b49a48..1ce8d34d22b 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -1,9 +1,13 @@ use gpui::ElementId; -use ui::{Tooltip, prelude::*}; +use ui::{ContextMenu, 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, }; @@ -22,7 +26,12 @@ impl CsvPreviewView { .w_full() .font_buffer(cx) .child(div().child(header_text)) - .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .child( + h_flex() + .gap_1() + .child(self.create_filter_button(cx, col_idx)) + .child(self.create_sort_button(cx, col_idx)), + ) .into_any_element() } @@ -82,9 +91,191 @@ impl CsvPreviewView { }; 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); + + PopoverMenu::new(ElementId::NamedInteger( + "filter-menu".into(), + col.get() as u64, + )) + .trigger_with_tooltip( + Button::new( + ElementId::NamedInteger("filter-button".into(), col.get() as u64), + if has_active_filters { "⛊" } else { "⛉" }, + ) + .size(ButtonSize::Compact) + .style(if has_active_filters { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }), + 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 filter_sort_order = view.settings.filter_sort_order; + let filter_menu = Self::create_filter_menu( + window, + cx, + view_entity.clone(), + col, + &column_filters, + has_active_filters, + filter_sort_order, + ); + Some(filter_menu) + } + }) + } + + fn create_filter_menu( + window: &mut ui::Window, + cx: &mut ui::App, + view_entity: gpui::Entity, + col: AnyColumn, + column_filters: &[(FilterEntry, FilterEntryState)], + has_active_filters: bool, + sort_order: FilterSortOrder, + ) -> gpui::Entity { + let mut available: Vec<&FilterEntry> = column_filters + .iter() + .filter_map(|(entry, state)| { + matches!(state, FilterEntryState::Available { .. }).then_some(entry) + }) + .collect(); + + match sort_order { + FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| { + a.content + .cmp(&b.content) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + }), + FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| { + b.occurred_times() + .cmp(&a.occurred_times()) + .then_with(|| a.content.cmp(&b.content)) + }), + } + + let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Unavailable { blocked_by } = state { + Some((entry, *blocked_by)) + } else { + None + } + }) + .collect(); + + // Pre-build applied-state lookup before moving into the closure + let applied_states: Vec<(FilterEntry, bool)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Available { is_applied } = state { + Some((entry.clone(), *is_applied)) + } else { + None + } + }) + .collect(); + + let available_cloned: Vec = available.iter().map(|e| (*e).clone()).collect(); + let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable + .into_iter() + .map(|(e, col)| (e.clone(), col)) + .collect(); + + ContextMenu::build(window, cx, move |menu, _, _| { + let mut menu = menu; + + if has_active_filters { + menu = menu + .toggleable_entry("Clear all", false, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.clear_filters(col, cx); + cx.notify(); + }); + } + }) + .separator(); + } + + for entry in &available_cloned { + let is_applied = applied_states + .iter() + .find(|(e, _)| e.content == entry.content) + .map_or(false, |(_, applied)| *applied); + + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + let entry_value = entry.content.clone(); + + menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.toggle_filter(col, entry_value.clone(), cx); + cx.notify(); + }); + } + }); + } + + if !unavailable_cloned.is_empty() { + menu = menu.separator().header("Hidden by other filters"); + for (entry, _blocked_by) in &unavailable_cloned { + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + menu = menu.custom_entry( + { + let label = label.clone(); + move |_window, cx| { + div() + .px_2() + .text_color(cx.theme().colors().text_muted) + .child(label.clone()) + .into_any_element() + } + }, + |_, _| {}, + ); + } + } + + menu + }) + } +} + +fn format_filter_label(content: Option<&SharedString>, count: usize) -> String { + match content { + Some(s) => format!("{s} ({count})"), + None => format!(" ({count})"), + } } diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 215d681c28f..2d5cc78c5f5 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,11 +26,21 @@ 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, diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs index 382b41a2850..f2613215fa5 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 00000000000..b2b8a78c05a --- /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/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index fb2e4f6cd6b..1f2b2373819 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -2793,54 +2793,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 +2877,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( @@ -2961,8 +2991,9 @@ mod test { devcontainer_json::MountDefinition, devcontainer_manifest::{ ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources, - DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config, - image_from_dockerfile, is_local_feature_ref, resolve_compose_dockerfile, + DockerInspect, dockerfile_inject_alias, extract_feature_id, find_primary_service, + get_remote_user_from_config, image_from_dockerfile, is_local_feature_ref, + resolve_compose_dockerfile, }, docker::{ DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, @@ -5877,6 +5908,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 +6208,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() {} + 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() {} + 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_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_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, diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52..a422d2811db 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 00000000000..4f3f9195827 --- /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 df3b556d6a4..4b0c2ca04bc 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,7 +402,7 @@ 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")
@@ -694,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 {
@@ -733,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;
@@ -770,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>(
@@ -906,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 00000000000..43438207d8c
--- /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/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs
index a2a196d1352..dd8b866a6b1 100644
--- a/crates/edit_prediction/src/edit_prediction_tests.rs
+++ b/crates/edit_prediction/src/edit_prediction_tests.rs
@@ -1389,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);
@@ -1412,7 +1412,6 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
         assert!(shown_predictions[0].editable_range.is_some());
     });
 
-    // prediction is reported as rejected
     let (reject_request, _) = requests.reject.next().await.unwrap();
 
     assert_eq!(
@@ -1427,6 +1426,75 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+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(
+        "/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();
+
+    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
diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs
index ca6379fa6ee..592d5e3b1af 100644
--- a/crates/edit_prediction/src/prediction.rs
+++ b/crates/edit_prediction/src/prediction.rs
@@ -49,27 +49,35 @@ impl EditPredictionResult {
         e2e_latency: std::time::Duration,
         cx: &mut AsyncApp,
     ) -> Self {
-        let (edits, new_snapshot) = (!edits.is_empty())
-            .then(|| {
-                edited_buffer.read_with(cx, |buffer, _cx| {
-                    let new_snapshot = buffer.snapshot();
-                    let edits: Arc<[(Range, Arc)]> =
-                        interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)
-                            .map(Arc::from)
-                            .unwrap_or_default();
-                    let snapshot = (!edits.is_empty()).then_some(new_snapshot);
-                    (Some(edits), snapshot)
-                })
+        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,
+                    ),
+                }
             })
-            .unwrap_or_default();
-        let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone());
-
-        let reject_reason = match edits.as_ref() {
-            None => Some(EditPredictionRejectReason::Empty),
-            Some(edits) if edits.is_empty() => Some(EditPredictionRejectReason::InterpolatedEmpty),
-            Some(_) => None,
         };
-        let edits = edits.unwrap_or_default();
+        let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone());
 
         let edit_preview = if !edits.is_empty() {
             edited_buffer
@@ -96,6 +104,34 @@ impl EditPredictionResult {
             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,
+        }
+    }
 }
 
 #[derive(Clone)]
diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
index c3cb556c7b1..b7ae7d629ec 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 009d91c5b57..54b324253ad 100644
--- a/crates/edit_prediction/src/zeta.rs
+++ b/crates/edit_prediction/src/zeta.rs
@@ -9,7 +9,9 @@ use crate::{
     udiff::prediction_edits_for_single_file_diff,
 };
 use anyhow::{Context as _, Result};
-use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest};
+use cloud_llm_client::{
+    AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
+};
 use edit_prediction_types::PredictedCursorPosition;
 use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
 use language::{
@@ -516,26 +518,41 @@ pub(crate) fn request_prediction_with_zeta(
                 snapshot: fallback_snapshot,
                 patch,
             } => {
-                let Some((buffer, snapshot, edits, cursor_position)) =
-                    prediction_edits_for_single_file_diff(&patch, &project, cx).await?
-                else {
-                    return Ok(Some(
-                        EditPredictionResult::new(
-                            id,
-                            &fallback_buffer,
-                            &fallback_snapshot,
-                            Arc::new([]),
-                            None,
-                            None,
-                            inputs,
-                            model_version,
-                            trigger,
-                            request_duration,
-                            cx,
-                        )
-                        .await,
-                    ));
-                };
+                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()
diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs
index cdf503fa23c..754ca7d1ba9 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_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs
index a285e8aa70a..bcd1f5d8e62 100644
--- a/crates/edit_prediction_types/src/edit_prediction_types.rs
+++ b/crates/edit_prediction_types/src/edit_prediction_types.rs
@@ -392,5 +392,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/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
index 0dc4c1aaf29..e9d36860852 100644
--- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs
+++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
@@ -1253,11 +1253,10 @@ impl RatePredictionsModal {
                 };
 
                 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 1ca500832e2..b6df4a370fc 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 f0b9cfb4f5d..2bd544736a5 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -669,10 +669,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.
diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs
index 8c8c3a36e9a..3b90d1e2bc0 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);
@@ -52,7 +58,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 +103,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 +123,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 +145,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 +397,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 +409,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 +631,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 +667,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 +696,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 +708,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 +724,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 +743,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::();
@@ -477,8 +817,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 +835,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 +880,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 +903,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 +948,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 +971,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 +1027,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 +1059,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 +1090,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 +1121,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 +1182,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 +1693,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 +1733,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 +1764,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 +1793,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 +1889,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"
@@ -1592,9 +1945,9 @@ 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 2d3afdac12b..90818dbe313 100644
--- a/crates/editor/src/clipboard.rs
+++ b/crates/editor/src/clipboard.rs
@@ -354,6 +354,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 +381,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 +439,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);
     }
 
@@ -536,9 +601,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,
diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs
index 25ac00a495c..b192fa3d683 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 {
@@ -1691,6 +1666,42 @@ fn completion_kind_highlight_name(kind: CompletionItemKind) -> Option<&'static s
     })
 }
 
+fn split_completion_label<'a>(
+    text: &'a str,
+    filter_range: &Range,
+    highlights: &[(Range, HighlightStyle)],
+) -> (
+    (&'a str, Vec<(Range, HighlightStyle)>),
+    (&'a str, Vec<(Range, HighlightStyle)>),
+) {
+    let (main_text, suffix_text) = text.split_at(filter_range.end);
+    let main_highlights = highlights
+        .iter()
+        .filter_map(|(range, highlight)| {
+            if range.start >= filter_range.end {
+                return None;
+            }
+            let clamped_end = range.end.min(filter_range.end);
+            Some((range.start..clamped_end, *highlight))
+        })
+        .collect();
+    let suffix_highlights = 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();
+    (
+        (main_text, main_highlights),
+        (suffix_text, suffix_highlights),
+    )
+}
+
 fn exact_case_match_count(query: &str, string_match: &StringMatch) -> usize {
     let mut exact_matches = 0;
     let mut query_chars = query.chars();
@@ -2038,3 +2049,45 @@ impl CodeActionsMenu {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn bold() -> HighlightStyle {
+        FontWeight::BOLD.into()
+    }
+
+    fn colored() -> HighlightStyle {
+        HighlightStyle {
+            fade_out: Some(0.5),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn test_split_completion_label_keeps_prefix_before_filter_range() {
+        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+            split_completion_label("&some_str: String", &(1..9), &[(11..17, colored())]);
+
+        assert_eq!(main_text, "&some_str");
+        assert_eq!(suffix_text, ": String");
+        assert_eq!(main_highlights, vec![]);
+        assert_eq!(suffix_highlights, vec![(2..8, colored())]);
+    }
+
+    #[test]
+    fn test_split_completion_label_splits_boundary_spanning_highlight() {
+        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+            split_completion_label(
+                "await.as_deref_mut(&mut self)",
+                &(6..18),
+                &[(0..29, bold())],
+            );
+
+        assert_eq!(main_text, "await.as_deref_mut");
+        assert_eq!(suffix_text, "(&mut self)");
+        assert_eq!(main_highlights, vec![(0..18, bold())]);
+        assert_eq!(suffix_highlights, vec![(0..11, bold())]);
+    }
+}
diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs
index e2f0be07ce0..8ad37dcfd63 100644
--- a/crates/editor/src/completions.rs
+++ b/crates/editor/src/completions.rs
@@ -830,14 +830,19 @@ impl Editor {
         let old_text = buffer
             .text_for_range(replace_range.clone())
             .collect::();
-        let lookbehind = newest_range_buffer
-            .start
-            .to_offset(buffer_snapshot)
-            .saturating_sub(replace_range.start.to_offset(&buffer_snapshot));
-        let lookahead = replace_range
-            .end
-            .to_offset(&buffer_snapshot)
-            .saturating_sub(newest_range_buffer.end.to_offset(&buffer));
+        let (lookbehind, lookahead) = if buffer.remote_id() == buffer_snapshot.remote_id() {
+            let lookbehind = newest_range_buffer
+                .start
+                .to_offset(&buffer)
+                .saturating_sub(replace_range.start.to_offset(&buffer));
+            let lookahead = replace_range
+                .end
+                .to_offset(&buffer)
+                .saturating_sub(newest_range_buffer.end.to_offset(&buffer));
+            (lookbehind, lookahead)
+        } else {
+            (0, 0)
+        };
         let prefix = &old_text[..old_text.len().saturating_sub(lookahead)];
         let suffix = &old_text[lookbehind.min(old_text.len())..];
 
diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs
index 9b5df0b8671..bfda1aae44e 100644
--- a/crates/editor/src/config.rs
+++ b/crates/editor/src/config.rs
@@ -360,10 +360,6 @@ impl Editor {
         self.delegate_expand_excerpts = delegate;
     }
 
-    pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) {
-        self.delegate_stage_and_restore = delegate;
-    }
-
     pub(super) fn set_on_local_selections_changed(
         &mut self,
         callback: Option) + 'static>>,
diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs
index c8e1a4424b1..1a7b9d27d0d 100644
--- a/crates/editor/src/display_map/block_map.rs
+++ b/crates/editor/src/display_map/block_map.rs
@@ -963,9 +963,14 @@ impl BlockMap {
             // Ensure the edit starts at a transform boundary.
             // If the edit starts within an isomorphic transform, preserve its prefix
             // If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
-            let transform = cursor.item().unwrap();
+            // The cursor can sit past the end of the tree when a companion edit
+            // is anchored at the new end-of-file and maps to `old_start` at the
+            // very end of the old transforms; in that case there is no transform
+            // preceding the edit and nothing to preserve.
             let transform_rows_before_edit = old_start - *cursor.start();
-            if transform_rows_before_edit > RowDelta(0) {
+            if transform_rows_before_edit > RowDelta(0)
+                && let Some(transform) = cursor.item()
+            {
                 if transform.block.is_none() {
                     // Preserve any portion of the old isomorphic transform that precedes this edit.
                     push_isomorphic(
@@ -5019,6 +5024,61 @@ mod tests {
         );
     }
 
+    // Regression test for ZED-9V4: `BlockMap::sync` walks the (old) transform
+    // tree with a `WrapRow` cursor and used to `cursor.item().unwrap()` for
+    // every edit, assuming each edit's `old.start` lands strictly inside the
+    // tree. The companion (split-diff) branch of `sync` can compose an edit
+    // anchored at the trailing boundary of the old transforms
+    // (`old.start == input_rows`), at which point the cursor is past the end of
+    // the tree and `item()` is `None`. That used to abort the process.
+    #[gpui::test]
+    fn test_sync_edit_anchored_at_end_of_transforms(cx: &mut gpui::TestAppContext) {
+        cx.update(init_test);
+
+        let buffer = cx.update(|cx| MultiBuffer::build_simple("aaa\nbbb\nccc\n", cx));
+        let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx));
+        let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
+
+        let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
+        let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
+        let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
+        let (wrap_map, old_wrap_snapshot) =
+            cx.update(|cx| WrapMap::new(tab_snapshot, test_font(), px(14.0), None, cx));
+        let block_map = BlockMap::new(old_wrap_snapshot.clone(), 0, 0);
+
+        // The tree now spans exactly `old_end` input rows.
+        let old_end = old_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // Grow the buffer so the new snapshot is larger than the old transforms.
+        let buffer_snapshot = buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                [(Point::new(3, 0)..Point::new(3, 0), "ddd\neee\n")],
+                None,
+                cx,
+            );
+            buffer.snapshot(cx)
+        });
+        let (inlay_snapshot, inlay_edits) =
+            inlay_map.sync(buffer_snapshot, subscription.consume().into_inner());
+        let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
+        let (tab_snapshot, tab_edits) =
+            tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap());
+        let (new_wrap_snapshot, _) = wrap_map.update(cx, |wrap_map, cx| {
+            wrap_map.sync(tab_snapshot, tab_edits, cx)
+        });
+        let new_end = new_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // An edit anchored exactly at the end of the old transforms, of the shape
+        // the companion branch of `sync` can produce.
+        let edits = Patch::new(vec![text::Edit {
+            old: old_end..old_end,
+            new: old_end..new_end,
+        }]);
+
+        let snapshot = block_map.read(new_wrap_snapshot, edits, None);
+        assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n");
+    }
+
     fn init_test(cx: &mut gpui::App) {
         let settings = SettingsStore::test(cx);
         cx.set_global(settings);
diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs
index 43e9b2ff375..5d110eb3ce1 100644
--- a/crates/editor/src/edit_prediction.rs
+++ b/crates/editor/src/edit_prediction.rs
@@ -2295,7 +2295,7 @@ impl Editor {
         let file_name = snapshot
             .file()
             .map(|file| SharedString::new(file.file_name(cx)))
-            .unwrap_or(SharedString::new_static("untitled"));
+            .unwrap_or(SharedString::new_static(MultiBuffer::DEFAULT_TITLE));
 
         h_flex()
             .id("ep-jump-outside-popover")
@@ -2429,7 +2429,7 @@ impl Editor {
                 let file_name = snapshot
                     .file()
                     .map(|file| file.file_name(cx))
-                    .unwrap_or("untitled");
+                    .unwrap_or(MultiBuffer::DEFAULT_TITLE);
                 Some(
                     h_flex()
                         .px_2()
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 12138cb7f92..a8ec9667ca8 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -103,16 +103,19 @@ pub use editor_settings::{
 };
 pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
-    render_breadcrumb_text,
+    file_status_label_color, render_breadcrumb_text,
 };
 pub use git::blame::BlameRenderer;
+pub use git::{
+    DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate,
+    RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls,
+    set_blame_renderer,
+};
 pub(crate) use git::{DiffHunkKey, StoredReviewComment};
 use git::{
-    DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, render_diff_hunk_controls,
-    update_uncommitted_diff_for_buffer,
+    DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer,
 };
 pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator};
-pub use git::{RenderDiffHunkControlsFn, set_blame_renderer};
 pub use hover_popover::hover_markdown_style;
 pub use inlays::Inlay;
 pub use items::MAX_TAB_TITLE_LEN;
@@ -124,7 +127,7 @@ pub use multi_buffer::{
     MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferSnapshot, PathKey, RowInfo, ToOffset,
     ToPoint,
 };
-pub use split::{SplittableEditor, ToggleSplitDiff};
+pub use split::{DiffStyleControls, SplittableEditor, ToggleSplitDiff};
 pub use split_editor_view::SplitEditorView;
 pub use text::Bias;
 
@@ -254,9 +257,8 @@ use theme::{
 };
 use theme_settings::{ThemeSettings, observe_buffer_font_size_adjustment};
 use ui::{
-    Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape,
-    IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide,
-    utils::WithRemSize,
+    Avatar, ContextMenu, Disclosure, IconButtonShape, Indicator, Key, KeyBinding, Tooltip,
+    prelude::*, scrollbars::ScrollbarAutoHide, tooltip_container, utils::WithRemSize,
 };
 use ui_input::ErasedEditor;
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
@@ -974,7 +976,6 @@ pub struct Editor {
     offset_content: bool,
     disable_expand_excerpt_buttons: bool,
     delegate_expand_excerpts: bool,
-    delegate_stage_and_restore: bool,
     delegate_open_excerpts: bool,
     enable_lsp_data: bool,
     needs_initial_data_update: bool,
@@ -1075,11 +1076,11 @@ pub struct Editor {
     show_git_blame_inline: bool,
     show_git_blame_inline_delay_task: Option>,
     git_blame_inline_enabled: bool,
-    render_diff_hunk_controls: RenderDiffHunkControlsFn,
     buffer_serialization: Option,
     show_selection_menu: Option,
     blame: Option>,
     blame_subscription: Option,
+    pending_blame_hover_observation: Option,
     custom_context_menu: Option<
         Box<
             dyn 'static
@@ -1129,12 +1130,7 @@ pub struct Editor {
     addons: TypeIdHashMap>,
     registered_buffers: HashMap,
     load_diff_task: Option>>,
-    /// Whether we are temporarily displaying a diff other than git's
-    temporary_diff_override: bool,
-    /// Whether to render all diff hunks with the "unstaged" appearance,
-    /// regardless of whether they have a secondary hunk. Used by views whose
-    /// diffs aren't related to the git index (e.g. agent diffs).
-    render_diff_hunks_as_unstaged: bool,
+    diff_hunk_delegate: Option>,
     selection_mark_mode: bool,
     toggle_fold_multiple_buffers: Task<()>,
     _scroll_cursor_center_top_bottom_task: Task<()>,
@@ -1387,11 +1383,15 @@ struct DeferredSelectionEffectsState {
     history_entry: SelectionHistoryEntry,
 }
 
+#[derive(Clone, Debug)]
+pub struct TransactionSelections {
+    pub undo: Arc<[Selection]>,
+    pub redo: Option]>>,
+}
+
 #[derive(Default)]
 struct SelectionHistory {
-    #[allow(clippy::type_complexity)]
-    selections_by_transaction:
-        HashMap]>, Option]>>)>,
+    selections_by_transaction: HashMap,
     mode: SelectionHistoryMode,
     undo_stack: VecDeque,
     redo_stack: VecDeque,
@@ -1411,23 +1411,23 @@ impl SelectionHistory {
             );
             return;
         }
-        self.selections_by_transaction
-            .insert(transaction_id, (selections, None));
+        self.selections_by_transaction.insert(
+            transaction_id,
+            TransactionSelections {
+                undo: selections,
+                redo: None,
+            },
+        );
     }
 
-    #[allow(clippy::type_complexity)]
-    fn transaction(
-        &self,
-        transaction_id: TransactionId,
-    ) -> Option<&(Arc<[Selection]>, Option]>>)> {
+    fn transaction(&self, transaction_id: TransactionId) -> Option<&TransactionSelections> {
         self.selections_by_transaction.get(&transaction_id)
     }
 
-    #[allow(clippy::type_complexity)]
     fn transaction_mut(
         &mut self,
         transaction_id: TransactionId,
-    ) -> Option<&mut (Arc<[Selection]>, Option]>>)> {
+    ) -> Option<&mut TransactionSelections> {
         self.selections_by_transaction.get_mut(&transaction_id)
     }
 
@@ -1633,6 +1633,97 @@ pub struct RewrapOptions {
     pub line_length: Option,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum GutterButtonIntent {
+    SetBookmark,
+    SetBreakpoint,
+}
+
+impl GutterButtonIntent {
+    fn as_str(&self) -> &'static str {
+        match self {
+            Self::SetBookmark => "Set Bookmark",
+            Self::SetBreakpoint => "Set Breakpoint",
+        }
+    }
+
+    fn icon(&self) -> ui::IconName {
+        match self {
+            Self::SetBookmark => ui::IconName::Bookmark,
+            Self::SetBreakpoint => ui::IconName::DebugBreakpoint,
+        }
+    }
+
+    fn color(&self) -> Color {
+        match self {
+            Self::SetBookmark => Color::Info,
+            Self::SetBreakpoint => Color::Hint,
+        }
+    }
+
+    fn action(&self) -> &'static dyn Action {
+        match self {
+            Self::SetBookmark => &ToggleBookmark,
+            Self::SetBreakpoint => &ToggleBreakpoint,
+        }
+    }
+}
+
+struct GutterButtonTooltip {
+    primary: GutterButtonIntent,
+    secondary: GutterButtonIntent,
+    focus_handle: FocusHandle,
+}
+
+impl GutterButtonTooltip {
+    fn active_intent(&self, modifiers: Modifiers) -> GutterButtonIntent {
+        if modifiers.secondary() {
+            self.secondary
+        } else {
+            self.primary
+        }
+    }
+
+    fn meta_text(&self, intent: GutterButtonIntent) -> String {
+        const RIGHT_CLICK_HINT: &str = "right-click for more options";
+
+        if self.primary == self.secondary {
+            return RIGHT_CLICK_HINT.to_string();
+        }
+        let modifier_as_text = gpui::Keystroke {
+            modifiers: Modifiers::secondary_key(),
+            ..Default::default()
+        };
+        let other = match intent {
+            GutterButtonIntent::SetBookmark => "breakpoint",
+            GutterButtonIntent::SetBreakpoint => "bookmark",
+        };
+        format!("{modifier_as_text}-click to add a {other}\n{RIGHT_CLICK_HINT}")
+    }
+}
+
+impl Render for GutterButtonTooltip {
+    fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let intent = self.active_intent(window.modifiers());
+        let key_binding = KeyBinding::for_action_in(intent.action(), &self.focus_handle, cx);
+        let meta_text = self.meta_text(intent);
+
+        tooltip_container(cx, move |this, _| {
+            this.child(
+                h_flex()
+                    .justify_between()
+                    .child(intent.as_str())
+                    .child(key_binding),
+            )
+            .child(
+                Label::new(meta_text)
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+        })
+    }
+}
+
 impl Editor {
     pub fn single_line(window: &mut Window, cx: &mut Context) -> Self {
         let buffer = cx.new(|cx| Buffer::local("", cx));
@@ -2190,7 +2281,6 @@ impl Editor {
             use_relative_line_numbers: None,
             disable_expand_excerpt_buttons: !full_mode,
             delegate_expand_excerpts: false,
-            delegate_stage_and_restore: false,
             delegate_open_excerpts: false,
             enable_lsp_data: full_mode,
             needs_initial_data_update: full_mode,
@@ -2295,7 +2385,6 @@ impl Editor {
             show_git_blame_inline_delay_task: None,
             git_blame_inline_enabled: full_mode
                 && ProjectSettings::get_global(cx).git.inline_blame.enabled,
-            render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
             buffer_serialization: is_minimap.not().then(|| {
                 BufferSerialization::new(
                     ProjectSettings::get_global(cx)
@@ -2305,6 +2394,7 @@ impl Editor {
             }),
             blame: None,
             blame_subscription: None,
+            pending_blame_hover_observation: None,
 
             bookmark_store,
             breakpoint_store,
@@ -2364,8 +2454,7 @@ impl Editor {
             serialize_folds: Task::ready(()),
             text_style_refinement: None,
             load_diff_task: load_uncommitted_diff,
-            temporary_diff_override: false,
-            render_diff_hunks_as_unstaged: false,
+            diff_hunk_delegate: None,
             minimap: None,
             change_list: ChangeList::new(),
             mode,
@@ -4400,61 +4489,20 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) -> IconButton {
-        #[derive(Clone, Copy)]
-        enum Intent {
-            SetBookmark,
-            SetBreakpoint,
-        }
-
-        impl Intent {
-            fn as_str(&self) -> &'static str {
-                match self {
-                    Intent::SetBookmark => "Set bookmark",
-                    Intent::SetBreakpoint => "Set breakpoint",
-                }
-            }
-
-            fn icon(&self) -> ui::IconName {
-                match self {
-                    Intent::SetBookmark => ui::IconName::Bookmark,
-                    Intent::SetBreakpoint => ui::IconName::DebugBreakpoint,
-                }
-            }
-
-            fn color(&self) -> Color {
-                match self {
-                    Intent::SetBookmark => Color::Info,
-                    Intent::SetBreakpoint => Color::Hint,
-                }
-            }
-
-            fn secondary_and_options(&self) -> String {
-                let alt_as_text = gpui::Keystroke {
-                    modifiers: Modifiers::secondary_key(),
-                    ..Default::default()
-                };
-                match self {
-                    Intent::SetBookmark => format!(
-                        "{alt_as_text}-click to add a breakpoint\nright-click for more options"
-                    ),
-                    Intent::SetBreakpoint => format!(
-                        "{alt_as_text}-click to add a bookmark\nright-click for more options"
-                    ),
-                }
-            }
-        }
-
         let gutter_settings = EditorSettings::get_global(cx).gutter;
         let show_bookmarks = self.show_bookmarks.unwrap_or(gutter_settings.bookmarks);
         let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints);
 
         let [primary, secondary] = match [show_breakpoints, show_bookmarks] {
-            [true, true] => [Intent::SetBreakpoint, Intent::SetBookmark],
-            [true, false] => [Intent::SetBreakpoint; 2],
-            [false, true] => [Intent::SetBookmark; 2],
+            [true, true] => [
+                GutterButtonIntent::SetBreakpoint,
+                GutterButtonIntent::SetBookmark,
+            ],
+            [true, false] => [GutterButtonIntent::SetBreakpoint; 2],
+            [false, true] => [GutterButtonIntent::SetBookmark; 2],
             [false, false] => {
                 log::error!("Trying to place gutter_hover without anything enabled!!");
-                [Intent::SetBookmark; 2]
+                [GutterButtonIntent::SetBookmark; 2]
             }
         };
 
@@ -4481,8 +4529,8 @@ impl Editor {
                     };
 
                     match intent {
-                        Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx),
-                        Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor(
+                        GutterButtonIntent::SetBookmark => editor.toggle_bookmark_at_row(row, cx),
+                        GutterButtonIntent::SetBreakpoint => editor.edit_breakpoint_at_anchor(
                             position,
                             Breakpoint::new_standard(),
                             BreakpointEditAction::Toggle,
@@ -4496,13 +4544,12 @@ impl Editor {
             }))
             .when(!has_context_menu, |button| {
                 button.tooltip(move |_window, cx| {
-                    Tooltip::with_meta_in(
-                        intent.as_str(),
-                        Some(&ToggleBreakpoint),
-                        intent.secondary_and_options(),
-                        &focus_handle,
-                        cx,
-                    )
+                    cx.new(|_| GutterButtonTooltip {
+                        primary,
+                        secondary,
+                        focus_handle: focus_handle.clone(),
+                    })
+                    .into()
                 })
             })
     }
@@ -7495,26 +7542,31 @@ impl Editor {
         });
     }
 
+    fn restore_selections(
+        &mut self,
+        selections: Option]>>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if let Some(selections) = selections.filter(|selections| !selections.is_empty()) {
+            self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                s.select_anchors(selections.to_vec());
+            });
+        }
+    }
+
     pub fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) {
         if self.read_only(cx) {
             return;
         }
 
         if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
-            if let Some((selections, _)) =
-                self.selection_history.transaction(transaction_id).cloned()
-            {
-                self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
-                    s.select_anchors(selections.to_vec());
-                });
-            } else {
-                log::error!(
-                    "No entry in selection_history found for undo. \
-                     This may correspond to a bug where undo does not update the selection. \
-                     If this is occurring, please add details to \
-                     https://github.com/zed-industries/zed/issues/22692"
-                );
+            let transaction = self.selection_history.transaction(transaction_id);
+            if transaction.is_none() {
+                log::error!("No selection history for undone transaction; selection unchanged");
             }
+            let selections = transaction.map(|transaction| transaction.undo.clone());
+            self.restore_selections(selections, window, cx);
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(window, cx);
             self.refresh_edit_prediction(
@@ -7535,20 +7587,11 @@ impl Editor {
         }
 
         if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
-            if let Some((_, Some(selections))) =
-                self.selection_history.transaction(transaction_id).cloned()
-            {
-                self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
-                    s.select_anchors(selections.to_vec());
-                });
-            } else {
-                log::error!(
-                    "No entry in selection_history found for redo. \
-                     This may correspond to a bug where undo does not update the selection. \
-                     If this is occurring, please add details to \
-                     https://github.com/zed-industries/zed/issues/22692"
-                );
-            }
+            let selections = self
+                .selection_history
+                .transaction(transaction_id)
+                .and_then(|transaction| transaction.redo.clone());
+            self.restore_selections(selections, window, cx);
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(window, cx);
             self.refresh_edit_prediction(
@@ -7664,16 +7707,21 @@ impl Editor {
         let cursor = self.rename_target_anchor(&selection, cx);
         let (cursor_buffer, cursor_buffer_position) =
             self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
+        let (head_buffer, head_buffer_position) = self
+            .buffer
+            .read(cx)
+            .text_anchor_for_position(selection.head(), cx)?;
         let (tail_buffer, cursor_buffer_position_end) = self
             .buffer
             .read(cx)
             .text_anchor_for_position(selection.tail(), cx)?;
-        if tail_buffer != cursor_buffer {
+        if tail_buffer != cursor_buffer || head_buffer != cursor_buffer {
             return None;
         }
 
         let snapshot = cursor_buffer.read(cx).snapshot();
         let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot);
+        let head_buffer_offset = head_buffer_position.to_offset(&snapshot);
         let cursor_buffer_offset_end = cursor_buffer_position_end.to_offset(&snapshot);
         let prepare_rename = provider.range_for_rename(&cursor_buffer, cursor_buffer_position, cx);
         drop(snapshot);
@@ -7686,7 +7734,9 @@ impl Editor {
                     let rename_buffer_range = rename_range.to_offset(&snapshot);
                     let cursor_offset_in_rename_range =
                         cursor_buffer_offset.saturating_sub(rename_buffer_range.start);
-                    let cursor_offset_in_rename_range_end =
+                    let head_offset_in_rename_range =
+                        head_buffer_offset.saturating_sub(rename_buffer_range.start);
+                    let tail_offset_in_rename_range =
                         cursor_buffer_offset_end.saturating_sub(rename_buffer_range.start);
 
                     this.take_rename(false, window, cx);
@@ -7727,24 +7777,23 @@ impl Editor {
                                 cx,
                             )
                         });
-                        let cursor_offset_in_rename_range =
-                            MultiBufferOffset(cursor_offset_in_rename_range);
-                        let cursor_offset_in_rename_range_end =
-                            MultiBufferOffset(cursor_offset_in_rename_range_end);
-                        let rename_selection_range = match cursor_offset_in_rename_range
-                            .cmp(&cursor_offset_in_rename_range_end)
-                        {
-                            Ordering::Equal => {
-                                editor.select_all(&SelectAll, window, cx);
-                                return editor;
-                            }
-                            Ordering::Less => {
-                                cursor_offset_in_rename_range..cursor_offset_in_rename_range_end
-                            }
-                            Ordering::Greater => {
-                                cursor_offset_in_rename_range_end..cursor_offset_in_rename_range
-                            }
-                        };
+                        let head_offset_in_rename_range =
+                            MultiBufferOffset(head_offset_in_rename_range);
+                        let tail_offset_in_rename_range =
+                            MultiBufferOffset(tail_offset_in_rename_range);
+                        let rename_selection_range =
+                            match head_offset_in_rename_range.cmp(&tail_offset_in_rename_range) {
+                                Ordering::Equal => {
+                                    editor.select_all(&SelectAll, window, cx);
+                                    return editor;
+                                }
+                                Ordering::Less => {
+                                    head_offset_in_rename_range..tail_offset_in_rename_range
+                                }
+                                Ordering::Greater => {
+                                    tail_offset_in_rename_range..head_offset_in_rename_range
+                                }
+                            };
                         if rename_selection_range.end.0 > old_name.len() {
                             editor.select_all(&SelectAll, window, cx);
                         } else {
@@ -8061,7 +8110,7 @@ impl Editor {
                 // will take you back to where you made the last edit, instead of staying where you scrolled
                 self.selection_history
                     .transaction(transaction_id_prev)
-                    .map(|t| t.0.clone())
+                    .map(|t| t.undo.clone())
             })
             .unwrap_or_else(|| self.selections.disjoint_anchors_arc());
 
@@ -8287,10 +8336,8 @@ impl Editor {
             .buffer
             .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx))
         {
-            if let Some((_, end_selections)) =
-                self.selection_history.transaction_mut(transaction_id)
-            {
-                *end_selections = Some(self.selections.disjoint_anchors_arc());
+            if let Some(transaction) = self.selection_history.transaction_mut(transaction_id) {
+                transaction.redo = Some(self.selections.disjoint_anchors_arc());
             } else {
                 log::error!("unexpectedly ended a transaction that wasn't started by this editor");
             }
@@ -8305,7 +8352,7 @@ impl Editor {
     pub fn modify_transaction_selection_history(
         &mut self,
         transaction_id: TransactionId,
-        modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)),
+        modify: impl FnOnce(&mut TransactionSelections),
     ) -> bool {
         self.selection_history
             .transaction_mut(transaction_id)
@@ -9521,6 +9568,9 @@ impl Editor {
                 ranges,
                 path_key,
             } => {
+                if let Some(hovered_link_state) = self.hovered_link_state.as_mut() {
+                    hovered_link_state.symbol_range = None;
+                }
                 self.refresh_document_highlights(cx);
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).diff_for(buffer_id).is_none()
@@ -9640,6 +9690,13 @@ impl Editor {
         let theme_settings = theme_settings::ThemeSettings::get_global(cx);
         let theme = cx.theme();
         let accent_colors = theme.accents().clone();
+        let editor_background = theme.colors().editor_background;
+        let auto_accent_colors =
+            AccentColors(crate::bracket_colorization::bracket_colorization_accents(
+                &accent_colors.0,
+                theme.appearance,
+                editor_background,
+            ));
 
         let accent_overrides = theme_settings
             .theme_overrides
@@ -9659,7 +9716,7 @@ impl Editor {
             .collect();
 
         Some(AccentData {
-            colors: accent_colors,
+            colors: auto_accent_colors,
             overrides: accent_overrides,
         })
     }
@@ -10848,7 +10905,7 @@ impl Editor {
                         if multibuffer.is_singleton() {
                             multibuffer.title(cx).to_string()
                         } else {
-                            "untitled".to_string()
+                            MultiBuffer::DEFAULT_TITLE.to_string()
                         }
                     })
             });
@@ -11619,30 +11676,50 @@ impl EditorSnapshot {
         current_selection_head: DisplayRow,
         count_wrapped_lines: bool,
     ) -> HashMap {
-        let initial_offset =
-            self.relative_line_delta(current_selection_head, rows.start, count_wrapped_lines);
-
-        self.row_infos(rows.start)
+        let mut row_infos = self
+            .row_infos(rows.start)
             .take(rows.len())
             .enumerate()
-            .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info))
+            .map(|(index, row_info)| (DisplayRow(rows.start.0 + index as u32), row_info))
             .filter(|(_row, row_info)| {
                 row_info.buffer_row.is_some()
                     || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
             })
-            .enumerate()
-            .filter_map(|(i, (row, row_info))| {
+            .peekable();
+
+        // We find the first row that actually passes the filter and calculate its
+        // delta independently. This ensures accuracy when scrolling, as the first
+        // visible row in `rows` might be a wrap part that is filtered out, which
+        // would otherwise offset the counter for subsequent lines if we used
+        // `rows.start` as the base for enumeration.
+        let Some((first_row, _)) = row_infos.peek() else {
+            return HashMap::default();
+        };
+
+        let mut current_delta =
+            self.relative_line_delta(current_selection_head, *first_row, count_wrapped_lines);
+
+        row_infos
+            .filter_map(|(row, row_info)| {
+                let is_deleted = row_info
+                    .diff_status
+                    .is_some_and(|status| status.is_deleted());
+
+                if !self.number_deleted_lines && is_deleted {
+                    // Even if we don't number this line, it still counts as a unit
+                    // of distance for the relative numbers of lines below it.
+                    current_delta += 1;
+                    return None;
+                }
+
                 // We want to ensure here that the current line has absolute
                 // numbering, even if we are in a soft-wrapped line. With the
                 // exception that if we are in a deleted line, we should number this
                 // relative with 0, as otherwise it would have no line number at all
-                let relative_line_number = (initial_offset + i as i64).unsigned_abs() as u32;
+                let relative_line_number = current_delta.unsigned_abs() as u32;
+                current_delta += 1;
 
-                (relative_line_number != 0
-                    || row_info
-                        .diff_status
-                        .is_some_and(|status| status.is_deleted()))
-                .then_some((row, relative_line_number))
+                (relative_line_number != 0 || is_deleted).then_some((row, relative_line_number))
             })
             .collect()
     }
@@ -11707,17 +11784,10 @@ pub enum EditorEvent {
         lines: u32,
         direction: ExpandExcerptDirection,
     },
-    StageOrUnstageRequested {
-        stage: bool,
-        hunks: Vec,
-    },
     OpenExcerptsRequested {
         selections_by_buffer: HashMap>, Option)>,
         split: bool,
     },
-    RestoreRequested {
-        hunks: Vec,
-    },
     /// Emitted when an underlying buffer changes, including edits made through another editor.
     BufferEdited,
     /// Emitted when this editor creates, undoes, or redoes an edit transaction.
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index de1d6905205..2a4ac54cf59 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -29,7 +29,8 @@ use language::{
     LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries,
     LanguageToolchainStore, Override, Point,
     language_settings::{
-        CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode,
+        CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent,
+        LspInsertMode,
     },
     tree_sitter_python,
 };
@@ -323,6 +324,39 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_undo_redo_with_empty_history_selections_does_not_panic(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let now = Instant::now();
+    let buffer = cx.new(|cx| language::Buffer::local("123456", cx));
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let editor = cx.add_window(|window, cx| build_editor(buffer, window, cx));
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)])
+        });
+        editor.start_transaction_at(now, window, cx);
+        editor.insert("a", window, cx);
+        let transaction_id = editor
+            .end_transaction_at(now, cx)
+            .expect("transaction should be created");
+        assert_eq!(editor.text(cx), "a123456");
+
+        editor.modify_transaction_selection_history(transaction_id, |selections| {
+            selections.undo = Vec::new().into();
+            selections.redo = Some(Vec::new().into());
+        });
+
+        editor.undo(&Undo, window, cx);
+        assert_eq!(editor.text(cx), "123456");
+
+        editor.redo(&Redo, window, cx);
+        assert_eq!(editor.text(cx), "a123456");
+    });
+}
+
 #[gpui::test]
 fn test_accessibility_keyboard_word_completion(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2864,6 +2898,237 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext)
     cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix");
 }
 
+#[gpui::test]
+async fn test_move_to_next_and_previous_comment_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // A blank comment line (`//`) splits one comment block into two paragraphs;
+    // a code line splits blocks; and a trailing comment preceded by code
+    // (`let x = 1; // ...`) is not a comment line at all.
+    cx.set_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    cx.run_until_parked();
+
+    let next = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_next_comment_paragraph(&MoveToNextCommentParagraph, window, cx)
+        });
+    };
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Forward: skip the second line of the first paragraph, the blank comment
+    // line, the code line, and the trailing comment line.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+    // No paragraph after the last one: the caret stays put.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+
+    // Backward is the mirror image.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    // No paragraph before the first one: the caret stays put.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+}
+
+#[gpui::test]
+async fn test_move_to_previous_comment_paragraph_skips_current_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Caret in the middle of the last line of the second paragraph: moving to
+    // the previous paragraph must skip the entire current paragraph and land on
+    // the first paragraph's start, not on the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta ˇtwo
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Same when the caret is part-way through the *first* line of the second
+    // paragraph.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beˇta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Inside the first paragraph there is no previous paragraph, so the caret
+    // stays put rather than jumping to the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+}
+
 #[gpui::test]
 async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2925,6 +3190,90 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+    let line_height = cx.update_editor(|editor, window, cx| {
+        editor.set_vertical_scroll_margin(2, cx);
+        editor
+            .style(cx)
+            .text
+            .line_height_in_pixels(window.rem_size())
+    });
+    let window = cx.window;
+    // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row)
+    cx.simulate_window_resize(window, size(px(1000.), 5. * line_height));
+
+    // Cursor at row 0 — autoscroll leaves viewport at y=0.
+    cx.set_state(indoc! {"
+        ˇone
+        two
+        three
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+
+        // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3.
+        // Cursor at row 0 < min_row=3 → clamped to row 3.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            3,
+            "cursor clamped to min_row after scrolling down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 3 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling up"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4.
+        // Cursor at row 2 < min_row=4 → clamped to row 4.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            4,
+            "cursor clamped to min_row after scrolling half page down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 4 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling half page up"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_autoscroll(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -8992,6 +9341,110 @@ async fn test_cut_line_ends(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_kill_ring_cut_accumulates_multi_line_kills(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇone\ntwo");
+
+    cx.update_editor(|e, window, cx| {
+        e.kill_ring_cut(&KillRingCut, window, cx);
+        e.kill_ring_cut(&KillRingCut, window, cx);
+    });
+
+    cx.update_editor(|e, window, cx| e.kill_ring_yank(&KillRingYank, window, cx));
+    cx.assert_editor_state("one\nˇtwo");
+}
+
+#[gpui::test]
+async fn test_kill_ring_cut_matches_emacs_kill_line_sequence_at_end_of_buffer(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "\nc");
+    });
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_cut_accumulates_final_line_without_trailing_newline(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "");
+    });
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_yank_breaks_kill_ring_cut_accumulation(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇa\nb\nc");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.update_editor(|editor, window, cx| editor.move_to_beginning(&MoveToBeginning, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+
+    cx.update_editor(|editor, _, cx| {
+        assert_eq!(editor.text(cx), "a\nb\nc");
+    });
+}
+
+#[gpui::test]
+async fn test_kill_ring_yank_pastes_accumulated_kill_at_each_cursor(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state("ˇone\ntwo");
+
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+    cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx));
+
+    cx.set_state("aˇ bˇ");
+    cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx));
+    cx.assert_editor_state("aone\nˇ bone\nˇ");
+}
+
 #[gpui::test]
 async fn test_clipboard(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -14950,6 +15403,89 @@ async fn setup_range_format_test(
     .await
 }
 
+/// Like `setup_range_format_test`, but backs the buffer with a FakeFs git
+/// repository so that `GitStore::get_unstaged_diff` returns a real diff.
+/// `head_content` sets the HEAD base, `index_content` sets the staged base.
+/// The buffer starts empty; the caller must `editor.set_text(...)` to set the
+/// working-tree content (the diff recomputes from buffer changes).
+async fn setup_range_format_test_with_git<'a>(
+    cx: &'a mut TestAppContext,
+    head_content: &str,
+    index_content: &str,
+) -> (
+    Entity,
+    Entity,
+    &'a mut gpui::VisualTestContext,
+    lsp::FakeLanguageServer,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", index_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    // Open the unstaged diff so GitStore tracks this buffer. Without this,
+    // `get_unstaged_diff` returns None and compute_format_target cannot
+    // produce range-based FormatTarget.
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    (project, editor, cx, fake_server)
+}
+
 fn refresh_editor_actions(cx: &mut VisualTestContext) {
     cx.executor().run_until_parked();
     cx.update(|window, cx| {
@@ -15162,6 +15698,940 @@ async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) {
     assert!(!cx.read(|cx| editor.is_dirty(cx)));
 }
 
+#[gpui::test]
+async fn test_modifications_format_on_save(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    save.await;
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(
+        cx.read(|cx| editor.is_dirty(cx)),
+        "editor should be dirty before save"
+    );
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when no git diff is available");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications without a git diff");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "one\ntwo\nthree\n";
+    fs.set_head_and_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\nTWO\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when range formatting is unsupported");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications when range formatting is unsupported");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\nTWO\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(Vec::new()))
+        })
+        .next()
+        .await;
+    save.await;
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "aaa\nbbb\nccc\nddd\neee\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move {
+                assert_eq!(
+                    params.text_document.uri,
+                    lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+                );
+                match count {
+                    0 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)),
+                        "!".to_string(),
+                    )])),
+                    1 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)),
+                        "!".to_string(),
+                    )])),
+                    _ => panic!("unexpected third range formatting request"),
+                }
+            }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2);
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            assert_eq!(
+                params.range.start.line, 4,
+                "only the unstaged hunk (LINE4) should be formatted"
+            );
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        1,
+        "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) {
+    let head_content = "a\nb\nc\n";
+    let staged_content = "A\nb\nc\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("A\nB\nc\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX));
+    let captured_start_line_clone = captured_start_line.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            captured_start_line_clone
+                .store(params.range.start.line as usize, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    let start_line = captured_start_line.load(atomic::Ordering::SeqCst);
+    assert_ne!(
+        start_line,
+        usize::MAX,
+        "expected at least one range formatting request"
+    );
+    assert_eq!(
+        start_line, 1,
+        "format range must exclude the staged row and start at the unstaged row"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(staged_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when all changes are staged");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when buffer matches HEAD");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.line_ending(),
+            language::LineEnding::Windows,
+            "buffer should detect CRLF line endings from the working tree file"
+        );
+    });
+
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "CRLF line endings must not cause spurious diff hunks"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "hunks separated by exactly one unchanged row must not merge"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when diff is empty");
+        })
+        .next();
+    let _no_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called when a diff is available but empty");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities(
+        cx,
+        lsp::ServerCapabilities {
+            document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+            document_formatting_provider: Some(lsp::OneOf::Left(true)),
+            ..lsp::ServerCapabilities::default()
+        },
+    )
+    .await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when no git diff is available");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when LSP lacks range support");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support"
+    );
+}
+
 #[gpui::test]
 async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) {
     let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
@@ -17675,6 +19145,202 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte
     })
 }
 
+#[gpui::test]
+async fn test_completion_in_multibuffer_with_newest_selection_in_other_buffer(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let main_text = indoc! {"
+        fn main() {
+            10.satu
+        }
+    "};
+    let other_text = indoc! {"
+        const VALUE: u32 = 0;
+    "};
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/a"),
+        json!({
+            "main.rs": main_text,
+            "other.rs": other_text,
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/a").as_ref()], cx).await;
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions {
+                    resolve_provider: None,
+                    ..lsp::CompletionOptions::default()
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+    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 cx = &mut VisualTestContext::from_window(*window, cx);
+    let main_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/a/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let other_buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/a/other.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let multi_buffer = cx.new(|cx| {
+        let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite);
+        multi_buffer.set_excerpts_for_path(
+            PathKey::sorted(0),
+            main_buffer.clone(),
+            [Point::zero()..main_buffer.read(cx).max_point()],
+            0,
+            cx,
+        );
+        multi_buffer.set_excerpts_for_path(
+            PathKey::sorted(1),
+            other_buffer.clone(),
+            [Point::zero()..other_buffer.read(cx).max_point()],
+            0,
+            cx,
+        );
+        multi_buffer
+    });
+
+    let editor = workspace.update_in(cx, |_, window, cx| {
+        cx.new(|cx| {
+            Editor::new(
+                EditorMode::Full {
+                    scale_ui_elements_with_buffer_font_size: false,
+                    show_active_line_background: false,
+                    sizing_behavior: SizingBehavior::Default,
+                },
+                multi_buffer.clone(),
+                Some(project.clone()),
+                window,
+                cx,
+            )
+        })
+    });
+
+    let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone());
+    pane.update_in(cx, |pane, window, cx| {
+        pane.add_item(Box::new(editor.clone()), true, true, None, window, cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+    cx.run_until_parked();
+
+    // Place the (only, and therefore newest) selection in `main.rs`, right after `satu`.
+    let main_cursor = editor.update(cx, |editor, cx| {
+        let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+        let main_snapshot = main_buffer.read(cx).snapshot();
+        let anchor = main_snapshot.anchor_before(Point::new(1, 11));
+        multibuffer_snapshot
+            .buffer_anchor_range_to_anchor_range(anchor..anchor)
+            .expect("main.rs cursor position should map into the multibuffer")
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_anchor_ranges([main_cursor.clone()]);
+        });
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.show_completions(&ShowCompletions, window, cx);
+    });
+
+    fake_server
+        .set_request_handler::(move |_, _| async move {
+            let completion_item = lsp::CompletionItem {
+                label: "saturating_sub()".into(),
+                text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
+                    lsp::InsertReplaceEdit {
+                        new_text: "saturating_sub()".to_owned(),
+                        insert: lsp::Range::new(
+                            lsp::Position::new(1, 7),
+                            lsp::Position::new(1, 11),
+                        ),
+                        replace: lsp::Range::new(
+                            lsp::Position::new(1, 7),
+                            lsp::Position::new(1, 11),
+                        ),
+                    },
+                )),
+                ..lsp::CompletionItem::default()
+            };
+
+            Ok(Some(lsp::CompletionResponse::Array(vec![completion_item])))
+        })
+        .next()
+        .await
+        .unwrap();
+
+    cx.condition(&editor, |editor, _| editor.context_menu_visible())
+        .await;
+
+    // Move the newest selection into `other.rs` while keeping the completions menu
+    // open.
+    let other_cursor = editor.update(cx, |editor, cx| {
+        let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+        let other_snapshot = other_buffer.read(cx).snapshot();
+        let anchor = other_snapshot.anchor_before(Point::new(0, 6));
+        multibuffer_snapshot
+            .buffer_anchor_range_to_anchor_range(anchor..anchor)
+            .expect("other.rs cursor position should map into the multibuffer")
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(
+            SelectionEffects::no_scroll().completions(false),
+            window,
+            cx,
+            |s| s.select_anchor_ranges([other_cursor.clone()]),
+        );
+        assert!(
+            editor.context_menu_visible(),
+            "the completions menu should still be open after moving the cursor"
+        );
+    });
+
+    // This used to panic with `invalid anchor - buffer id does not match`.
+    editor
+        .update_in(cx, |editor, window, cx| {
+            editor
+                .confirm_completion_replace(&ConfirmCompletionReplace, window, cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+    // The completion targets `main.rs`, so it should still be applied there.
+    main_buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            indoc! {"
+                fn main() {
+                    10.saturating_sub()
+                }
+            "}
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_completion(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -21922,6 +23588,166 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) {
     cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }");
 }
 
+async fn check_completion_additional_edits(
+    initial_state: &str,
+    keystroke: &str,
+    completion_item: lsp::CompletionItem,
+    state_after_primary_edit: &str,
+    state_after_additional_edits: &str,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorLspTestContext::new_rust(
+        lsp::ServerCapabilities {
+            completion_provider: Some(lsp::CompletionOptions {
+                trigger_characters: Some(vec![".".to_string()]),
+                resolve_provider: Some(true),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        cx,
+    )
+    .await;
+
+    cx.set_state(initial_state);
+
+    let closure_completion_item = completion_item.clone();
+    let mut request = cx.set_request_handler::(move |_, _, _| {
+        let task_completion_item = closure_completion_item.clone();
+        async move {
+            Ok(Some(lsp::CompletionResponse::Array(vec![
+                task_completion_item,
+            ])))
+        }
+    });
+
+    cx.simulate_keystroke(keystroke);
+    request.next().await;
+
+    cx.condition(|editor, _| editor.context_menu_visible())
+        .await;
+    let apply_additional_edits = cx.update_editor(|editor, window, cx| {
+        editor
+            .confirm_completion(&ConfirmCompletion::default(), window, cx)
+            .unwrap()
+    });
+    cx.assert_editor_state(state_after_primary_edit);
+
+    cx.set_request_handler::(move |_, _, _| {
+        let task_completion_item = completion_item.clone();
+        async move { Ok(task_completion_item) }
+    })
+    .next()
+    .await
+    .unwrap();
+    apply_additional_edits.await.unwrap();
+    cx.assert_editor_state(state_after_additional_edits);
+}
+
+// rust-analyzer's ref-match completions (`&some_str`) deliver the `&` as a
+// zero-width additionalTextEdit at the primary edit's start.
+// Ref: https://github.com/zed-industries/zed/issues/56973
+#[gpui::test]
+async fn test_completions_with_zero_width_additional_edit_at_primary_edit_start(
+    cx: &mut TestAppContext,
+) {
+    check_completion_additional_edits(
+        // The completion must not start at (0, 0), so that this case is
+        // distinct from the file-start auto-import test below.
+        "bar(fˇ)",
+        "o",
+        lsp::CompletionItem {
+            label: "&foo".to_string(),
+            filter_text: Some("foo".to_string()),
+            // Replaces the typed `fo` with `foo`; the committed range is 4..7.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 6)),
+                new_text: "foo".to_string(),
+            })),
+            // Zero-width insertion exactly at the committed range's start.
+            additional_text_edits: Some(vec![lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)),
+                new_text: "&".to_string(),
+            }]),
+            ..Default::default()
+        },
+        "bar(fooˇ)",
+        "bar(&fooˇ)",
+        cx,
+    )
+    .await;
+}
+
+// Additional edits which overlap the primary completion edit must be skipped
+// while non-overlapping edits from the same completion are still applied.
+// Ref: https://github.com/zed-industries/zed/pull/1871
+#[gpui::test]
+async fn test_completions_skip_additional_edits_overlapping_primary_edit(cx: &mut TestAppContext) {
+    check_completion_additional_edits(
+        "fˇ\nbar",
+        "o",
+        lsp::CompletionItem {
+            label: "foo".to_string(),
+            filter_text: Some("foo".to_string()),
+            // Replaces the typed `fo` with `foo`; the committed range is 0..3.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 2)),
+                new_text: "foo".to_string(),
+            })),
+            additional_text_edits: Some(vec![
+                // Skip text strictly inside the committed range.
+                lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(0, 1), lsp::Position::new(0, 2)),
+                    new_text: "XXX".to_string(),
+                },
+                // Apply text which does not touch the committed range.
+                lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
+                    new_text: "baz".to_string(),
+                },
+            ]),
+            ..Default::default()
+        },
+        "fooˇ\nbar",
+        "fooˇ\nbaz",
+        cx,
+    )
+    .await;
+}
+
+// When both the primary completion edit and an additional edit (auto-import)
+// start at the very beginning of the file, the additional edit must not be
+// treated as overlapping. This payload shape matches what
+// typescript-language-server actually sends for auto-imports at file start.
+// Ref: https://github.com/zed-industries/zed/issues/26136
+#[gpui::test]
+async fn test_completions_with_file_start_auto_import_additional_edit(cx: &mut TestAppContext) {
+    check_completion_additional_edits(
+        "ˇ",
+        "f",
+        lsp::CompletionItem {
+            label: "foo".to_string(),
+            // Replaces the typed `f` at file start; the committed range is 0..3.
+            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)),
+                new_text: "foo".to_string(),
+            })),
+            // Auto-import inserted at the very start of the file.
+            additional_text_edits: Some(vec![lsp::TextEdit {
+                range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
+                new_text: "bar\n".to_string(),
+            }]),
+            ..Default::default()
+        },
+        "fooˇ",
+        "bar\nfooˇ",
+        cx,
+    )
+    .await;
+}
+
 #[gpui::test]
 async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -28345,6 +30171,45 @@ fn add_log_breakpoint_at_cursor(
     );
 }
 
+#[gpui::test]
+fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let focus_handle = cx.update(|cx| cx.focus_handle());
+    let tooltip = GutterButtonTooltip {
+        primary: GutterButtonIntent::SetBreakpoint,
+        secondary: GutterButtonIntent::SetBookmark,
+        focus_handle,
+    };
+
+    let primary_intent = tooltip.active_intent(Modifiers::none());
+    assert_eq!(primary_intent, GutterButtonIntent::SetBreakpoint);
+    assert!(primary_intent.action().as_any().is::());
+
+    let secondary_intent = tooltip.active_intent(Modifiers::secondary_key());
+    assert_eq!(secondary_intent, GutterButtonIntent::SetBookmark);
+    assert!(secondary_intent.action().as_any().is::());
+
+    // When both features are enabled, the meta text advertises the
+    // modifier-click alternative.
+    let meta = tooltip.meta_text(primary_intent);
+    assert!(meta.contains("-click to add a bookmark"), "got: {meta}");
+    assert!(meta.contains("right-click for more options"));
+    let meta = tooltip.meta_text(secondary_intent);
+    assert!(meta.contains("-click to add a breakpoint"), "got: {meta}");
+
+    // When only one feature is enabled (primary == secondary), a
+    // modifier-click repeats the primary action, so the tooltip must not
+    // advertise it.
+    let single_feature_tooltip = GutterButtonTooltip {
+        primary: GutterButtonIntent::SetBreakpoint,
+        secondary: GutterButtonIntent::SetBreakpoint,
+        focus_handle: tooltip.focus_handle,
+    };
+    let meta = single_feature_tooltip.meta_text(GutterButtonIntent::SetBreakpoint);
+    assert_eq!(meta, "right-click for more options");
+}
+
 #[gpui::test]
 async fn test_breakpoint_toggling(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 0e4cb930dcb..fe06b33f431 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -3,6 +3,7 @@ mod mouse;
 
 #[cfg(test)]
 pub(crate) use header::StickyHeader;
+pub use header::file_status_label_color;
 pub(crate) use header::{header_jump_data, render_buffer_header};
 
 use crate::{
@@ -286,22 +287,22 @@ impl EditorElement {
         register_action(editor, window, Editor::scroll_cursor_bottom);
         register_action(editor, window, Editor::scroll_cursor_center_top_bottom);
         register_action(editor, window, |editor, _: &LineDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &LineUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &PageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &PageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx)
         });
         register_action(editor, window, Editor::move_to_previous_word_start);
         register_action(editor, window, Editor::move_to_previous_subword_start);
@@ -311,6 +312,8 @@ impl EditorElement {
         register_action(editor, window, Editor::move_to_end_of_line);
         register_action(editor, window, Editor::move_to_start_of_paragraph);
         register_action(editor, window, Editor::move_to_end_of_paragraph);
+        register_action(editor, window, Editor::move_to_next_comment_paragraph);
+        register_action(editor, window, Editor::move_to_previous_comment_paragraph);
         register_action(editor, window, Editor::move_to_beginning);
         register_action(editor, window, Editor::move_to_end);
         register_action(editor, window, Editor::move_to_start_of_excerpt);
@@ -4623,7 +4626,7 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> (Vec, Vec<(DisplayRow, Bounds)>) {
-        let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone();
+        let diff_hunk_delegate = editor.read(cx).diff_hunk_delegate();
         let hovered_diff_hunk_row = editor.read(cx).hovered_diff_hunk_row;
         let sticky_top = text_hitbox.bounds.top() + sticky_header_height;
 
@@ -4695,7 +4698,7 @@ impl EditorElement {
                         sticky_top.min(max_y)
                     };
 
-                    let mut element = render_diff_hunk_controls(
+                    let mut element = diff_hunk_delegate.render_hunk_controls(
                         display_row_range.start.0,
                         status,
                         multi_buffer_range.clone(),
@@ -6547,8 +6550,11 @@ impl EditorElement {
     }
 
     fn diff_hunk_hollow(&self, status: DiffHunkStatus, cx: &mut App) -> bool {
-        let unstaged =
-            self.editor.read(cx).render_diff_hunks_as_unstaged || status.has_secondary_hunk();
+        let unstaged = !self
+            .editor
+            .read(cx)
+            .diff_hunk_delegate()
+            .render_hunk_as_staged(&status, cx);
         let unstaged_hollow = matches!(
             ProjectSettings::get_global(cx).git.hunk_style,
             GitHunkStyleSetting::UnstagedHollow
@@ -9295,7 +9301,7 @@ impl Element for EditorElement {
                     };
 
                     let (diff_hunk_controls, diff_hunk_control_bounds) =
-                        if is_read_only && !self.editor.read(cx).delegate_stage_and_restore {
+                        if is_read_only && self.editor.read(cx).diff_hunk_delegate.is_none() {
                             (vec![], vec![])
                         } else {
                             self.layout_diff_hunk_controls(
@@ -11280,6 +11286,57 @@ mod tests {
         assert_eq!(relative_rows[&DisplayRow(5)], 2);
     }
 
+    #[gpui::test]
+    async fn test_relative_line_numbers_after_scrolling_wrapped_line(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let window = cx.add_window(|window, cx| {
+            let buffer = MultiBuffer::build_simple("", cx);
+            Editor::new(EditorMode::full(), buffer, None, window, cx)
+        });
+
+        update_test_language_settings(
+            cx,
+            &|settings: &mut settings::AllLanguageSettingsContent| {
+                settings.defaults.soft_wrap = Some(language_settings::SoftWrap::Bounded);
+                settings.defaults.preferred_line_length = Some(10);
+            },
+        );
+
+        window
+            .update(cx, |editor, _window, cx| {
+                let text = format!("{}\nshort line", "a".repeat(100));
+                editor.buffer.update(cx, |buffer, cx| {
+                    buffer.edit([(Point::default()..Point::default(), text)], None, cx);
+                });
+            })
+            .unwrap();
+        cx.run_until_parked();
+
+        window
+            .update(cx, |editor, _window, cx| {
+                let snapshot = editor.snapshot(_window, cx);
+
+                let line_1_display_row = Point::new(1, 0).to_display_point(&snapshot).row();
+                assert!(
+                    line_1_display_row.0 > 1,
+                    "Line 0 should wrap into multiple rows"
+                );
+
+                let start_row = DisplayRow(1);
+                let relative_rows = snapshot.calculate_relative_line_numbers(
+                    &(start_row..line_1_display_row.next_row()),
+                    line_1_display_row,
+                    false,
+                );
+
+                // If the bug exists, line_1_display_row would have a non-zero relative number
+                // and would be included in the map. It should be 0 (and thus omitted).
+                assert!(!relative_rows.contains_key(&line_1_display_row));
+            })
+            .unwrap();
+    }
+
     #[gpui::test]
     async fn test_vim_visual_selections(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs
index e51f29514a0..0ba76a13761 100644
--- a/crates/editor/src/element/header.rs
+++ b/crates/editor/src/element/header.rs
@@ -12,7 +12,7 @@ use gpui::{
     linear_color_stop, linear_gradient, point, px, size,
 };
 use language::language_settings::ShowWhitespaceSetting;
-use multi_buffer::{Anchor, ExcerptBoundaryInfo};
+use multi_buffer::{Anchor, ExcerptBoundaryInfo, MultiBuffer};
 use project::Entry;
 use settings::{RelativeLineNumbers, Settings};
 use smallvec::SmallVec;
@@ -20,8 +20,8 @@ use sum_tree::Bias;
 use text::BufferId;
 use theme::ActiveTheme;
 use ui::{
-    ButtonLike, ContextMenu, Indicator, KeyBinding, Tooltip, prelude::*, right_click_menu,
-    text_for_keystroke,
+    ButtonLike, ContextMenu, DiffStat, Indicator, KeyBinding, Tooltip, prelude::*,
+    right_click_menu, text_for_keystroke, utils::WithRemSize,
 };
 use util::ResultExt;
 use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel};
@@ -624,6 +624,13 @@ pub(crate) fn render_buffer_header(
     window: &mut Window,
     cx: &mut App,
 ) -> impl IntoElement {
+    let buffer_id = for_excerpt.buffer_id();
+    let header_hovered_state = window.use_keyed_state(
+        ("buffer-header-hovered", buffer_id.to_proto()),
+        cx,
+        |_, _| false,
+    );
+    let header_hovered = *header_hovered_state.read(cx);
     let editor_read = editor.read(cx);
     let multi_buffer = editor_read.buffer.read(cx);
     let is_read_only = editor_read.read_only(cx);
@@ -637,11 +644,16 @@ pub(crate) fn render_buffer_header(
         None
     };
 
-    let buffer_id = for_excerpt.buffer_id();
     let file_status = multi_buffer
         .all_diff_hunks_expanded()
         .then(|| editor_read.status_for_buffer_id(buffer_id, cx))
         .flatten();
+    let diff_stat = multi_buffer
+        .all_diff_hunks_expanded()
+        .then(|| multibuffer_snapshot.diff_for_buffer_id(buffer_id))
+        .flatten()
+        .map(|diff| diff.changed_row_counts())
+        .filter(|(added, removed)| *added > 0 || *removed > 0);
     let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| {
         let buffer = buffer.read(cx);
         let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) {
@@ -679,8 +691,19 @@ pub(crate) fn render_buffer_header(
     let opaque_window =
         cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque;
 
+    let show_open_file_button =
+        can_open_excerpts && relative_path.is_some() && (is_selected || header_hovered);
+
     let header = div()
         .id(("buffer-header", buffer_id.to_proto()))
+        .on_hover(move |hovered, _window, cx| {
+            header_hovered_state.update(cx, |state, cx| {
+                if *state != *hovered {
+                    *state = *hovered;
+                    cx.notify();
+                }
+            });
+        })
         .p(BUFFER_HEADER_PADDING)
         .w_full()
         .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
@@ -693,7 +716,6 @@ pub(crate) fn render_buffer_header(
                 .pr_2()
                 .rounded_sm()
                 .gap_1p5()
-                .when(is_sticky && opaque_window, |el| el.shadow_md())
                 .border_1()
                 .map(|border| {
                     let border_color =
@@ -704,10 +726,9 @@ pub(crate) fn render_buffer_header(
                         };
                     border.border_color(border_color)
                 })
-                .when(opaque_window, |el| {
-                    el.bg(colors.editor_subheader_background)
-                })
-                .hover(|style| style.bg(colors.element_hover))
+                .when(is_sticky && opaque_window, |s| s.shadow_md())
+                .when(opaque_window, |s| s.bg(colors.editor_subheader_background))
+                .hover(|s| s.bg(colors.element_hover))
                 .map(|header| {
                     let editor = editor.clone();
                     let buffer_id = for_excerpt.buffer_id();
@@ -800,7 +821,7 @@ pub(crate) fn render_buffer_header(
                             |path_header| {
                                 let filename = filename
                                     .map(SharedString::from)
-                                    .unwrap_or_else(|| "untitled".into());
+                                    .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into());
 
                                 let full_path = match parent_path.as_deref() {
                                     Some(parent) if !parent.is_empty() => {
@@ -886,15 +907,26 @@ pub(crate) fn render_buffer_header(
                                     })
                             },
                         ))
-                        .when(can_open_excerpts && relative_path.is_some(), |this| {
-                            this.child(
-                                div()
-                                    .when(!is_selected, |this| {
-                                        this.visible_on_hover("buffer-header-group")
-                                    })
-                                    .child(
+                        .child(
+                            h_flex()
+                                .gap_2()
+                                .when_some(diff_stat, |this, (added, removed)| {
+                                    let ui_font_size =
+                                        theme_settings::ThemeSettings::get_global(cx)
+                                            .ui_font_size(cx);
+                                    this.child(WithRemSize::new(ui_font_size).child(DiffStat::new(
+                                        ("buffer-header-diff-stat", buffer_id.to_proto()),
+                                        added as usize,
+                                        removed as usize,
+                                    )))
+                                })
+                                .when(show_open_file_button, |this| {
+                                    this.child(
                                         Button::new("open-file-button", "Open File")
-                                            .style(ButtonStyle::OutlinedGhost)
+                                            .style(ButtonStyle::OutlinedCustom(
+                                                cx.theme().colors().border.opacity(0.6),
+                                            ))
+                                            .layer(ui::ElevationIndex::ElevatedSurface)
                                             .when(is_selected, |this| {
                                                 this.key_binding(KeyBinding::for_action_in(
                                                     &OpenExcerpts,
@@ -913,9 +945,9 @@ pub(crate) fn render_buffer_header(
                                                     );
                                                 }
                                             })),
-                                    ),
-                            )
-                        })
+                                    )
+                                }),
+                        )
                         .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
                         .on_click(window.listener_for(editor, {
                             let buffer_id = for_excerpt.buffer_id();
@@ -1055,7 +1087,7 @@ pub(crate) fn render_buffer_header(
         })
 }
 
-fn file_status_label_color(file_status: Option) -> Color {
+pub fn file_status_label_color(file_status: Option) -> Color {
     file_status.map_or(Color::Default, |status| {
         if status.is_conflicted() {
             Color::Conflict
diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs
index 7224fa15d4c..d9871fa2cb5 100644
--- a/crates/editor/src/git.rs
+++ b/crates/editor/src/git.rs
@@ -2,20 +2,242 @@ pub(super) mod blame;
 
 use super::*;
 use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
-use buffer_diff::DiffHunkStatus;
+use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind};
 
-pub type RenderDiffHunkControlsFn = Arc<
-    dyn Fn(
-        u32,
-        &DiffHunkStatus,
-        Range,
-        bool,
-        Pixels,
-        &Entity,
-        &mut Window,
-        &mut App,
-    ) -> AnyElement,
->;
+#[derive(Clone)]
+pub struct ResolvedDiffHunk {
+    pub buffer_range: Range,
+    pub diff_base_byte_range: Range,
+    pub status: DiffHunkStatus,
+}
+
+#[derive(Clone)]
+pub struct ResolvedDiffHunks {
+    pub diff: Entity,
+    pub buffer_id: BufferId,
+    pub buffer: Option>,
+    pub hunks: Vec,
+}
+
+pub trait DiffHunkDelegate {
+    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 restore(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if hunks.is_empty() || editor.read_only(cx) {
+            return;
+        }
+        self.stage_or_unstage(false, hunks.clone(), editor, window, cx);
+        editor.transact(window, cx, |editor, window, cx| {
+            editor.restore_diff_hunks(hunks, cx);
+            let selections = editor
+                .selections
+                .all::(&editor.display_snapshot(cx));
+            editor.change_selections(
+                SelectionEffects::no_scroll(),
+                window,
+                cx,
+                |selections_state| {
+                    selections_state.select(selections);
+                },
+            );
+        });
+    }
+
+    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;
+
+    fn render_hunk_as_staged(&self, status: &DiffHunkStatus, _cx: &App) -> bool {
+        !status.has_secondary_hunk()
+    }
+}
+
+pub struct UncommittedDiffHunkDelegate;
+
+impl DiffHunkDelegate for UncommittedDiffHunkDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let stage = hunks
+            .iter()
+            .flat_map(|hunks| hunks.hunks.iter())
+            .any(|hunk| hunk.status.has_secondary_hunk());
+        self.stage_or_unstage(stage, hunks, editor, window, cx);
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        editor: &mut Editor,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(project) = editor.project() else {
+            return;
+        };
+        for hunks in hunks {
+            let Some(buffer) = hunks.buffer else {
+                continue;
+            };
+            let ranges = hunks
+                .hunks
+                .into_iter()
+                .map(|hunk| hunk.buffer_range)
+                .collect::>();
+            if ranges.is_empty() {
+                continue;
+            }
+            let secondary_diff = hunks.diff.read(cx).secondary_diff();
+            project
+                .update(cx, |project, cx| {
+                    if stage {
+                        let Some(secondary_diff) = secondary_diff else {
+                            return Err(anyhow::anyhow!("diff has no unstaged secondary"));
+                        };
+                        project.stage_hunks(buffer, secondary_diff, ranges, cx)
+                    } else {
+                        project.unstage_uncommitted_hunks(buffer, hunks.diff, ranges, cx)
+                    }
+                })
+                .log_err();
+        }
+    }
+
+    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,
+            editor,
+            window,
+            cx,
+        )
+    }
+}
+
+pub struct RestoreOnlyDiffHunkDelegate;
+
+impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate {
+    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 {
+        gpui::Empty.into_any_element()
+    }
+}
+
+pub struct RestoreOnlyUnstagedDiffHunkDelegate;
+
+impl DiffHunkDelegate for RestoreOnlyUnstagedDiffHunkDelegate {
+    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 {
+        gpui::Empty.into_any_element()
+    }
+
+    fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool {
+        false
+    }
+}
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub(super) enum DisplayDiffHunk {
@@ -166,24 +388,115 @@ impl Editor {
         })
     }
 
-    pub fn set_render_diff_hunk_controls(
-        &mut self,
-        render_diff_hunk_controls: RenderDiffHunkControlsFn,
-        cx: &mut Context,
-    ) {
-        self.render_diff_hunk_controls = render_diff_hunk_controls;
-        cx.notify();
+    fn resolve_diff_hunks(
+        &self,
+        hunks: Vec,
+        cx: &App,
+    ) -> Vec {
+        let multibuffer = self.buffer().read(cx);
+        let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id);
+        let mut resolved = Vec::new();
+
+        for (source_buffer_id, hunks) in &chunk_by {
+            let Some(diff) = multibuffer.diff_for(source_buffer_id) else {
+                continue;
+            };
+            let diff_snapshot = diff.read(cx).snapshot(cx);
+            let main_buffer_id = diff_snapshot.buffer_id();
+            let buffer = multibuffer.buffer(main_buffer_id).or_else(|| {
+                self.project
+                    .as_ref()
+                    .and_then(|project| project.read(cx).buffer_for_id(main_buffer_id, cx))
+            });
+            let mut resolved_hunks = Vec::new();
+
+            for hunk in hunks {
+                if hunk.buffer_id == main_buffer_id {
+                    resolved_hunks.push(ResolvedDiffHunk {
+                        buffer_range: hunk.buffer_range,
+                        diff_base_byte_range: hunk.diff_base_byte_range.start.0
+                            ..hunk.diff_base_byte_range.end.0,
+                        status: hunk.status,
+                    });
+                } else {
+                    let diff_base_byte_range =
+                        hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0;
+                    let Some(hunk) = diff_snapshot
+                        .hunks_intersecting_base_text_range(
+                            diff_base_byte_range.clone(),
+                            diff_snapshot.buffer_snapshot(),
+                        )
+                        .find(|hunk| hunk.diff_base_byte_range == diff_base_byte_range)
+                    else {
+                        continue;
+                    };
+                    let kind = if hunk.buffer_range.start == hunk.buffer_range.end {
+                        DiffHunkStatusKind::Deleted
+                    } else if hunk.diff_base_byte_range.is_empty() {
+                        DiffHunkStatusKind::Added
+                    } else {
+                        DiffHunkStatusKind::Modified
+                    };
+                    resolved_hunks.push(ResolvedDiffHunk {
+                        buffer_range: hunk.buffer_range,
+                        diff_base_byte_range: hunk.diff_base_byte_range,
+                        status: DiffHunkStatus {
+                            kind,
+                            secondary: hunk.secondary_status,
+                        },
+                    });
+                }
+            }
+
+            if !resolved_hunks.is_empty() {
+                resolved.push(ResolvedDiffHunks {
+                    diff,
+                    buffer_id: main_buffer_id,
+                    buffer,
+                    hunks: resolved_hunks,
+                });
+            }
+        }
+
+        resolved
     }
 
-    /// Make all diff hunks render with the "unstaged" appearance, regardless
-    /// of whether they have a secondary hunk. Intended for views whose diffs
-    /// aren't related to the git index (e.g. agent diffs).
-    pub fn set_render_diff_hunks_as_unstaged(
+    pub fn diff_hunk_delegate(&self) -> Arc {
+        self.diff_hunk_delegate
+            .clone()
+            .unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate))
+    }
+
+    pub fn set_diff_hunk_delegate(
         &mut self,
-        render_as_unstaged: bool,
+        delegate: Option>,
         cx: &mut Context,
     ) {
-        self.render_diff_hunks_as_unstaged = render_as_unstaged;
+        let had_delegate = self.diff_hunk_delegate.is_some();
+        let has_delegate = delegate.is_some();
+        self.diff_hunk_delegate = delegate;
+
+        if !had_delegate && has_delegate {
+            self.load_diff_task.take();
+        } else if had_delegate && !has_delegate {
+            self.buffer.update(cx, |buffer, cx| {
+                buffer.set_all_diff_hunks_collapsed(cx);
+            });
+
+            if let Some(project) = self.project.clone() {
+                self.load_diff_task = Some(
+                    update_uncommitted_diff_for_buffer(
+                        cx.entity(),
+                        &project,
+                        self.buffer.read(cx).all_buffers(),
+                        self.buffer.clone(),
+                        cx,
+                    )
+                    .shared(),
+                );
+            }
+        }
+
         cx.notify();
     }
 
@@ -265,33 +578,6 @@ impl Editor {
         cx.notify();
     }
 
-    pub fn start_temporary_diff_override(&mut self) {
-        self.load_diff_task.take();
-        self.temporary_diff_override = true;
-    }
-
-    pub fn end_temporary_diff_override(&mut self, cx: &mut Context) {
-        self.temporary_diff_override = false;
-        self.render_diff_hunks_as_unstaged = false;
-        self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx);
-        self.buffer.update(cx, |buffer, cx| {
-            buffer.set_all_diff_hunks_collapsed(cx);
-        });
-
-        if let Some(project) = self.project.clone() {
-            self.load_diff_task = Some(
-                update_uncommitted_diff_for_buffer(
-                    cx.entity(),
-                    &project,
-                    self.buffer.read(cx).all_buffers(),
-                    self.buffer.clone(),
-                    cx,
-                )
-                .shared(),
-            );
-        }
-    }
-
     /// Hides the inline blame popover element, in case it's already visible, or
     /// interrupts the task meant to show it, in case the task is running.
     ///
@@ -638,6 +924,29 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
+        let just_started = self.blame.is_none();
+        if just_started {
+            self.start_git_blame(true, window, cx);
+        }
+        let Some(blame) = self.blame.as_ref() else {
+            return;
+        };
+
+        if just_started && !blame.read(cx).has_generated_entries() {
+            let subscription = cx.observe_in(blame, window, |editor, blame, window, cx| {
+                if blame.read(cx).has_generated_entries() {
+                    editor.pending_blame_hover_observation.take();
+                    editor.show_blame_hover_popover(window, cx);
+                }
+            });
+            self.pending_blame_hover_observation = Some(subscription);
+            return;
+        }
+
+        self.show_blame_hover_popover(window, cx);
+    }
+
+    fn show_blame_hover_popover(&mut self, window: &mut Window, cx: &mut Context) {
         let snapshot = self.snapshot(window, cx);
         let cursor = self
             .selections
@@ -647,9 +956,6 @@ impl Editor {
             return;
         };
 
-        if self.blame.is_none() {
-            self.start_git_blame(true, window, cx);
-        }
         let Some(blame) = self.blame.as_ref() else {
             return;
         };
@@ -744,31 +1050,48 @@ impl Editor {
         );
     }
 
-    pub(super) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) {
-        let mut revert_changes = HashMap::default();
-        let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id);
-        for (buffer_id, hunks) in &chunk_by {
-            let hunks = hunks.collect::>();
-            for hunk in &hunks {
-                self.prepare_restore_change(&mut revert_changes, hunk, cx);
-            }
-            self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx);
-        }
-        if !revert_changes.is_empty() {
-            self.buffer().update(cx, |multi_buffer, cx| {
-                for (buffer_id, changes) in revert_changes {
-                    if let Some(buffer) = multi_buffer.buffer(buffer_id) {
-                        buffer.update(cx, |buffer, cx| {
-                            buffer.edit(
-                                changes
-                                    .into_iter()
-                                    .map(|(range, text)| (range, text.to_string())),
-                                None,
-                                cx,
-                            );
-                        });
+    pub(super) fn restore_diff_hunks(
+        &mut self,
+        hunks: Vec,
+        cx: &mut Context,
+    ) {
+        let mut revert_changes = Vec::new();
+        for hunks in hunks {
+            let Some(buffer) = hunks.buffer else {
+                continue;
+            };
+            let diff_snapshot = hunks.diff.read(cx).snapshot(cx);
+            let changes = hunks
+                .hunks
+                .into_iter()
+                .filter_map(|hunk| {
+                    if hunk.diff_base_byte_range == (0..0)
+                        && hunk.buffer_range.start.is_min()
+                        && hunk.buffer_range.end.is_max()
+                    {
+                        return None;
                     }
-                }
+                    let original_text = diff_snapshot
+                        .base_text()
+                        .as_rope()
+                        .slice(hunk.diff_base_byte_range.start..hunk.diff_base_byte_range.end);
+                    Some((hunk.buffer_range, original_text))
+                })
+                .collect::>();
+            if !changes.is_empty() {
+                revert_changes.push((buffer, changes));
+            }
+        }
+
+        for (buffer, changes) in revert_changes {
+            buffer.update(cx, |buffer, cx| {
+                buffer.edit(
+                    changes
+                        .into_iter()
+                        .map(|(range, text)| (range, text.to_string())),
+                    None,
+                    cx,
+                );
             });
         }
     }
@@ -1401,18 +1724,25 @@ impl Editor {
     pub(super) fn toggle_staged_selected_diff_hunks(
         &mut self,
         _: &::git::ToggleStaged,
-        _: &mut Window,
+        window: &mut Window,
         cx: &mut Context,
     ) {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
         let ranges: Vec<_> = self
             .selections
             .disjoint_anchors()
             .iter()
             .map(|s| s.range())
             .collect();
-        let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot);
-        self.stage_or_unstage_diff_hunks(stage, ranges, cx);
+        let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
+        cx.spawn_in(window, async move |this, cx| {
+            task.await?;
+            this.update_in(cx, |this, window, cx| {
+                let snapshot = this.buffer.read(cx).snapshot(cx);
+                let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+                this.apply_toggle(hunks, window, cx);
+            })
+        })
+        .detach_and_log_err(cx);
     }
 
     pub(super) fn stage_and_next(
@@ -1433,42 +1763,47 @@ impl Editor {
         self.do_stage_or_unstage_and_next(false, window, cx);
     }
 
-    pub(super) fn do_stage_or_unstage(
-        &self,
+    pub fn apply_toggle(
+        &mut self,
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.toggle(hunks, self, window, cx);
+    }
+
+    pub fn apply_stage_or_unstage(
+        &mut self,
         stage: bool,
-        buffer_id: BufferId,
-        hunks: impl Iterator,
-        cx: &mut App,
-    ) -> Option<()> {
-        let project = self.project()?;
-        let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?;
-        let diff = self.buffer.read(cx).diff_for(buffer_id)?;
-        let buffer_snapshot = buffer.read(cx).snapshot();
-        let file_exists = buffer_snapshot
-            .file()
-            .is_some_and(|file| file.disk_state().exists());
-        diff.update(cx, |diff, cx| {
-            diff.stage_or_unstage_hunks(
-                stage,
-                &hunks
-                    .map(|hunk| buffer_diff::DiffHunk {
-                        buffer_range: hunk.buffer_range,
-                        // We don't need to pass in word diffs here because they're only used for rendering and
-                        // this function changes internal state
-                        base_word_diffs: Vec::default(),
-                        buffer_word_diffs: Vec::default(),
-                        diff_base_byte_range: hunk.diff_base_byte_range.start.0
-                            ..hunk.diff_base_byte_range.end.0,
-                        secondary_status: hunk.status.secondary,
-                        range: Point::zero()..Point::zero(), // unused
-                    })
-                    .collect::>(),
-                &buffer_snapshot,
-                file_exists,
-                cx,
-            )
-        });
-        None
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.stage_or_unstage(stage, hunks, self, window, cx);
+    }
+
+    pub fn apply_restore(
+        &mut self,
+        hunks: Vec,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let hunks = self.resolve_diff_hunks(hunks, cx);
+        if hunks.is_empty() {
+            return;
+        }
+        let delegate = self.diff_hunk_delegate();
+        delegate.restore(hunks, self, window, cx);
     }
 
     pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool {
@@ -1756,31 +2091,20 @@ impl Editor {
         }
     }
 
-    fn stage_or_unstage_diff_hunks(
+    pub fn stage_or_unstage_diff_hunks(
         &mut self,
         stage: bool,
         ranges: Vec>,
+        window: &mut Window,
         cx: &mut Context,
     ) {
-        if self.delegate_stage_and_restore {
-            let snapshot = self.buffer.read(cx).snapshot(cx);
-            let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
-            if !hunks.is_empty() {
-                cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks });
-            }
-            return;
-        }
         let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
-        cx.spawn(async move |this, cx| {
+        cx.spawn_in(window, async move |this, cx| {
             task.await?;
-            this.update(cx, |this, cx| {
+            this.update_in(cx, |this, window, cx| {
                 let snapshot = this.buffer.read(cx).snapshot(cx);
-                let chunk_by = this
-                    .diff_hunks_in_ranges(&ranges, &snapshot)
-                    .chunk_by(|hunk| hunk.buffer_id);
-                for (buffer_id, hunks) in &chunk_by {
-                    this.do_stage_or_unstage(stage, buffer_id, hunks, cx);
-                }
+                let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+                this.apply_stage_or_unstage(stage, hunks, window, cx);
             })
         })
         .detach_and_log_err(cx);
@@ -1827,66 +2151,8 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        if self.delegate_stage_and_restore {
-            let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges);
-            if !hunks.is_empty() {
-                cx.emit(EditorEvent::RestoreRequested { hunks });
-            }
-            return;
-        }
         let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges);
-        self.transact(window, cx, |editor, window, cx| {
-            editor.restore_diff_hunks(hunks, cx);
-            let selections = editor
-                .selections
-                .all::(&editor.display_snapshot(cx));
-            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
-                s.select(selections);
-            });
-        });
-    }
-
-    fn has_stageable_diff_hunks_in_ranges(
-        &self,
-        ranges: &[Range],
-        snapshot: &MultiBufferSnapshot,
-    ) -> bool {
-        let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot);
-        hunks.any(|hunk| hunk.status().has_secondary_hunk())
-    }
-
-    fn prepare_restore_change(
-        &self,
-        revert_changes: &mut HashMap, Rope)>>,
-        hunk: &MultiBufferDiffHunk,
-        cx: &mut App,
-    ) -> Option<()> {
-        if hunk.is_created_file() {
-            return None;
-        }
-        let multi_buffer = self.buffer.read(cx);
-        let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-        let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?;
-        let original_text = diff_snapshot
-            .base_text()
-            .as_rope()
-            .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0);
-        let buffer = multi_buffer.buffer(hunk.buffer_id)?;
-        let buffer = buffer.read(cx);
-        let buffer_snapshot = buffer.snapshot();
-        let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default();
-        if let Err(i) = buffer_revert_changes.binary_search_by(|probe| {
-            probe
-                .0
-                .start
-                .cmp(&hunk.buffer_range.start, &buffer_snapshot)
-                .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot))
-        }) {
-            buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text));
-            Some(())
-        } else {
-            None
-        }
+        self.apply_restore(hunks, window, cx);
     }
 
     fn save_buffers_for_ranges_if_needed(
@@ -1929,11 +2195,11 @@ impl Editor {
         let ranges = self.selections.disjoint_anchor_ranges().collect::>();
 
         if ranges.iter().any(|range| range.start != range.end) {
-            self.stage_or_unstage_diff_hunks(stage, ranges, cx);
+            self.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
             return;
         }
 
-        self.stage_or_unstage_diff_hunks(stage, ranges, cx);
+        self.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
 
         let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded();
         let wrap_around = !all_diff_hunks_expanded;
@@ -2658,7 +2924,7 @@ pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App)
     cx.set_global(GlobalBlameRenderer(Arc::new(renderer)));
 }
 
-pub(super) fn render_diff_hunk_controls(
+pub fn render_diff_hunk_controls(
     row: u32,
     status: &DiffHunkStatus,
     hunk_range: Range,
@@ -2703,11 +2969,12 @@ pub(super) fn render_diff_hunk_controls(
                     })
                     .on_click({
                         let editor = editor.clone();
-                        move |_event, _window, cx| {
+                        move |_event, window, cx| {
                             editor.update(cx, |editor, cx| {
                                 editor.stage_or_unstage_diff_hunks(
                                     true,
                                     vec![hunk_range.start..hunk_range.start],
+                                    window,
                                     cx,
                                 );
                             });
@@ -2729,11 +2996,12 @@ pub(super) fn render_diff_hunk_controls(
                     })
                     .on_click({
                         let editor = editor.clone();
-                        move |_event, _window, cx| {
+                        move |_event, window, cx| {
                             editor.update(cx, |editor, cx| {
                                 editor.stage_or_unstage_diff_hunks(
                                     false,
                                     vec![hunk_range.start..hunk_range.start],
+                                    window,
                                     cx,
                                 );
                             });
@@ -2860,7 +3128,7 @@ pub(super) fn update_uncommitted_diff_for_buffer(
     });
     cx.spawn(async move |cx| {
         let diffs = future::join_all(tasks).await;
-        if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) {
+        if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) {
             return;
         }
 
diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs
index 0e6ad1cb0ea..e96a1087bd5 100644
--- a/crates/editor/src/git/blame.rs
+++ b/crates/editor/src/git/blame.rs
@@ -1295,4 +1295,101 @@ mod tests {
             filename: String::new(),
         }
     }
+
+    #[gpui::test]
+    async fn test_blame_hover_shows_popover_on_first_trigger(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            use gpui::UpdateGlobal;
+            settings::SettingsStore::update_global(
+                cx,
+                |store: &mut settings::SettingsStore, cx| {
+                    store
+                        .set_user_settings(r#"{"git": {"inline_blame": {"enabled": false}}}"#, cx)
+                        .expect("failed to set user settings");
+                },
+            );
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/my-repo",
+            json!({
+                ".git": {},
+                "file.txt": "line 1\nline 2\nline 3\n"
+            }),
+        )
+        .await;
+
+        fs.set_blame_for_repo(
+            Path::new("/my-repo/.git"),
+            vec![(
+                repo_path("file.txt"),
+                Blame {
+                    entries: vec![
+                        blame_entry("1b1b1b", 0..1),
+                        blame_entry("2c2c2c", 1..2),
+                        blame_entry("3d3d3d", 2..3),
+                    ],
+                    ..Default::default()
+                },
+            )],
+        );
+
+        let project = project::Project::test(fs, ["/my-repo".as_ref()], cx).await;
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer("/my-repo/file.txt", cx)
+            })
+            .await
+            .unwrap();
+        let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+
+        let (editor, cx) = cx.add_window_view(|window, cx| {
+            crate::test::build_editor_with_project(project, multi_buffer, window, cx)
+        });
+
+        // Verify blame is not loaded yet
+        editor.update(cx, |editor, _cx| {
+            assert!(
+                editor.blame().is_none(),
+                "blame should not be loaded initially"
+            );
+        });
+
+        // Focus the editor so that blame generation proceeds
+        editor.update_in(cx, |editor, window, cx| {
+            editor.focus_handle.focus(window, cx);
+        });
+
+        // Trigger BlameHover — this should start blame loading and defer showing the popover
+        editor.update_in(cx, |editor, window, cx| {
+            assert!(editor.blame().is_none());
+            editor.blame_hover(&crate::BlameHover, window, cx);
+            assert!(
+                editor.blame().is_some(),
+                "blame entity should be created after blame_hover"
+            );
+            assert!(
+                editor.pending_blame_hover_observation.is_some(),
+                "should have registered an observation to wait for blame data"
+            );
+        });
+
+        // Let the async blame generation complete
+        cx.run_until_parked();
+
+        // The observation should have fired and cleaned itself up
+        editor.update(cx, |editor, cx| {
+            assert!(
+                editor.pending_blame_hover_observation.is_none(),
+                "observation should be consumed after blame data is generated"
+            );
+            assert!(
+                editor.blame().unwrap().read(cx).has_generated_entries(),
+                "blame should have generated entries"
+            );
+        });
+    }
 }
diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs
index 96aeae19933..ff4b8c4b8d0 100644
--- a/crates/editor/src/hover_popover.rs
+++ b/crates/editor/src/hover_popover.rs
@@ -798,19 +798,33 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
     }
 }
 
+fn parse_file_link(link: &str) -> Option<(PathBuf, Option)> {
+    let uri = Url::parse(link).ok().filter(|uri| uri.scheme() == "file")?;
+    let fragment = uri.fragment().map(ToOwned::to_owned);
+    let path = uri.to_file_path().unwrap_or_else(|_| {
+        let encoded = uri.path();
+
+        urlencoding::decode(encoded)
+            .map(Cow::into_owned)
+            .map(PathBuf::from)
+            .unwrap_or_else(|_| PathBuf::from(encoded))
+    });
+
+    Some((path, fragment))
+}
+
 pub fn open_markdown_url(
     workspace: Option>,
     link: SharedString,
     window: &mut Window,
     cx: &mut App,
 ) {
-    if let Ok(uri) = Url::parse(&link)
-        && uri.scheme() == "file"
+    if let Some((path, fragment)) = parse_file_link(&link)
         && let Some(workspace) = workspace
     {
         workspace.update(cx, |workspace, cx| {
             let task = workspace.open_abs_path(
-                PathBuf::from(uri.path()),
+                path,
                 OpenOptions {
                     visible: Some(OpenVisible::None),
                     ..Default::default()
@@ -823,7 +837,7 @@ pub fn open_markdown_url(
                 let item = task.await?;
                 // Ruby LSP uses URLs with #L1,1-4,4
                 // we'll just take the first number and assume it's a line number
-                let Some(fragment) = uri.fragment() else {
+                let Some(fragment) = fragment else {
                     return anyhow::Ok(());
                 };
                 let mut accum = 0u32;
@@ -2747,4 +2761,21 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_parse_file_links() {
+        assert_eq!(
+            parse_file_link("file:///path/to/file"),
+            Some((PathBuf::from("/path/to/file"), None))
+        );
+        assert_eq!(
+            parse_file_link("file:///path/to/file%20with%20spaces"),
+            Some((PathBuf::from("/path/to/file with spaces"), None))
+        );
+        assert_eq!(
+            parse_file_link("file:///path/to/file#123"),
+            Some((PathBuf::from("/path/to/file"), Some("123".to_string())))
+        );
+        assert_eq!(parse_file_link("http://example.com/"), None,);
+    }
 }
diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs
index 876bb8fa286..8192fc4a18f 100644
--- a/crates/editor/src/input.rs
+++ b/crates/editor/src/input.rs
@@ -1666,10 +1666,8 @@ impl Editor {
             this.change_selections(Default::default(), window, cx, |s| s.select(selections));
 
             let selections = this.selections.all::(&this.display_snapshot(cx));
-            let selections_on_single_row = selections.windows(2).all(|selections| {
-                selections[0].start.row == selections[1].start.row
-                    && selections[0].end.row == selections[1].end.row
-                    && selections[0].start.row == selections[0].end.row
+            let selections_on_single_row = selections.array_windows::<2>().all(|[a, b]| {
+                a.start.row == b.start.row && a.end.row == b.end.row && a.start.row == a.end.row
             });
             let selections_selecting = selections
                 .iter()
diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs
index f61546e289a..4acdb10ff75 100644
--- a/crates/editor/src/items.rs
+++ b/crates/editor/src/items.rs
@@ -18,13 +18,15 @@ use gpui::{
     IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
 };
 use language::{
-    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point,
-    SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
+    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT,
+    Point, SelectionGoal,
+    language_settings::{FormatOnSave, LanguageSettings},
+    proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
 use project::{
-    File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
+    File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger,
     project_settings::ProjectSettings, search::SearchQuery,
 };
 use rope::TextSummary;
@@ -39,7 +41,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
+use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _};
 use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
 use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
@@ -717,7 +719,22 @@ impl Item for Editor {
     }
 
     fn suggested_filename(&self, cx: &App) -> SharedString {
-        self.buffer.read(cx).title(cx).to_string().into()
+        let multi_buffer = self.buffer.read(cx);
+        let title = multi_buffer.title(cx);
+        if let Some(buffer) = multi_buffer.as_singleton() {
+            let buffer = buffer.read(cx);
+            if buffer.file().is_none()
+                && let Some(language) = buffer.language()
+                && *language != *PLAIN_TEXT
+                && let Some(suffix) = language.path_suffixes().first()
+                && !suffix.is_empty()
+                && !title.ends_with(&format!(".{suffix}"))
+            {
+                return format!("{title}.{suffix}").into();
+            }
+        }
+
+        title.to_string().into()
     }
 
     fn tab_icon(&self, _: &Window, cx: &App) -> Option {
@@ -959,16 +976,21 @@ impl Item for Editor {
 
         cx.spawn_in(window, async move |this, cx| {
             if options.format {
-                this.update_in(cx, |editor, window, cx| {
-                    editor.perform_format(
-                        project.clone(),
+                let format_task = this.update_in(cx, |editor, window, cx| {
+                    let format_target = compute_format_target(
+                        &buffers_to_save,
                         format_trigger,
-                        FormatTarget::Buffers(buffers_to_save.clone()),
-                        window,
+                        editor.buffer(),
+                        project.read(cx).git_store(),
                         cx,
-                    )
-                })?
-                .await?;
+                    );
+                    format_target.map(|target| {
+                        editor.perform_format(project.clone(), format_trigger, target, window, cx)
+                    })
+                })?;
+                if let Some(format_task) = format_task {
+                    format_task.await?;
+                }
             }
 
             if !buffers_to_save.is_empty() {
@@ -2252,6 +2274,115 @@ fn chunk_search_range(
     }))
 }
 
+/// Decides what to format based on the `format_on_save` settings of the saved buffers.
+///
+/// In the modifications modes, only lines with unstaged changes are formatted.
+/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available`
+/// falls back to formatting entire buffers.
+/// When a diff is available but empty, nothing is formatted in either mode.
+fn compute_format_target(
+    buffers: &HashSet>,
+    trigger: FormatTrigger,
+    multi_buffer: &Entity,
+    git_store: &Entity,
+    cx: &App,
+) -> Option {
+    if trigger == FormatTrigger::Manual {
+        return Some(FormatTarget::Buffers(buffers.clone()));
+    }
+
+    let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+    let git_store = git_store.read(cx);
+
+    let mut fall_back_to_full_format = false;
+    let mut modified_ranges: Vec> = Vec::new();
+
+    for buffer_entity in buffers.iter() {
+        let buffer = buffer_entity.read(cx);
+        let settings = LanguageSettings::for_buffer(buffer, cx);
+        match settings.format_on_save {
+            FormatOnSave::On | FormatOnSave::Off => {
+                return Some(FormatTarget::Buffers(buffers.clone()));
+            }
+            FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {}
+        }
+
+        let Some(diff_snapshot) = git_store
+            .get_unstaged_diff(buffer.remote_id(), cx)
+            .map(|diff| diff.read(cx).snapshot(cx))
+        else {
+            if settings.format_on_save == FormatOnSave::ModificationsIfAvailable {
+                fall_back_to_full_format = true;
+            }
+            continue;
+        };
+
+        let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot);
+        let flat_anchors = anchor_ranges
+            .iter()
+            .flat_map(|range| [range.start, range.end])
+            .collect::>();
+        let multi_buffer_anchors =
+            multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors);
+        for pair in multi_buffer_anchors.chunks_exact(2) {
+            let (Some(start), Some(end)) = (&pair[0], &pair[1]) else {
+                continue;
+            };
+            modified_ranges
+                .push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot));
+        }
+    }
+
+    if fall_back_to_full_format {
+        Some(FormatTarget::Buffers(buffers.clone()))
+    } else if modified_ranges.is_empty() {
+        None
+    } else {
+        Some(FormatTarget::Ranges(modified_ranges))
+    }
+}
+
+/// Computes the buffer ranges that have unstaged changes, expanded to full lines and
+/// with adjacent hunks merged, for use with format-on-save. An empty result means the
+/// buffer has no formatable modifications.
+fn compute_modified_ranges(
+    buffer_snapshot: &language::BufferSnapshot,
+    diff_snapshot: &buffer_diff::BufferDiffSnapshot,
+) -> Vec> {
+    let mut merged: Vec> = Vec::new();
+    for hunk in diff_snapshot.hunks(buffer_snapshot) {
+        let range = hunk.buffer_range;
+        if range.start.cmp(&range.end, buffer_snapshot).is_eq() {
+            // Deletion-only hunks produce no buffer content to format.
+            continue;
+        }
+        let start_point = range.start.to_point(buffer_snapshot);
+        let end_point = range.end.to_point(buffer_snapshot);
+        let start_row = start_point.row;
+        let end_row = if end_point.column == 0 && end_point.row > start_point.row {
+            end_point.row - 1
+        } else {
+            end_point.row
+        };
+        let line_start = text::Point::new(start_row, 0);
+        let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row));
+        let expanded =
+            buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end);
+
+        if let Some(last) = merged.last_mut() {
+            let last_end_point = last.end.to_point(buffer_snapshot);
+            if start_row <= last_end_point.row + 1 {
+                if expanded.end.to_point(buffer_snapshot) > last_end_point {
+                    last.end = expanded.end;
+                }
+                continue;
+            }
+        }
+        merged.push(expanded);
+    }
+    merged
+}
+
 #[cfg(test)]
 mod tests {
     use crate::editor_tests::init_test;
@@ -2386,6 +2517,95 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_suggested_filename_uses_language_extension_for_untitled_buffer(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| Buffer::local("", cx).with_language(languages::rust_lang(), cx))
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "untitled.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_appends_extension_to_content_title(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("sadsdsads\nmore text", cx).with_language(languages::rust_lang(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.tab_content_text(0, cx).as_ref(), "sadsdsads");
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "sadsdsads.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_does_not_duplicate_extension(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("main.rs\nfn main() {}", cx).with_language(languages::rust_lang(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "main.rs");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_keeps_content_title_for_plain_text(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| {
+            cx.new(|cx| {
+                Buffer::local("shopping list\nmilk", cx)
+                    .with_language(language::PLAIN_TEXT.clone(), cx)
+            })
+        });
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_suggested_filename_keeps_content_title_without_language(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_test(cx, |_| {});
+
+        let buffer = cx.update(|cx| cx.new(|cx| Buffer::local("shopping list\nmilk", cx)));
+        let (editor, cx) =
+            cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx));
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list");
+        });
+    }
+
     async fn deserialize_editor(
         item_id: ItemId,
         workspace_id: WorkspaceId,
@@ -2817,4 +3037,124 @@ mod tests {
             "Editor::deserialize should not add items to panes as a side effect"
         );
     }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n";
+        // Modify line1 and line5 to create two non-adjacent hunks.
+        let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges");
+
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot);
+            assert_eq!(r0.start.row, 1, "first hunk should start at row 1");
+            assert_eq!(r0.end.row, 1, "first hunk should end at row 1");
+            assert_eq!(r1.start.row, 5, "second hunk should start at row 5");
+            assert_eq!(r1.end.row, 5, "second hunk should end at row 5");
+        });
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) {
+        let buffer_text = "line0\nline1\nline2\n";
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(
+                    buffer_text,
+                    &buffer.text_snapshot(),
+                    cx,
+                )
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "buffer that matches its diff base should produce no modified ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\n";
+        // Buffer has line1 deleted (pure deletion).
+        let buffer_text = "line0\nline2\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        // Verify the diff has a deletion hunk.
+        let hunk_count = buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            diff_snapshot.hunks(text_snapshot).count()
+        });
+        assert!(hunk_count > 0, "diff should have hunks");
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "deletion-only hunks should be skipped, leaving no ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\n";
+        // Modify lines 2 and 3 which are adjacent; they should merge into one range.
+        let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges.len(),
+            1,
+            "adjacent hunks (rows 2 and 3) should be merged into one range"
+        );
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            assert_eq!(r.start.row, 2, "merged range should start at row 2");
+            assert_eq!(r.end.row, 3, "merged range should end at row 3");
+        });
+    }
 }
diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs
index 5742c9d20ce..92b1a30cf22 100644
--- a/crates/editor/src/movement.rs
+++ b/crates/editor/src/movement.rs
@@ -582,6 +582,99 @@ pub fn end_of_paragraph(
     map.max_point()
 }
 
+/// Returns whether `row` is part of a comment paragraph: a line whose first
+/// non-whitespace character lies within a comment scope and which contains at
+/// least one alphanumeric character.
+///
+/// This intentionally excludes:
+/// - blank lines and code lines,
+/// - end-of-line comments preceded by code (the first non-whitespace character
+///   is then code, not a comment),
+/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
+///   alphanumeric content), which act as paragraph separators.
+fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
+    let buffer_row = MultiBufferRow(row);
+    if snapshot.is_line_blank(buffer_row) {
+        return false;
+    }
+    let indent_len = snapshot.indent_size_for_line(buffer_row).len;
+    let indent_end = Point::new(row, indent_len);
+    let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
+        matches!(
+            scope.override_name(),
+            Some("comment") | Some("comment.inclusive")
+        )
+    });
+    if !in_comment {
+        return false;
+    }
+    let line_end = Point::new(row, snapshot.line_len(buffer_row));
+    snapshot
+        .text_for_range(indent_end..line_end)
+        .flat_map(|chunk| chunk.chars())
+        .any(|c| c.is_alphanumeric())
+}
+
+/// Returns the position of the first non-whitespace character of the next or
+/// previous comment paragraph, relative to `from`.
+///
+/// A comment paragraph is a run of consecutive comment lines (see
+/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
+/// lines, and blank/divider comment lines. If no such paragraph exists in the
+/// requested direction, `from` is returned unchanged.
+///
+/// Both directions always move to a *different* paragraph than the one the
+/// caret is in: when the caret is inside a comment paragraph, the entire
+/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
+/// start rather than the current paragraph's own start.
+pub fn comment_paragraph(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    direction: Direction,
+) -> DisplayPoint {
+    let snapshot = map.buffer_snapshot();
+    let from_point = from.to_point(map);
+    let max_row = snapshot.max_row().0;
+
+    let is_paragraph_start = |row: u32| {
+        is_comment_paragraph_line(snapshot, row)
+            && (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
+    };
+    let paragraph_start_point =
+        |row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
+
+    let target = match direction {
+        Direction::Next => (from_point.row..=max_row).find_map(|row| {
+            let point = paragraph_start_point(row);
+            (point > from_point && is_paragraph_start(row)).then_some(point)
+        }),
+        Direction::Prev => {
+            // If the caret is within a comment paragraph, skip over the whole
+            // current paragraph so we land on the *previous* paragraph rather
+            // than stopping at the current paragraph's own start.
+            let mut boundary_row = from_point.row;
+            if is_comment_paragraph_line(snapshot, boundary_row) {
+                while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
+                    boundary_row -= 1;
+                }
+                (0..boundary_row)
+                    .rev()
+                    .find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
+            } else {
+                (0..=from_point.row).rev().find_map(|row| {
+                    let point = paragraph_start_point(row);
+                    (point < from_point && is_paragraph_start(row)).then_some(point)
+                })
+            }
+        }
+    };
+
+    match target {
+        Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
+        None => from,
+    }
+}
+
 pub fn start_of_excerpt(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs
index 4f9880c438b..471f68b16b8 100644
--- a/crates/editor/src/navigation.rs
+++ b/crates/editor/src/navigation.rs
@@ -608,6 +608,68 @@ impl Editor {
         })
     }
 
+    pub fn move_to_next_comment_paragraph(
+        &mut self,
+        _: &MoveToNextCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Next,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
+    pub fn move_to_previous_comment_paragraph(
+        &mut self,
+        _: &MoveToPreviousCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Prev,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
     pub fn select_to_start_of_paragraph(
         &mut self,
         _: &SelectToStartOfParagraph,
diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs
index ec7f9036c4a..dcd96c675c5 100644
--- a/crates/editor/src/scroll.rs
+++ b/crates/editor/src/scroll.rs
@@ -5,7 +5,7 @@ pub(crate) mod scroll_amount;
 use crate::editor_settings::ScrollBeyondLastLine;
 use crate::{
     Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings,
-    MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint,
+    MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint,
     display_map::{DisplaySnapshot, ToDisplayPoint},
     hover_popover::hide_hover,
     persistence::EditorDb,
@@ -945,6 +945,63 @@ impl Editor {
         self.set_scroll_position(new_position, window, cx);
     }
 
+    pub fn scroll_screen_with_cursor_margin(
+        &mut self,
+        amount: &ScrollAmount,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.scroll_screen(amount, window, cx);
+
+        let Some(visible_line_count) = self.visible_line_count() else {
+            return;
+        };
+        let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+        let top = self
+            .scroll_manager
+            .scroll_top_display_point(&display_snapshot, cx);
+        let vertical_scroll_margin =
+            (self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2);
+
+        let max_point = display_snapshot.max_point();
+        let min_row = if top.row().0 == 0 {
+            DisplayRow(0)
+        } else {
+            DisplayRow(top.row().0 + vertical_scroll_margin)
+        };
+        let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
+            max_point.row()
+        } else {
+            DisplayRow(
+                (top.row().0 + visible_line_count as u32)
+                    .saturating_sub(1 + vertical_scroll_margin),
+            )
+        };
+
+        self.change_selections(
+            SelectionEffects::no_scroll().nav_history(false),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    let head = selection.head();
+                    let new_row = if head.row() < min_row {
+                        min_row
+                    } else if head.row() > max_row {
+                        max_row
+                    } else {
+                        head.row()
+                    };
+                    if new_row != head.row() {
+                        let new_head =
+                            map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left);
+                        selection.collapse_to(new_head, selection.goal);
+                    }
+                })
+            },
+        );
+    }
+
     /// Returns an ordering. The newest selection is:
     ///     Ordering::Equal => on screen
     ///     Ordering::Less => above or to the left of the screen
diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs
index e568b886f73..93a6f96a443 100644
--- a/crates/editor/src/split.rs
+++ b/crates/editor/src/split.rs
@@ -3,27 +3,27 @@ use std::{
     sync::Arc,
 };
 
-use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkStatus};
 use collections::HashMap;
 
+use fs::Fs;
 use gpui::{
-    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription,
-    WeakEntity, canvas,
+    Action, AnyElement, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity,
+    canvas, prelude::*,
 };
-use itertools::Itertools;
+
 use language::{Buffer, Capability, HighlightedText};
 use multi_buffer::{
     Anchor, AnchorRangeExt as _, BufferOffset, ExcerptRange, ExpandExcerptDirection, MultiBuffer,
-    MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey,
+    MultiBufferPoint, MultiBufferSnapshot, PathKey,
 };
 use project::Project;
 use rope::Point;
-use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore};
+use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore, update_settings_file};
 use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _};
-use ui::{
-    App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
-    Styled as _, Window, div,
-};
+
+use ui::{Toggleable as _, Tooltip, prelude::*, render_modifiers};
+use util::ResultExt as _;
 
 use crate::{
     display_map::CompanionExcerptPatch,
@@ -37,11 +37,12 @@ use workspace::{
 };
 
 use crate::{
-    Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap,
+    Autoscroll, DiffHunkDelegate, Editor, EditorEvent, EditorSettings, ResolvedDiffHunks,
+    ToggleSoftWrap, UncommittedDiffHunkDelegate,
     actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint},
     display_map::Companion,
 };
-use zed_actions::assistant::InlineAssist;
+use zed_actions::{OpenSettingsAt, assistant::InlineAssist};
 
 pub(crate) fn patches_for_lhs_range(
     rhs_snapshot: &MultiBufferSnapshot,
@@ -150,32 +151,97 @@ fn translate_lhs_selections_to_rhs(
     translated
 }
 
-fn translate_lhs_hunks_to_rhs(
-    lhs_hunks: &[MultiBufferDiffHunk],
-    splittable: &SplittableEditor,
-    cx: &App,
-) -> Vec {
-    let Some(lhs) = &splittable.lhs else {
-        return vec![];
-    };
-    let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx);
-    let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx);
-    let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect();
+struct SplitLhsDiffHunkDelegate {
+    splittable: WeakEntity,
+}
 
-    let mut translated = Vec::new();
-    for lhs_hunk in lhs_hunks {
-        let Some(diff) = lhs_snapshot.diff_for_buffer_id(lhs_hunk.buffer_id) else {
-            continue;
-        };
-        let rhs_buffer_id = diff.buffer_id();
-        if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| {
-            rhs_hunk.buffer_id == rhs_buffer_id
-                && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range
-        }) {
-            translated.push(rhs_hunk.clone());
-        }
+impl DiffHunkDelegate for SplitLhsDiffHunkDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.toggle(hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.stage_or_unstage(stage, hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    fn restore(
+        &self,
+        hunks: Vec,
+        _editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.splittable
+            .update(cx, |splittable, cx| {
+                splittable.rhs_editor.update(cx, |editor, cx| {
+                    let delegate = editor.diff_hunk_delegate();
+                    delegate.restore(hunks, editor, window, cx);
+                });
+            })
+            .log_err();
+    }
+
+    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 {
+        let Some(splittable) = self.splittable.upgrade() else {
+            return gpui::Empty.into_any_element();
+        };
+        let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate();
+        delegate.render_hunk_controls(
+            row,
+            status,
+            hunk_range,
+            is_created_file,
+            line_height,
+            editor,
+            window,
+            cx,
+        )
+    }
+
+    fn render_hunk_as_staged(&self, status: &DiffHunkStatus, cx: &App) -> bool {
+        let Some(splittable) = self.splittable.upgrade() else {
+            return false;
+        };
+        let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate();
+        delegate.render_hunk_as_staged(status, cx)
     }
-    translated
 }
 
 fn patches_for_range(
@@ -401,6 +467,124 @@ fn patch_for_excerpt(
 #[action(namespace = editor)]
 pub struct ToggleSplitDiff;
 
+/// Unified/split diff view toggle buttons, shared by the toolbars of every
+/// diff view (project diff, branch diff, solo diff) so they can't drift apart.
+#[derive(gpui::IntoElement)]
+pub struct DiffStyleControls {
+    splittable_editor: Entity,
+}
+
+impl DiffStyleControls {
+    pub fn new(splittable_editor: Entity) -> Self {
+        Self { splittable_editor }
+    }
+
+    fn set_diff_view_style(
+        splittable_editor: &Entity,
+        diff_view_style: DiffViewStyle,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        update_settings_file(::global(cx), cx, move |settings, _| {
+            settings.editor.diff_view_style = Some(diff_view_style);
+        });
+
+        splittable_editor.update(cx, |editor, cx| {
+            if editor.diff_view_style() != diff_view_style {
+                editor.toggle_split(&ToggleSplitDiff, window, cx);
+            }
+        });
+    }
+}
+
+impl RenderOnce for DiffStyleControls {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
+        let editor = self.splittable_editor.read(cx);
+        let diff_view_style = editor.diff_view_style();
+        let is_split_set = diff_view_style == DiffViewStyle::Split;
+        let is_split_pending = is_split_set && !editor.is_split();
+        let min_columns = EditorSettings::get_global(cx).minimum_split_diff_width as u32;
+
+        let split_icon = if is_split_pending {
+            IconName::DiffSplitAuto
+        } else {
+            IconName::DiffSplit
+        };
+
+        h_flex()
+            .gap_1()
+            .child(
+                IconButton::new("diff-style-unified", IconName::DiffUnified)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(diff_view_style == DiffViewStyle::Unified)
+                    .tooltip(Tooltip::text("Unified"))
+                    .on_click({
+                        let splittable_editor = self.splittable_editor.clone();
+                        move |_, window, cx| {
+                            Self::set_diff_view_style(
+                                &splittable_editor,
+                                DiffViewStyle::Unified,
+                                window,
+                                cx,
+                            );
+                        }
+                    }),
+            )
+            .child(
+                IconButton::new("diff-style-split", split_icon)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(is_split_set)
+                    .tooltip(Tooltip::element(move |_, cx| {
+                        let message = if is_split_pending {
+                            format!("Split when wider than {} columns", min_columns).into()
+                        } else {
+                            SharedString::from("Split")
+                        };
+
+                        v_flex()
+                            .child(message)
+                            .child(
+                                h_flex()
+                                    .gap_0p5()
+                                    .text_ui_sm(cx)
+                                    .text_color(Color::Muted.color(cx))
+                                    .children(render_modifiers(
+                                        &gpui::Modifiers::secondary_key(),
+                                        PlatformStyle::platform(),
+                                        None,
+                                        Some(TextSize::Small.rems(cx).into()),
+                                        false,
+                                    ))
+                                    .child("click to change min width"),
+                            )
+                            .into_any_element()
+                    }))
+                    .on_click({
+                        let splittable_editor = self.splittable_editor.clone();
+                        move |_, window, cx| {
+                            if window.modifiers().secondary() {
+                                window.dispatch_action(
+                                    OpenSettingsAt {
+                                        path: "minimum_split_diff_width".to_string(),
+                                        target: None,
+                                    }
+                                    .boxed_clone(),
+                                    cx,
+                                );
+                            } else {
+                                Self::set_diff_view_style(
+                                    &splittable_editor,
+                                    DiffViewStyle::Split,
+                                    window,
+                                    cx,
+                                );
+                            }
+                        }
+                    }),
+            )
+    }
+}
+
 pub struct SplittableEditor {
     rhs_multibuffer: Entity,
     rhs_editor: Entity,
@@ -452,28 +636,13 @@ impl SplittableEditor {
         self.lhs.is_some()
     }
 
-    pub fn set_render_diff_hunk_controls(
+    pub fn set_diff_hunk_delegate(
         &self,
-        render_diff_hunk_controls: RenderDiffHunkControlsFn,
+        delegate: Option>,
         cx: &mut Context,
     ) {
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx);
-        });
-    }
-
-    pub fn disable_diff_hunk_controls(&self, cx: &mut Context) {
-        let empty_controls = Arc::new(|_, _: &_, _, _, _, _: &_, _: &mut _, _: &mut _| {
-            gpui::Empty.into_any_element()
-        });
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(empty_controls.clone(), cx);
-        });
-    }
-
-    pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) {
-        self.update_editors(cx, |editor, cx| {
-            editor.set_render_diff_hunks_as_unstaged(true, cx);
+        self.rhs_editor.update(cx, |editor, cx| {
+            editor.set_diff_hunk_delegate(delegate, cx);
         });
     }
 
@@ -514,7 +683,7 @@ impl SplittableEditor {
             editor.disable_inline_diagnostics();
             editor.disable_mouse_wheel_zoom();
             editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
-            editor.start_temporary_diff_override();
+            editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx);
             editor
         });
         // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs
@@ -612,15 +781,16 @@ impl SplittableEditor {
             multibuffer
         });
 
-        let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone();
-        let render_diff_hunks_as_unstaged = self.rhs_editor.read(cx).render_diff_hunks_as_unstaged;
+        let splittable = cx.weak_entity();
         let lhs_editor = cx.new(|cx| {
             let mut editor =
                 Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx);
-            editor.set_render_diff_hunks_as_unstaged(render_diff_hunks_as_unstaged, cx);
             editor.set_number_deleted_lines(true, cx);
             editor.set_delegate_expand_excerpts(true);
-            editor.set_delegate_stage_and_restore(true);
+            editor.set_diff_hunk_delegate(
+                Some(Arc::new(SplitLhsDiffHunkDelegate { splittable })),
+                cx,
+            );
             editor.set_delegate_open_excerpts(true);
             editor.set_show_vertical_scrollbar(false, cx);
             editor.disable_lsp_data();
@@ -631,10 +801,6 @@ impl SplittableEditor {
             editor
         });
 
-        lhs_editor.update(cx, |editor, cx| {
-            editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
-        });
-
         let mut subscriptions = vec![cx.subscribe_in(
             &lhs_editor,
             window,
@@ -665,30 +831,7 @@ impl SplittableEditor {
                         this.expand_excerpts(rhs_anchors.into_iter(), *lines, *direction, cx);
                     }
                 }
-                EditorEvent::StageOrUnstageRequested { stage, hunks } => {
-                    if this.lhs.is_some() {
-                        let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
-                        if !translated.is_empty() {
-                            let stage = *stage;
-                            this.rhs_editor.update(cx, |editor, cx| {
-                                let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id);
-                                for (buffer_id, hunks) in &chunk_by {
-                                    editor.do_stage_or_unstage(stage, buffer_id, hunks, cx);
-                                }
-                            });
-                        }
-                    }
-                }
-                EditorEvent::RestoreRequested { hunks } => {
-                    if this.lhs.is_some() {
-                        let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
-                        if !translated.is_empty() {
-                            this.rhs_editor.update(cx, |editor, cx| {
-                                editor.restore_diff_hunks(translated, cx);
-                            });
-                        }
-                    }
-                }
+
                 EditorEvent::OpenExcerptsRequested {
                     selections_by_buffer,
                     split,
diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs
index 7c77dc79baf..0f8d2c52b2c 100644
--- a/crates/extension_api/wit/since_v0.8.0/settings.rs
+++ b/crates/extension_api/wit/since_v0.8.0/settings.rs
@@ -6,6 +6,8 @@ use std::{collections::HashMap, num::NonZeroU32};
 pub struct LanguageSettings {
     /// How many columns a tab should occupy.
     pub tab_size: NonZeroU32,
+    /// Whether to indent with hard tabs (true) or spaces (false).
+    pub hard_tabs: bool,
     /// The preferred line length (column at which to wrap).
     pub preferred_line_length: u32,
 }
diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs
index 59b00f936f9..aae9dbb942d 100644
--- a/crates/extension_host/src/extension_store_test.rs
+++ b/crates/extension_host/src/extension_store_test.rs
@@ -769,7 +769,11 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
     theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor());
     let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
     language_extension::init(
-        LspAccess::ViaLspStore(project.update(cx, |project, _| project.lsp_store())),
+        LspAccess::ViaLspStore(
+            project
+                .update(cx, |project, _| project.lsp_store())
+                .downgrade(),
+        ),
         proxy.clone(),
         language_registry.clone(),
     );
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
index 15e5ff94062..25cd0c2d368 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
@@ -968,6 +968,7 @@ impl ExtensionImports for WasmState {
                         );
                         Ok(serde_json::to_string(&settings::LanguageSettings {
                             tab_size: settings.tab_size,
+                            hard_tabs: settings.hard_tabs,
                             preferred_line_length: settings.preferred_line_length,
                         })?)
                     }
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index 31096cc38bc..f9db079a491 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -118,14 +118,3 @@ impl FeatureFlag for AutoWatchFeatureFlag {
     type Value = PresenceFlag;
 }
 register_feature_flag!(AutoWatchFeatureFlag);
-
-/// Wraps agent-run terminal commands in an OS-level sandbox where supported
-/// (currently macOS Seatbelt only). When off, terminal commands run with the
-/// agent's full ambient permissions, as they always have.
-pub struct SandboxingFeatureFlag;
-
-impl FeatureFlag for SandboxingFeatureFlag {
-    const NAME: &'static str = "sandboxing";
-    type Value = PresenceFlag;
-}
-register_feature_flag!(SandboxingFeatureFlag);
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index f3711361d13..1203e3dde02 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -1952,7 +1952,7 @@ impl<'a> PathComponentSlice<'a> {
 
     fn elision_range(&self, budget: usize, matches: &[usize]) -> Option> {
         let eligible_range = {
-            assert!(matches.windows(2).all(|w| w[0] <= w[1]));
+            assert!(matches.is_sorted());
             let mut matches = matches.iter().copied().peekable();
             let mut longest: Option> = None;
             let mut cur = 0..0;
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 45ee57b35f5..00e91cb7998 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -162,28 +162,6 @@ impl FakeGitRepository {
 }
 
 impl GitRepository for FakeGitRepository {
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let fut = self.with_state_async(false, move |state| {
-            state
-                .index_contents
-                .get(&path)
-                .context("not present in index")
-                .cloned()
-        });
-        self.executor.spawn(async move { fut.await.ok() }).boxed()
-    }
-
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let fut = self.with_state_async(false, move |state| {
-            state
-                .head_contents
-                .get(&path)
-                .context("not present in HEAD")
-                .cloned()
-        });
-        self.executor.spawn(async move { fut.await.ok() }).boxed()
-    }
-
     fn load_commit_template(&self) -> BoxFuture<'_, Result>> {
         async { Ok(None) }.boxed()
     }
@@ -264,9 +242,38 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> {
+        let fut = self.with_state_async(false, move |state| {
+            Ok(revisions
+                .into_iter()
+                .map(|rev| {
+                    let (prefix, path) = rev.split_once(':')?;
+                    let repo_path = RepoPath::new(path).ok()?;
+                    match prefix {
+                        "" => state.index_contents.get(&repo_path).cloned(),
+                        "HEAD" => state.head_contents.get(&repo_path).cloned(),
+                        _ => None,
+                    }
+                })
+                .collect())
+        });
+        self.executor.spawn(fut).boxed()
+    }
+
     fn show(&self, commit: String) -> BoxFuture<'_, Result> {
         self.with_state_async(false, move |state| {
-            let sha = state.refs.get(&commit).cloned().unwrap_or(commit);
+            let sha = match state.refs.get(&commit) {
+                Some(sha) => sha.clone(),
+                // Real git fails to show an unresolvable revision (e.g. HEAD on an
+                // unborn branch), so only fall back to treating the input as a sha.
+                None => {
+                    anyhow::ensure!(
+                        commit.parse::().is_ok(),
+                        "unable to resolve revision: {commit}"
+                    );
+                    commit
+                }
+            };
             Ok(CommitDetails {
                 sha: sha.into(),
                 message: "initial commit".into(),
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index 59fe449b185..ec6e0554b9a 100644
--- a/crates/fs/src/fs.rs
+++ b/crates/fs/src/fs.rs
@@ -295,6 +295,7 @@ pub struct Metadata {
     pub len: u64,
     pub is_fifo: bool,
     pub is_executable: bool,
+    pub is_writable: bool,
 }
 
 /// Filesystem modification time. The purpose of this newtype is to discourage use of operations
@@ -807,15 +808,8 @@ impl Fs for RealFs {
         // its target and leave the link behind.
         let path = std::path::absolute(path).context("Could not make the path absolute")?;
 
-        let (tx, rx) = futures::channel::oneshot::channel();
-        std::thread::Builder::new()
-            .name("trash file or dir".to_string())
-            .spawn(|| tx.send(trash::delete_with_info(path)))
-            .expect("The os can spawn threads");
-
-        Ok(rx
+        Ok(smol::unblock(move || trash::delete_with_info(path))
             .await
-            .context("Tx dropped or fs.restore panicked")?
             .context("Could not trash file or dir")?
             .into())
     }
@@ -1032,6 +1026,7 @@ impl Fs for RealFs {
             is_dir: metadata.file_type().is_dir(),
             is_fifo,
             is_executable,
+            is_writable: !metadata.permissions().readonly(),
         }))
     }
 
@@ -1098,7 +1093,11 @@ impl Fs for RealFs {
                 }
             }
             watcher.add(&target).ok();
-            if let Some(parent) = target.parent() {
+            // Skipped for poll watchers: PollWatcher::watch() recursively scans
+            // at registration, blocking on large virtual filesystem mounts
+            if let Some(parent) = target.parent()
+                && !fs_watcher::requires_poll_watcher(parent)
+            {
                 watcher.add(parent).log_err();
             }
         }
@@ -2404,7 +2403,18 @@ impl FakeFs {
         self.state
             .lock()
             .remove_dir_errors
-            .insert(normalize_path(path.as_ref()), message);
+            .insert(Self::remove_dir_error_key(path.as_ref()), message);
+    }
+
+    /// Entry resolution in `try_entry` ignores drive prefixes, so the error
+    /// injection map must too.
+    /// Otherwise, on Windows, a key like `C:\workspace\dir` would never match a
+    /// lookup for `\workspace\dir`.
+    fn remove_dir_error_key(path: &Path) -> PathBuf {
+        normalize_path(path)
+            .components()
+            .skip_while(|component| matches!(component, Component::Prefix(_)))
+            .collect()
     }
 
     pub fn paths(&self, include_dot_git: bool) -> Vec {
@@ -2549,7 +2559,12 @@ impl FakeFs {
         self.simulate_random_delay().await;
 
         let path = normalize_path(path);
-        if let Some(message) = self.state.lock().remove_dir_errors.get(&path) {
+        if let Some(message) = self
+            .state
+            .lock()
+            .remove_dir_errors
+            .get(&Self::remove_dir_error_key(&path))
+        {
             anyhow::bail!("{message}");
         }
         let parent_path = path.parent().context("cannot remove the root")?;
@@ -3072,6 +3087,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Dir {
                     inode, mtime, len, ..
@@ -3083,6 +3099,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Symlink { .. } => unreachable!(),
             }))
diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs
index 4fc66380954..99664ae7480 100644
--- a/crates/fs/src/fs_watcher.rs
+++ b/crates/fs/src/fs_watcher.rs
@@ -56,13 +56,22 @@ impl FsWatcher {
             log::trace!("path to watch is already watched: {path:?}");
             return Ok(());
         }
-        if let Some(registration) = register_existing_path(
-            path,
+        match register_existing_path(
+            path.clone(),
             case_insensitive,
             self.tx.clone(),
             self.pending_path_events.clone(),
         )? {
-            self.registrations.lock().insert(key, registration);
+            Some(registration) => {
+                self.registrations.lock().insert(key, registration);
+            }
+            None => {
+                // Registration was skipped (e.g. the native watch-limit cooldown
+                // is active). Retry in the background rather than silently leaving
+                // the path unwatched forever.
+                log::warn!("watch registration for {path:?} was skipped; retrying in background");
+                self.add_pending_path(path);
+            }
         }
         Ok(())
     }
@@ -1245,6 +1254,56 @@ mod tests {
         assert_eq!(backend.unwatch_calls, &[parent.to_path_buf()]);
     }
 
+    #[gpui::test]
+    async fn pending_path_is_registered_once_created(cx: &mut gpui::TestAppContext) {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let path = temp_dir.path().join("file.txt");
+
+        let (tx, rx) = async_channel::unbounded();
+        let pending_path_events: Arc>> = Default::default();
+        let watcher = FsWatcher::new(cx.executor(), tx, pending_path_events.clone());
+
+        watcher
+            .add(&path)
+            .expect("add path that does not exist yet");
+        assert!(
+            watcher
+                .pending_registrations
+                .lock()
+                .contains_key(path.as_path())
+        );
+        assert!(watcher.registrations.lock().is_empty());
+
+        std::fs::write(&path, b"contents").expect("create path");
+
+        // poll_path_until_created stats the path on smol's blocking pool, which
+        // the deterministic executor cannot drive; park until the poll task
+        // signals the event channel.
+        cx.executor().allow_parking();
+        cx.executor().advance_clock(poll_interval());
+        rx.recv().await.expect("receive watcher event");
+
+        assert!(
+            !watcher
+                .pending_registrations
+                .lock()
+                .contains_key(path.as_path())
+        );
+        let case_insensitive = case_insensitive_path(&path);
+        let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive);
+        assert!(watcher.registrations.lock().contains_key(&key));
+
+        // poll_path_until_created also enqueues a Rescan for the same path, but
+        // enqueue_path_events -> util::extend_sorted dedups by path, so only Created survives.
+        assert_eq!(
+            pending_path_events.lock().clone(),
+            vec![PathEvent {
+                path: path.clone(),
+                kind: Some(PathEventKind::Created),
+            }]
+        );
+    }
+
     #[test]
     fn native_watch_limit_cools_down_subsequent_native_registrations() {
         let native_backend = Arc::new(Mutex::new(FakeWatchBackend {
diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs
index 50b62fa506b..326c741ad19 100644
--- a/crates/git/src/commit.rs
+++ b/crates/git/src/commit.rs
@@ -109,6 +109,7 @@ pub fn parse_git_diff_name_status(content: &str) -> impl Iterator StatusCode::Modified,
+                "T" => StatusCode::TypeChanged,
                 "A" => StatusCode::Added,
                 "D" => StatusCode::Deleted,
                 _ => continue,
@@ -127,6 +128,7 @@ mod tests {
     fn test_parse_git_diff_name_status() {
         let input = concat!(
             "M\x00Cargo.lock\x00",
+            "T\x00LICENSE-GPL\x00",
             "M\x00crates/project/Cargo.toml\x00",
             "M\x00crates/project/src/buffer_store.rs\x00",
             "D\x00crates/project/src/git.rs\x00",
@@ -142,6 +144,7 @@ mod tests {
             output,
             &[
                 ("Cargo.lock", StatusCode::Modified),
+                ("LICENSE-GPL", StatusCode::TypeChanged),
                 ("crates/project/Cargo.toml", StatusCode::Modified),
                 ("crates/project/src/buffer_store.rs", StatusCode::Modified),
                 ("crates/project/src/git.rs", StatusCode::Deleted),
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index fe9947f23b5..4bb16a39515 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore";
 pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
 pub const LFS_DIR: &str = "lfs";
 pub const OBJECTS_DIR: &str = "objects";
+pub const REFS_DIR: &str = "refs";
+pub const REFTABLE_DIR: &str = "reftable";
 pub const HOOKS_DIR: &str = "hooks";
 pub const LOGS_DIR: &str = "logs";
 pub const LOGS_REF_STASH: &str = "logs/refs/stash";
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index f09c13f54ab..7bea8283d70 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -18,6 +18,7 @@ use smallvec::SmallVec;
 use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 use text::LineEnding;
 
+use std::borrow::Cow;
 use std::collections::HashSet;
 use std::ffi::{OsStr, OsString};
 use std::sync::atomic::AtomicBool;
@@ -757,20 +758,22 @@ pub enum LogSource {
 }
 
 impl LogSource {
-    fn get_args(&self) -> Result> {
+    fn get_args(&self) -> Vec> {
         match self {
-            LogSource::All => Ok(vec![
-                "--ignore-missing", // needed in case of unborn HEAD
-                "--branches",
-                "--remotes",
-                "--tags",
-                "HEAD",
-            ]),
-            LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
-            LogSource::Sha(oid) => Ok(vec![
-                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
-            ]),
-            LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
+            LogSource::All => vec![
+                Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
+                Cow::Borrowed("--branches"),
+                Cow::Borrowed("--remotes"),
+                Cow::Borrowed("--tags"),
+                Cow::Borrowed("HEAD"),
+            ],
+            LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
+            LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
+            LogSource::Path(path) => vec![
+                Cow::Borrowed("--follow"),
+                Cow::Borrowed("--"),
+                Cow::Borrowed(path.as_unix_str()),
+            ],
         }
     }
 }
@@ -801,12 +804,18 @@ pub trait GitRepository: Send + Sync {
     /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option>;
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
+        let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]);
+        async move { future.await.ok()?.pop()? }.boxed()
+    }
 
     /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>;
+    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
+        let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]);
+        async move { future.await.ok()?.pop()? }.boxed()
+    }
     fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>;
 
     fn set_index_text(
@@ -830,6 +839,8 @@ pub trait GitRepository: Send + Sync {
     /// Resolve a list of refs to SHAs.
     fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>;
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>>;
+
     fn head_sha(&self) -> BoxFuture<'_, Option> {
         async move {
             self.revparse_batch(vec!["HEAD".into()])
@@ -1445,7 +1456,7 @@ impl GitRepository for RealGitRepository {
                 };
 
                 match status_code {
-                    StatusCode::Modified => {
+                    StatusCode::Modified | StatusCode::TypeChanged => {
                         stdin.write_all(commit.as_bytes()).await?;
                         stdin.write_all(b":").await?;
                         stdin.write_all(path.as_bytes()).await?;
@@ -1491,7 +1502,7 @@ impl GitRepository for RealGitRepository {
                 };
 
                 match status_code {
-                    StatusCode::Modified => {
+                    StatusCode::Modified | StatusCode::TypeChanged => {
                         info_line.clear();
                         stdout.read_line(&mut info_line).await?;
                         let len = info_line.trim_end().parse().with_context(|| {
@@ -1585,47 +1596,6 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let git_binary = self.git_binary();
-        let path_str = format!(":{}", path.as_unix_str());
-        self.executor
-            .spawn(async move {
-                let git = git_binary;
-                let output = git
-                    .build_command(&["show", &path_str])
-                    .stdout(Stdio::piped())
-                    .stderr(Stdio::piped())
-                    .output()
-                    .await
-                    .log_err()?;
-                if !output.status.success() {
-                    return None;
-                }
-                String::from_utf8(output.stdout).ok()
-            })
-            .boxed()
-    }
-
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> {
-        let git = self.git_binary();
-        let path_str = format!("HEAD:{}", path.as_unix_str());
-        self.executor
-            .spawn(async move {
-                let output = git
-                    .build_command(&["show", &path_str])
-                    .stdout(Stdio::piped())
-                    .stderr(Stdio::piped())
-                    .output()
-                    .await
-                    .log_err()?;
-                if !output.status.success() {
-                    return None;
-                }
-                String::from_utf8(output.stdout).ok()
-            })
-            .boxed()
-    }
-
     fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> {
         let git_binary = self.git_binary();
         let oid_str = oid.to_string();
@@ -1801,6 +1771,68 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> {
+        let git = self.git_binary();
+        self.executor
+            .spawn(async move {
+                if revisions.is_empty() {
+                    return Ok(Vec::new());
+                }
+
+                let mut process = git
+                    .build_command(&["cat-file", "--batch"])
+                    .stdin(Stdio::piped())
+                    .stdout(Stdio::piped())
+                    .stderr(Stdio::piped())
+                    .kill_on_drop(true)
+                    .spawn()?;
+
+                let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
+                let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
+                let mut newline = [0u8; 1];
+
+                let mut header_bytes = Vec::new();
+                let mut results = Vec::with_capacity(revisions.len());
+                for rev in &revisions {
+                    stdin.write_all(rev.as_bytes()).await?;
+                    stdin.write_all(b"\n").await?;
+                    stdin.flush().await?;
+
+                    header_bytes.clear();
+                    stdout.read_until(b'\n', &mut header_bytes).await?;
+                    let header_line = String::from_utf8_lossy(&header_bytes);
+
+                    let parts: Vec<&str> = header_line.trim().split(' ').collect();
+                    match parts[..] {
+                        [.., "missing"] => {
+                            results.push(None);
+                        }
+                        [_, object_type, size_str] => {
+                            let size: usize = size_str
+                                .parse()
+                                .with_context(|| format!("invalid object size: {size_str}"))?;
+
+                            let mut content = vec![0u8; size];
+                            stdout.read_exact(&mut content).await?;
+                            stdout.read_exact(&mut newline).await?;
+
+                            if object_type == "blob" {
+                                results.push(String::from_utf8(content).ok());
+                            } else {
+                                results.push(None);
+                            }
+                        }
+                        _ => bail!("invalid cat-file header: {header_line}"),
+                    }
+                }
+
+                drop(stdin);
+                process.output().await?;
+                Ok(results)
+            })
+            .boxed()
+    }
+
     fn merge_message(&self) -> BoxFuture<'_, Option> {
         let path = self.path().join("MERGE_MSG");
         self.executor
@@ -3082,8 +3114,9 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
-            git_log_command.extend(log_source.get_args()?);
+            git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&git_log_command);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::piped());
@@ -3153,6 +3186,7 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
             let hash_query = commit_hash_search_query(search_args.query.as_str())
                 .map(|query| query.to_ascii_lowercase());
@@ -3168,7 +3202,7 @@ impl GitRepository for RealGitRepository {
                 args.push(search_args.query.as_str());
             }
 
-            args.extend(log_source.get_args()?);
+            args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&args);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::null());
@@ -3913,7 +3947,7 @@ mod tests {
 
     #[allow(clippy::disallowed_methods)]
     #[track_caller]
-    fn git_command(working_directory: &Path, arguments: I)
+    fn git_command_output(working_directory: &Path, arguments: I) -> String
     where
         I: IntoIterator,
         S: AsRef,
@@ -3934,6 +3968,19 @@ mod tests {
             "git command failed: {}",
             String::from_utf8_lossy(&output.stderr)
         );
+        String::from_utf8(output.stdout)
+            .expect("git command output was not valid UTF-8")
+            .trim()
+            .to_string()
+    }
+
+    #[track_caller]
+    fn git_command(working_directory: &Path, arguments: I)
+    where
+        I: IntoIterator,
+        S: AsRef,
+    {
+        git_command_output(working_directory, arguments);
     }
 
     fn git_init_repo(path: &Path) {
@@ -4031,6 +4078,61 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("file.txt"), "regular contents\n")
+            .expect("failed to write regular file");
+        git_command(repo_dir.path(), ["add", "file.txt"]);
+        git_command(repo_dir.path(), ["commit", "-m", "initial"]);
+
+        let repository = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .expect("failed to open repository");
+        fs::write(repo_dir.path().join("file.txt"), "target")
+            .expect("failed to write symlink target");
+
+        let symlink_blob = repository
+            .git_binary()
+            .run(&["hash-object", "-w", "file.txt"])
+            .await
+            .expect("failed to write symlink blob");
+        git_command(
+            repo_dir.path(),
+            [
+                OsString::from("update-index"),
+                OsString::from("--cacheinfo"),
+                OsString::from("120000"),
+                OsString::from(symlink_blob),
+                OsString::from("file.txt"),
+            ],
+        );
+        git_command(repo_dir.path(), ["commit", "-m", "type change"]);
+
+        let commit_diff = repository
+            .load_commit("HEAD".to_string(), cx.to_async())
+            .await
+            .expect("failed to load type-changed commit");
+        assert_eq!(commit_diff.files.len(), 1);
+
+        let file = commit_diff
+            .files
+            .first()
+            .expect("type-changed file should be present");
+        assert_eq!(file.path.as_unix_str(), "file.txt");
+        assert_eq!(file.old_text.as_deref(), Some("regular contents\n"));
+        assert_eq!(file.new_text.as_deref(), Some("target"));
+        assert_eq!(file.status(), CommitFileStatus::Modified);
+    }
+
     #[gpui::test]
     async fn test_check_access(cx: &mut TestAppContext) {
         disable_git_global_config();
@@ -4397,6 +4499,42 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
+        disable_git_global_config();
+
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("file"), "initial").unwrap();
+        git_command(repo_dir.path(), ["add", "file"]);
+        git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
+
+        let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
+            .parse()
+            .unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        let (request_tx, request_rx) = async_channel::unbounded();
+
+        repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
+            .await
+            .unwrap();
+
+        let graph_data = request_rx.recv().await.unwrap();
+        assert_eq!(graph_data.len(), 1);
+        assert_eq!(graph_data[0].sha, commit_sha);
+    }
+
     #[gpui::test]
     async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
         cx.executor().allow_parking();
@@ -4654,6 +4792,103 @@ mod tests {
         // );
     }
 
+    #[gpui::test]
+    async fn test_load_revisions(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git_init_repo(repo_dir.path());
+
+        let file1_path = repo_dir.path().join("file1");
+        let file2_path = repo_dir.path().join("file2");
+        let space_file_path = repo_dir.path().join("file with spaces");
+
+        smol::fs::write(&file1_path, "file1 committed contents")
+            .await
+            .unwrap();
+        smol::fs::write(&file2_path, "file2 committed contents")
+            .await
+            .unwrap();
+        smol::fs::write(&space_file_path, "space file committed contents")
+            .await
+            .unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Stage files and commit
+        repo.stage_paths(
+            vec![
+                repo_path("file1"),
+                repo_path("file2"),
+                repo_path("file with spaces"),
+            ],
+            Arc::new(HashMap::default()),
+        )
+        .await
+        .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(test_commit_envs()),
+        )
+        .await
+        .unwrap();
+
+        // Now modify files in index but not yet committed
+        smol::fs::write(&file1_path, "file1 index contents")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+
+        // Write working tree contents (not indexed, not committed)
+        smol::fs::write(&file1_path, "file1 worktree contents")
+            .await
+            .unwrap();
+
+        // Now test load_revisions
+        let results = repo
+            .load_revisions(
+                [
+                    "HEAD:file1",
+                    ":file1",
+                    "HEAD:file2",
+                    ":file2",
+                    "HEAD:nonexistent",
+                    "HEAD:file with spaces",
+                    "HEAD:nonexistent file with spaces",
+                ]
+                .into_iter()
+                .map(String::from)
+                .collect(),
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            results,
+            vec![
+                Some("file1 committed contents".into()),
+                Some("file1 index contents".into()),
+                Some("file2 committed contents".into()),
+                Some("file2 committed contents".into()), // untouched in index, should match HEAD
+                None,
+                Some("space file committed contents".into()),
+                None,
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
         disable_git_global_config();
diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml
index 4b6fc85041b..f46146ef8c3 100644
--- a/crates/git_ui/Cargo.toml
+++ b/crates/git_ui/Cargo.toml
@@ -23,14 +23,15 @@ askpass.workspace = true
 async-channel.workspace = true
 buffer_diff.workspace = true
 call = { workspace = true, optional = true }
+client.workspace = true
 collections.workspace = true
 component.workspace = true
 db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 fs.workspace = true
-futures.workspace = true
 futures-lite.workspace = true
+futures.workspace = true
 fuzzy.workspace = true
 fuzzy_nucleo.workspace = true
 git.workspace = true
@@ -50,8 +51,8 @@ prompt_store.workspace = true
 proto.workspace = true
 rand.workspace = true
 release_channel.workspace = true
-remote_connection.workspace = true
 remote.workspace = true
+remote_connection.workspace = true
 schemars.workspace = true
 search.workspace = true
 serde.workspace = true
@@ -67,6 +68,7 @@ theme.workspace = true
 theme_settings.workspace = true
 time.workspace = true
 time_format.workspace = true
+tracing.workspace = true
 ui.workspace = true
 ui_input.workspace = true
 util.workspace = true
@@ -75,7 +77,6 @@ workspace.workspace = true
 zed_actions.workspace = true
 zeroize.workspace = true
 ztracing.workspace = true
-tracing.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true
@@ -91,12 +92,12 @@ indoc.workspace = true
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
+remote_connection = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 task.workspace = true
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
-remote_connection = { workspace = true, features = ["test-support"] }
 
 [package.metadata.cargo-machete]
 ignored = ["tracing"]
diff --git a/crates/git_ui/src/branch_diff.rs b/crates/git_ui/src/branch_diff.rs
new file mode 100644
index 00000000000..068d8e9af19
--- /dev/null
+++ b/crates/git_ui/src/branch_diff.rs
@@ -0,0 +1,1199 @@
+use crate::{
+    branch_picker,
+    diff_multibuffer::DiffMultibuffer,
+    project_diff::{
+        self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button,
+    },
+};
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result, anyhow};
+use editor::{
+    Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor,
+    actions::SendReviewToAgent,
+};
+use git::{repository::DiffType, status::FileStatus};
+use gpui::{
+    Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    SharedString, Subscription, Task, WeakEntity,
+};
+use language::{BufferId, Capability};
+use project::{
+    Project, ProjectPath,
+    git_store::{
+        Repository,
+        diff_buffer_list::{self, DiffBase},
+    },
+};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    sync::Arc,
+};
+use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*};
+use workspace::{
+    ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
+    ToolbarItemView, Workspace,
+    item::{Item, ItemEvent, SaveOptions, TabContentParams},
+    notifications::NotifyTaskExt,
+    searchable::SearchableItemHandle,
+};
+use zed_actions::agent::ReviewBranchDiff;
+
+/// The workspace item for a branch (merge-base) diff: "Changes since {branch}".
+/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates
+/// the [`Item`] surface to it. The merge base can be changed in place via the
+/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring
+/// the editor (the merge styling is identical for every base ref).
+pub struct BranchDiff {
+    diff: Entity,
+    project: Entity,
+    workspace: WeakEntity,
+    _diff_event_subscription: Subscription,
+}
+
+struct BranchDiffAddon {
+    branch_diff: Entity,
+}
+
+impl Addon for BranchDiffAddon {
+    fn to_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option {
+        self.branch_diff
+            .read(cx)
+            .status_for_buffer_id(buffer_id, cx)
+    }
+}
+
+impl BranchDiff {
+    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) {
+        workspace.register_action(Self::deploy_branch_diff);
+        workspace.register_action(Self::compare_with_branch);
+        workspace::register_serializable_item::(cx);
+    }
+
+    fn deploy_branch_diff(
+        workspace: &mut Workspace,
+        _: &DeployBranchDiff,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        telemetry::event!("Git Branch Diff Opened");
+        let project = workspace.project().clone();
+        let Some(intended_repo) = project.read(cx).active_repository(cx) else {
+            let workspace = cx.entity().downgrade();
+            window
+                .spawn(cx, async |_cx| {
+                    let result: Result<()> = Err(anyhow!("No active repository"));
+                    result
+                })
+                .detach_and_notify_err(workspace, window, cx);
+            return;
+        };
+
+        let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
+        let workspace = cx.entity();
+        let workspace_weak = workspace.downgrade();
+        window
+            .spawn(cx, async move |cx| {
+                let base_ref = default_branch
+                    .await??
+                    .context("Could not determine default branch")?;
+
+                workspace.update_in(cx, |workspace, window, cx| {
+                    Self::deploy_branch_diff_with_base_ref(
+                        workspace,
+                        project,
+                        intended_repo,
+                        base_ref,
+                        window,
+                        cx,
+                    );
+                })?;
+
+                anyhow::Ok(())
+            })
+            .detach_and_notify_err(workspace_weak, window, cx);
+    }
+
+    fn compare_with_branch(
+        workspace: &mut Workspace,
+        _: &CompareWithBranch,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let project = workspace.project().clone();
+        let Some(repository) = project.read(cx).active_repository(cx) else {
+            let workspace = cx.entity().downgrade();
+            window
+                .spawn(cx, async |_cx| {
+                    let result: Result<()> = Err(anyhow!("No active repository"));
+                    result
+                })
+                .detach_and_notify_err(workspace, window, cx);
+            return;
+        };
+        let selected_branch = workspace.active_item_as::(cx).and_then(|item| {
+            match item.read(cx).diff_base(cx) {
+                DiffBase::Merge { base_ref } => Some(base_ref.clone()),
+                DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
+            }
+        });
+        let workspace_handle = workspace.weak_handle();
+        let on_select = Arc::new({
+            let repository = repository.clone();
+            let workspace = workspace_handle.clone();
+            move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
+                let base_ref: SharedString = branch.name().to_owned().into();
+                workspace
+                    .update(cx, |workspace, cx| {
+                        Self::deploy_branch_diff_with_base_ref(
+                            workspace,
+                            project.clone(),
+                            repository.clone(),
+                            base_ref,
+                            window,
+                            cx,
+                        );
+                    })
+                    .ok();
+            }
+        });
+
+        workspace.toggle_modal(window, cx, |window, cx| {
+            branch_picker::select_modal(
+                workspace_handle,
+                Some(repository),
+                selected_branch,
+                on_select,
+                window,
+                cx,
+            )
+        });
+    }
+
+    fn deploy_branch_diff_with_base_ref(
+        workspace: &mut Workspace,
+        project: Entity,
+        intended_repo: Entity,
+        base_ref: SharedString,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let existing = workspace.items_of_type::(cx).find(|item| {
+            let item = item.read(cx);
+            matches!(
+                item.diff_base(cx),
+                DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
+            )
+        });
+        if let Some(existing) = existing {
+            workspace.activate_item(&existing, true, true, window, cx);
+
+            let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| {
+                current.read(cx).id != intended_repo.read(cx).id
+            });
+
+            if needs_switch {
+                existing.update(cx, |branch_diff, cx| {
+                    branch_diff.set_repo(Some(intended_repo), cx);
+                });
+            }
+
+            return;
+        }
+
+        let workspace = cx.entity();
+        let workspace_weak = workspace.downgrade();
+        window
+            .spawn(cx, async move |cx| {
+                let this = cx
+                    .update(|window, cx| {
+                        Self::new_with_branch_base(
+                            project,
+                            workspace.clone(),
+                            base_ref,
+                            intended_repo,
+                            window,
+                            cx,
+                        )
+                    })?
+                    .await?;
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
+                    })
+                    .ok();
+                anyhow::Ok(())
+            })
+            .detach_and_notify_err(workspace_weak, window, cx);
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new_with_default_branch(
+        project: Entity,
+        workspace: Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
+            return Task::ready(Err(anyhow!("No active repository")));
+        };
+        let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
+        window.spawn(cx, async move |cx| {
+            let base_ref = main_branch
+                .await??
+                .context("Could not determine default branch")?;
+            cx.update(|window, cx| {
+                cx.new(|cx| {
+                    Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
+                })
+            })
+        })
+    }
+
+    pub(crate) fn new_with_branch_base(
+        project: Entity,
+        workspace: Entity,
+        base_ref: SharedString,
+        repo: Entity,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        window.spawn(cx, async move |cx| {
+            cx.update(|window, cx| {
+                cx.new(|cx| {
+                    Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
+                })
+            })
+        })
+    }
+
+    pub(crate) fn new_with_base_ref(
+        project: Entity,
+        workspace: Entity,
+        base_ref: SharedString,
+        repo: Option>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        let branch_diff = cx.new(|cx| {
+            let mut branch_diff = diff_buffer_list::DiffBufferList::new(
+                DiffBase::Merge { base_ref },
+                project.clone(),
+                window,
+                cx,
+            );
+            if repo.is_some() {
+                branch_diff.set_repo(repo, cx);
+            }
+            branch_diff
+        });
+        let branch_diff_for_addon = branch_diff.clone();
+        let diff = cx.new(|cx| {
+            DiffMultibuffer::new(
+                branch_diff,
+                Capability::ReadWrite,
+                "No changes",
+                move |editor, cx| {
+                    editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx);
+                    editor.rhs_editor().update(cx, move |rhs_editor, _cx| {
+                        rhs_editor.set_read_only(false);
+                        rhs_editor.register_addon(BranchDiffAddon {
+                            branch_diff: branch_diff_for_addon,
+                        });
+                    });
+                },
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            )
+        });
+        Self::from_diff(diff, project, workspace, cx)
+    }
+
+    fn from_diff(
+        diff: Entity,
+        project: Entity,
+        workspace: Entity,
+        cx: &mut Context,
+    ) -> Self {
+        let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
+            cx.emit(event.clone())
+        });
+        Self {
+            diff,
+            project,
+            workspace: workspace.downgrade(),
+            _diff_event_subscription: diff_event_subscription,
+        }
+    }
+
+    pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
+        self.diff.read(cx).diff_base(cx)
+    }
+
+    pub(crate) fn repo(&self, cx: &App) -> Option> {
+        self.diff.read(cx).repo(cx)
+    }
+
+    pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) {
+        self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx));
+    }
+
+    fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) {
+        self.diff.update(cx, |diff, cx| {
+            diff.branch_diff().update(cx, |branch_diff, cx| {
+                branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx);
+            });
+        });
+    }
+
+    fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) {
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return;
+        };
+        let Some(repo) = self.repo(cx) else {
+            return;
+        };
+
+        let diff_receiver = repo.update(cx, |repo, cx| {
+            repo.diff(
+                DiffType::MergeBase {
+                    base_ref: base_ref.clone(),
+                },
+                cx,
+            )
+        });
+
+        let workspace = self.workspace.clone();
+        window
+            .spawn(cx, {
+                let workspace = workspace.clone();
+                async move |cx| {
+                    let diff_text = diff_receiver.await??;
+
+                    if let Some(workspace) = workspace.upgrade() {
+                        workspace.update_in(cx, |_workspace, window, cx| {
+                            window.dispatch_action(
+                                ReviewBranchDiff {
+                                    diff_text: diff_text.into(),
+                                    base_ref,
+                                }
+                                .boxed_clone(),
+                                cx,
+                            );
+                        })?;
+                    }
+
+                    anyhow::Ok(())
+                }
+            })
+            .detach_and_notify_err(workspace, window, cx);
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn editor(&self, cx: &App) -> Entity {
+        self.diff.read(cx).editor().clone()
+    }
+}
+
+impl EventEmitter for BranchDiff {}
+
+impl Focusable for BranchDiff {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.diff.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for BranchDiff {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
+        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
+    }
+
+    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Arc,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> bool {
+        self.diff
+            .update(cx, |diff, cx| diff.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, cx: &App) -> Option {
+        Some(self.tab_content_text(0, cx))
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(0, cx))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
+        match self.diff_base(cx) {
+            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
+            DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(),
+        }
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Branch Diff Opened")
+    }
+
+    fn as_searchable(&self, _: &Entity, cx: &App) -> Option> {
+        Some(Box::new(self.diff.read(cx).editor().clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.diff.read(cx).for_each_project_item(cx, f);
+    }
+
+    fn active_project_path(&self, cx: &App) -> Option {
+        self.diff.read(cx).active_project_path(cx)
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff
+            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
+    }
+
+    fn can_split(&self) -> bool {
+        true
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task>>
+    where
+        Self: Sized,
+    {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(None);
+        };
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return Task::ready(None);
+        };
+        let repo = self.repo(cx);
+        let project = self.project.clone();
+        Task::ready(Some(cx.new(|cx| {
+            Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx)
+        })))
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.diff.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.diff.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _cx: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.save(options, project, window, cx))
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity,
+        _: ProjectPath,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Task> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.reload(project, window, cx))
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity,
+        cx: &'a App,
+    ) -> Option {
+        if type_id == TypeId::of::() {
+            Some(self_handle.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(
+                self.diff
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .rhs_editor()
+                    .clone()
+                    .into(),
+            )
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).editor().clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).branch_diff().clone().into())
+        } else {
+            None
+        }
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl Render for BranchDiff {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        div()
+            .size_full()
+            .on_action(cx.listener(Self::review_diff))
+            .child(self.diff.clone())
+    }
+}
+
+impl SerializableItem for BranchDiff {
+    fn serialized_item_kind() -> &'static str {
+        "BranchDiff"
+    }
+
+    fn cleanup(
+        _: workspace::WorkspaceId,
+        _: Vec,
+        _: &mut Window,
+        _: &mut App,
+    ) -> Task> {
+        Task::ready(Ok(()))
+    }
+
+    fn deserialize(
+        project: Entity,
+        workspace: WeakEntity,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        let db = project_diff::persistence::ProjectDiffDb::global(cx);
+        window.spawn(cx, async move |cx| {
+            let diff_base = db.get_project_diff_base(item_id, workspace_id)?;
+            let DiffBase::Merge { base_ref } = diff_base else {
+                anyhow::bail!("expected a merge base for a branch diff");
+            };
+            let workspace = workspace.upgrade().context("workspace gone")?;
+            cx.update(|window, cx| {
+                cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx))
+            })
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        workspace: &mut Workspace,
+        item_id: workspace::ItemId,
+        _closing: bool,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) -> Option>> {
+        let workspace_id = workspace.database_id()?;
+        let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
+            return None;
+        };
+        let diff_base = DiffBase::Merge { base_ref };
+        let db = project_diff::persistence::ProjectDiffDb::global(cx);
+        Some(cx.background_spawn(async move {
+            db.save_project_diff_base(item_id, workspace_id, diff_base)
+                .await
+        }))
+    }
+
+    fn should_serialize(&self, _: &Self::Event) -> bool {
+        false
+    }
+}
+
+pub struct BranchDiffToolbar {
+    branch_diff: Option>,
+}
+
+impl BranchDiffToolbar {
+    pub fn new(_cx: &mut Context) -> Self {
+        Self { branch_diff: None }
+    }
+
+    fn branch_diff(&self, _: &App) -> Option> {
+        self.branch_diff.as_ref()?.upgrade()
+    }
+
+    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) {
+        if let Some(branch_diff) = self.branch_diff(cx) {
+            branch_diff.focus_handle(cx).focus(window, cx);
+        }
+        let action = action.boxed_clone();
+        cx.defer(move |cx| {
+            cx.dispatch_action(action.as_ref());
+        })
+    }
+}
+
+impl EventEmitter for BranchDiffToolbar {}
+
+impl ToolbarItemView for BranchDiffToolbar {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _: &mut Window,
+        cx: &mut Context,
+    ) -> ToolbarItemLocation {
+        self.branch_diff = active_pane_item
+            .and_then(|item| item.act_as::(cx))
+            .map(|entity| entity.downgrade());
+        if self.branch_diff.is_some() {
+            ToolbarItemLocation::PrimaryRight
+        } else {
+            ToolbarItemLocation::Hidden
+        }
+    }
+
+    fn pane_focus_update(
+        &mut self,
+        _pane_focused: bool,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+}
+
+impl Render for BranchDiffToolbar {
+    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let Some(branch_diff) = self.branch_diff(cx) else {
+            return div();
+        };
+        let focus_handle = branch_diff.focus_handle(cx);
+        let review_count = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .total_review_comment_count();
+        let (additions, deletions) = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .calculate_changed_lines(cx);
+        let diff_base = branch_diff.read(cx).diff_base(cx).clone();
+        let DiffBase::Merge { base_ref } = diff_base else {
+            return div();
+        };
+        let selected_base_ref = base_ref.clone();
+        let base_ref_label = format!("Base: {base_ref}");
+        let repository = branch_diff.read(cx).repo(cx);
+        let workspace = branch_diff.read(cx).workspace.clone();
+        let view_for_picker = branch_diff.downgrade();
+
+        let is_multibuffer_empty = branch_diff
+            .read(cx)
+            .diff
+            .read(cx)
+            .multibuffer()
+            .read(cx)
+            .is_empty();
+        let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
+
+        let show_review_button = !is_multibuffer_empty && is_ai_enabled;
+
+        h_flex()
+            .my_neg_1()
+            .py_1()
+            .gap_1p5()
+            .flex_wrap()
+            .justify_between()
+            .when(!is_multibuffer_empty, |this| {
+                this.child(DiffStat::new(
+                    "branch-diff-stat",
+                    additions as usize,
+                    deletions as usize,
+                ))
+            })
+            .child(Divider::vertical().ml_1())
+            .child(
+                PopoverMenu::new("branch-diff-base-branch-picker")
+                    .menu(move |window, cx| {
+                        let view_for_picker = view_for_picker.clone();
+                        let on_select = Arc::new(
+                            move |branch: git::repository::Branch,
+                                  _window: &mut Window,
+                                  cx: &mut App| {
+                                let base_ref: SharedString = branch.name().to_owned().into();
+                                view_for_picker
+                                    .update(cx, |branch_diff, cx| {
+                                        branch_diff.set_merge_base(base_ref, cx);
+                                        cx.notify();
+                                    })
+                                    .ok();
+                            },
+                        );
+
+                        Some(branch_picker::select_popover(
+                            workspace.clone(),
+                            repository.clone(),
+                            Some(selected_base_ref.clone()),
+                            on_select,
+                            window,
+                            cx,
+                        ))
+                    })
+                    .trigger_with_tooltip(
+                        Button::new("branch-diff-base-branch", base_ref_label).end_icon(
+                            Icon::new(IconName::ChevronDown)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        ),
+                        Tooltip::text("Select Base Branch"),
+                    ),
+            )
+            .when(show_review_button, |this| {
+                let focus_handle = focus_handle.clone();
+                this.child(Divider::vertical()).child(
+                    Button::new("review-diff", "Review Diff")
+                        .start_icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .tooltip(move |_, cx| {
+                            Tooltip::with_meta_in(
+                                "Review Diff",
+                                Some(&ReviewDiff),
+                                "Send this diff for your last agent to review.",
+                                &focus_handle,
+                                cx,
+                            )
+                        })
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.dispatch_action(&ReviewDiff, window, cx);
+                        })),
+                )
+            })
+            .when(review_count > 0, |this| {
+                this.child(Divider::vertical()).child(
+                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
+                        cx.listener(|this, _, window, cx| {
+                            this.dispatch_action(&SendReviewToAgent, window, cx)
+                        }),
+                    ),
+                )
+            })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use anyhow::anyhow;
+    use collections::HashMap;
+    use editor::test::editor_test_context::assert_state_with_diff;
+    use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
+    use gpui::TestAppContext;
+    use project::FakeFs;
+    use serde_json::json;
+    use settings::{DiffViewStyle, SettingsStore};
+    use std::path::Path;
+    use std::sync::Arc;
+    use unindent::Unindent as _;
+    use util::{
+        path,
+        rel_path::{RelPath, rel_path},
+    };
+    use workspace::MultiWorkspace;
+
+    use super::*;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
+                });
+            });
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+            editor::init(cx);
+            crate::init(cx);
+        });
+    }
+
+    #[gpui::test(iterations = 50)]
+    async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.diff_view_style = Some(DiffViewStyle::Split);
+                });
+            });
+        });
+
+        let build_conflict_text: fn(usize) -> String = |tag: usize| {
+            let mut lines = (0..80)
+                .map(|line_index| format!("line {line_index}"))
+                .collect::>();
+            for offset in [5usize, 20, 37, 61] {
+                lines[offset] = format!("base-{tag}-line-{offset}");
+            }
+            format!("{}\n", lines.join("\n"))
+        };
+        let initial_conflict_text = build_conflict_text(0);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "helper.txt": "same\n",
+                "conflict.txt": initial_conflict_text,
+            }),
+        )
+        .await;
+        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+            state
+                .refs
+                .insert("MERGE_HEAD".into(), "conflict-head".into());
+        })
+        .unwrap();
+        fs.set_status_for_repo(
+            path!("/project/.git").as_ref(),
+            &[(
+                "conflict.txt",
+                FileStatus::Unmerged(UnmergedStatus {
+                    first_head: UnmergedStatusCode::Updated,
+                    second_head: UnmergedStatusCode::Updated,
+                }),
+            )],
+        );
+        fs.set_merge_base_content_for_repo(
+            path!("/project/.git").as_ref(),
+            &[
+                ("conflict.txt", build_conflict_text(1)),
+                ("helper.txt", "same\n".to_string()),
+            ],
+        );
+
+        let project = Project::test(fs.clone(), [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 _branch_diff = cx
+            .update(|window, cx| {
+                BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/conflict.txt"), cx)
+            })
+            .await
+            .unwrap();
+        buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
+        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
+        cx.run_until_parked();
+
+        cx.update(|window, cx| {
+            let fs = fs.clone();
+            window
+                .spawn(cx, async move |cx| {
+                    cx.background_executor().simulate_random_delay().await;
+                    fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+                        state.refs.insert("HEAD".into(), "head-1".into());
+                        state.refs.remove("MERGE_HEAD");
+                    })
+                    .unwrap();
+                    fs.set_status_for_repo(
+                        path!("/project/.git").as_ref(),
+                        &[
+                            (
+                                "conflict.txt",
+                                FileStatus::Tracked(TrackedStatus {
+                                    index_status: git::status::StatusCode::Modified,
+                                    worktree_status: git::status::StatusCode::Modified,
+                                }),
+                            ),
+                            (
+                                "helper.txt",
+                                FileStatus::Tracked(TrackedStatus {
+                                    index_status: git::status::StatusCode::Modified,
+                                    worktree_status: git::status::StatusCode::Modified,
+                                }),
+                            ),
+                        ],
+                    );
+                    // FakeFs assigns deterministic OIDs by entry position; flipping order churns
+                    // conflict diff identity without reaching into view internals.
+                    fs.set_merge_base_content_for_repo(
+                        path!("/project/.git").as_ref(),
+                        &[
+                            ("helper.txt", "helper-base\n".to_string()),
+                            ("conflict.txt", build_conflict_text(2)),
+                        ],
+                    );
+                })
+                .detach();
+        });
+
+        cx.update(|window, cx| {
+            let buffer = buffer.clone();
+            window
+                .spawn(cx, async move |cx| {
+                    cx.background_executor().simulate_random_delay().await;
+                    for edit_index in 0..10 {
+                        if edit_index > 0 {
+                            cx.background_executor().simulate_random_delay().await;
+                        }
+                        buffer.update(cx, |buffer, cx| {
+                            let len = buffer.len();
+                            if edit_index % 2 == 0 {
+                                buffer.edit(
+                                    [(0..0, format!("status-burst-head-{edit_index}\n"))],
+                                    None,
+                                    cx,
+                                );
+                            } else {
+                                buffer.edit(
+                                    [(len..len, format!("status-burst-tail-{edit_index}\n"))],
+                                    None,
+                                    cx,
+                                );
+                            }
+                        });
+                    }
+                })
+                .detach();
+        });
+
+        cx.run_until_parked();
+    }
+
+    #[gpui::test]
+    async fn test_branch_diff(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": "C",
+                "b.txt": "new",
+                "c.txt": "in-merge-base-and-work-tree",
+                "d.txt": "created-in-head",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [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 diff = cx
+            .update(|window, cx| {
+                BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
+            "sha",
+        );
+        fs.set_merge_base_content_for_repo(
+            Path::new(path!("/project/.git")),
+            &[
+                ("a.txt", "A".into()),
+                ("c.txt", "in-merge-base-and-work-tree".into()),
+            ],
+        );
+        cx.run_until_parked();
+
+        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
+
+        assert_state_with_diff(
+            &editor,
+            cx,
+            &"
+                - A
+                + ˇC
+                + new
+                + created-in-head"
+                .unindent(),
+        );
+
+        let statuses: HashMap, Option> =
+            editor.update(cx, |editor, cx| {
+                editor
+                    .buffer()
+                    .read(cx)
+                    .all_buffers()
+                    .iter()
+                    .map(|buffer| {
+                        (
+                            buffer.read(cx).file().unwrap().path().clone(),
+                            editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
+                        )
+                    })
+                    .collect()
+            });
+
+        assert_eq!(
+            statuses,
+            HashMap::from_iter([
+                (
+                    rel_path("a.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Modified,
+                        worktree_status: git::status::StatusCode::Modified
+                    }))
+                ),
+                (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
+                (
+                    rel_path("d.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Added,
+                        worktree_status: git::status::StatusCode::Added
+                    }))
+                )
+            ])
+        );
+    }
+
+    #[gpui::test]
+    async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": "changed",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [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 target_branch_diff = cx
+            .update(|window, cx| {
+                let Some(repository) = project.read(cx).active_repository(cx) else {
+                    return Task::ready(Err(anyhow!("No active repository")));
+                };
+                BranchDiff::new_with_branch_base(
+                    project.clone(),
+                    workspace.clone(),
+                    "topic".into(),
+                    repository,
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(target_branch_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(DeployBranchDiff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
+            let active_item = workspace.active_item_as::(cx).unwrap();
+            let active_base_ref = match active_item.read(cx).diff_base(cx) {
+                DiffBase::Merge { base_ref } => base_ref.to_string(),
+                DiffBase::Head | DiffBase::Index | DiffBase::Staged => {
+                    panic!("expected active item to be a branch diff")
+                }
+            };
+            let base_refs = workspace
+                .items_of_type::(cx)
+                .filter_map(|item| match item.read(cx).diff_base(cx) {
+                    DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
+                    DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
+                })
+                .collect::>();
+            (active_base_ref, base_refs)
+        });
+        base_refs.sort();
+
+        assert_eq!(active_base_ref, "origin/main");
+        assert_eq!(base_refs, vec!["origin/main", "topic"]);
+    }
+}
diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs
index 50090a559ca..06c554e7b6e 100644
--- a/crates/git_ui/src/commit_modal.rs
+++ b/crates/git_ui/src/commit_modal.rs
@@ -8,7 +8,8 @@ use git::{Amend, Commit, GenerateCommitMessage, Signoff};
 use project::DisableAiSettings;
 use settings::Settings;
 use ui::{
-    ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
+    ButtonLike, ContextMenu, ElevationIndex, KeybindingHint, PopoverMenu, PopoverMenuHandle,
+    SplitButton, Tooltip, prelude::*,
 };
 use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
 
@@ -268,17 +269,14 @@ impl CommitModal {
         id: impl Into,
         keybinding_target: Option,
     ) -> impl IntoElement {
+        let menu_open = self.commit_menu_handle.is_deployed();
+
         PopoverMenu::new(id.into())
-            .trigger(
-                ui::ButtonLike::new_rounded_right("commit-split-button-right")
-                    .layer(ui::ElevationIndex::ModalSurface)
-                    .size(ui::ButtonSize::None)
-                    .child(
-                        div()
-                            .px_1()
-                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
-                    ),
-            )
+            .with_handle(self.commit_menu_handle.clone())
+            .trigger(crate::render_split_button_chevron_trigger(
+                "modal-commit-split-button-right",
+                menu_open,
+            ))
             .menu({
                 let git_panel_entity = self.git_panel.clone();
                 move |window, cx| {
@@ -327,7 +325,10 @@ impl CommitModal {
                     }))
                 }
             })
-            .with_handle(self.commit_menu_handle.clone())
+            .offset(gpui::Point {
+                x: px(0.),
+                y: px(2.),
+            })
             .anchor(Anchor::TopRight)
     }
 
@@ -370,13 +371,12 @@ impl CommitModal {
             .unwrap_or_else(|| "".to_owned());
 
         let branch_picker_button = Button::new("branch_picker_button", branch)
+            .label_size(LabelSize::Small)
             .start_icon(
                 Icon::new(IconName::GitBranch)
                     .size(IconSize::Small)
                     .color(Color::Placeholder),
             )
-            .style(ButtonStyle::Transparent)
-            .color(Color::Muted)
             .on_click(cx.listener(|_, _, window, cx| {
                 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
             }));
@@ -401,6 +401,7 @@ impl CommitModal {
                 x: px(0.0),
                 y: px(-2.0),
             });
+
         let focus_handle = self.focus_handle(cx);
 
         let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| {
@@ -409,13 +410,12 @@ impl CommitModal {
 
         h_flex()
             .group("commit_editor_footer")
-            .flex_none()
-            .w_full()
-            .items_center()
-            .justify_between()
             .w_full()
             .h(px(self.properties.footer_height))
+            .w_full()
             .gap_1()
+            .flex_none()
+            .justify_between()
             .child(
                 h_flex()
                     .gap_1()
@@ -430,64 +430,53 @@ impl CommitModal {
                     .children(generate_commit_message)
                     .children(co_authors),
             )
-            .child(div().flex_1())
             .child(
                 h_flex()
-                    .items_center()
-                    .justify_end()
-                    .flex_none()
-                    .px_1()
                     .gap_4()
                     .child(close_kb_hint)
                     .child(SplitButton::new(
-                        ui::ButtonLike::new_rounded_left(ElementId::Name(
-                            format!("split-button-left-{}", commit_label).into(),
-                        ))
-                        .layer(ui::ElevationIndex::ModalSurface)
-                        .size(ui::ButtonSize::Compact)
-                        .child(
-                            div()
-                                .child(Label::new(commit_label).size(LabelSize::Small))
-                                .mr_0p5(),
-                        )
-                        .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
-                            telemetry::event!("Git Committed", source = "Git Modal");
-                            this.git_panel.update(cx, |git_panel, cx| {
-                                git_panel.commit_changes(
-                                    CommitOptions {
-                                        amend: is_amend_pending,
-                                        signoff: is_signoff_enabled,
-                                        allow_empty: false,
-                                    },
-                                    window,
-                                    cx,
-                                )
-                            });
-                            cx.emit(DismissEvent);
-                        }))
-                        .disabled(!can_commit)
-                        .tooltip({
-                            let focus_handle = focus_handle.clone();
-                            move |_window, cx| {
-                                if can_commit {
-                                    Tooltip::with_meta_in(
-                                        tooltip,
-                                        Some(&git::Commit),
-                                        format!(
-                                            "git commit{}{}",
-                                            if is_amend_pending { " --amend" } else { "" },
-                                            if is_signoff_enabled { " --signoff" } else { "" }
-                                        ),
-                                        &focus_handle.clone(),
+                        ButtonLike::new_rounded_left(format!("split-button-left-{}", commit_label))
+                            .layer(ElevationIndex::ModalSurface)
+                            .size(ButtonSize::Compact)
+                            .disabled(!can_commit)
+                            .child(Label::new(commit_label).size(LabelSize::Small).mr_0p5())
+                            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
+                                telemetry::event!("Git Committed", source = "Git Modal");
+                                this.git_panel.update(cx, |git_panel, cx| {
+                                    git_panel.commit_changes(
+                                        CommitOptions {
+                                            amend: is_amend_pending,
+                                            signoff: is_signoff_enabled,
+                                            allow_empty: false,
+                                        },
+                                        window,
                                         cx,
                                     )
-                                } else {
-                                    Tooltip::simple(tooltip, cx)
+                                });
+                                cx.emit(DismissEvent);
+                            }))
+                            .tooltip({
+                                let focus_handle = focus_handle.clone();
+                                move |_window, cx| {
+                                    if can_commit {
+                                        Tooltip::with_meta_in(
+                                            tooltip,
+                                            Some(&git::Commit),
+                                            format!(
+                                                "git commit{}{}",
+                                                if is_amend_pending { " --amend" } else { "" },
+                                                if is_signoff_enabled { " --signoff" } else { "" }
+                                            ),
+                                            &focus_handle.clone(),
+                                            cx,
+                                        )
+                                    } else {
+                                        Tooltip::simple(tooltip, cx)
+                                    }
                                 }
-                            }
-                        }),
+                            }),
                         self.render_git_commit_menu(
-                            ElementId::Name(format!("split-button-right-{}", commit_label).into()),
+                            format!("split-button-right-{}", commit_label),
                             Some(focus_handle),
                         )
                         .into_any_element(),
diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs
index 9fff74d3983..a45590260da 100644
--- a/crates/git_ui/src/commit_view.rs
+++ b/crates/git_ui/src/commit_view.rs
@@ -2,8 +2,8 @@ use anyhow::{Context as _, Result};
 use buffer_diff::BufferDiff;
 use collections::HashMap;
 use editor::{
-    Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor,
-    hover_markdown_style, multibuffer_context_lines,
+    Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyDiffHunkDelegate,
+    SplittableEditor, hover_markdown_style, multibuffer_context_lines,
 };
 use futures_lite::future::yield_now;
 use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
@@ -275,7 +275,7 @@ impl CommitView {
                 window,
                 cx,
             );
-            editor.disable_diff_hunk_controls(cx);
+            editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx);
 
             editor.rhs_editor().update(cx, |editor, cx| {
                 editor.set_show_bookmarks(false, cx);
@@ -1193,7 +1193,7 @@ impl Item for CommitView {
                         window,
                         cx,
                     );
-                    editor.disable_diff_hunk_controls(cx);
+                    editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx);
                     editor.rhs_editor().update(cx, |editor, cx| {
                         editor.set_show_bookmarks(false, cx);
                         editor.set_show_breakpoints(false, cx);
diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs
index 309a1180781..1199afb9d44 100644
--- a/crates/git_ui/src/conflict_view.rs
+++ b/crates/git_ui/src/conflict_view.rs
@@ -17,7 +17,7 @@ use project::{
 use settings::Settings;
 use std::{ops::Range, sync::Arc};
 use ui::{ButtonLike, Divider, Tooltip, prelude::*};
-use util::{debug_panic, maybe};
+use util::debug_panic;
 use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle};
 use zed_actions::agent::{
     ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
@@ -115,19 +115,17 @@ pub(crate) fn buffer_ranges_updated(
         return;
     }
 
-    let buffer_conflicts = editor
-        .addon_mut::()
-        .unwrap()
-        .buffers
-        .entry(buffer_id)
-        .or_insert_with(|| {
-            let subscription = cx.subscribe(&conflict_set, conflicts_updated);
-            BufferConflicts {
-                block_ids: Vec::new(),
-                conflict_set: conflict_set.clone(),
-                _subscription: subscription,
-            }
-        });
+    let Some(conflict_addon) = editor.addon_mut::() else {
+        return;
+    };
+    let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| {
+        let subscription = cx.subscribe(&conflict_set, conflicts_updated);
+        BufferConflicts {
+            block_ids: Vec::new(),
+            conflict_set: conflict_set.clone(),
+            _subscription: subscription,
+        }
+    });
 
     let conflict_set = buffer_conflicts.conflict_set.clone();
     let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len();
@@ -150,18 +148,17 @@ pub(crate) fn buffers_removed(
     cx: &mut Context,
 ) {
     let mut removed_block_ids = HashSet::default();
-    editor
-        .addon_mut::()
-        .unwrap()
-        .buffers
-        .retain(|buffer_id, buffer| {
-            if removed_buffer_ids.contains(buffer_id) {
-                removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id));
-                false
-            } else {
-                true
-            }
-        });
+    let Some(conflict_addon) = editor.addon_mut::() else {
+        return;
+    };
+    conflict_addon.buffers.retain(|buffer_id, buffer| {
+        if removed_buffer_ids.contains(buffer_id) {
+            removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id));
+            false
+        } else {
+            true
+        }
+    });
     editor.remove_blocks(removed_block_ids, None, cx);
 }
 
@@ -176,9 +173,13 @@ fn conflicts_updated(
     let conflict_set = conflict_set.read(cx).snapshot();
     let multibuffer = editor.buffer().read(cx);
     let snapshot = multibuffer.snapshot(cx);
-    let old_range = maybe!({
-        let conflict_addon = editor.addon_mut::().unwrap();
-        let buffer_conflicts = conflict_addon.buffers.get(&buffer_id)?;
+    let old_range = {
+        let Some(conflict_addon) = editor.addon_mut::() else {
+            return;
+        };
+        let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else {
+            return;
+        };
         match buffer_conflicts.block_ids.get(event.old_range.clone()) {
             Some(_) => Some(event.old_range.clone()),
             None => {
@@ -197,10 +198,12 @@ fn conflicts_updated(
                 }
             }
         }
-    });
+    };
 
     // Remove obsolete highlights and blocks
-    let conflict_addon = editor.addon_mut::().unwrap();
+    let Some(conflict_addon) = editor.addon_mut::() else {
+        return;
+    };
     if let Some((buffer_conflicts, old_range)) = conflict_addon
         .buffers
         .get_mut(&buffer_id)
@@ -256,7 +259,9 @@ fn conflicts_updated(
     }
     let new_block_ids = editor.insert_blocks(blocks, None, cx);
 
-    let conflict_addon = editor.addon_mut::().unwrap();
+    let Some(conflict_addon) = editor.addon_mut::() else {
+        return;
+    };
     if let Some((buffer_conflicts, old_range)) =
         conflict_addon.buffers.get_mut(&buffer_id).zip(old_range)
     {
@@ -481,7 +486,7 @@ pub(crate) fn resolve_conflict(
                 let buffer_id = resolved_conflict.ours.end.buffer_id;
                 let buffer = multibuffer.read(cx).buffer(buffer_id)?;
                 resolved_conflict.resolve(buffer.clone(), &ranges, cx);
-                let conflict_addon = editor.addon_mut::().unwrap();
+                let conflict_addon = editor.addon_mut::()?;
                 let snapshot = multibuffer.read(cx).snapshot(cx);
                 let buffer_snapshot = buffer.read(cx).snapshot();
                 let state = conflict_addon
diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs
new file mode 100644
index 00000000000..96c05303719
--- /dev/null
+++ b/crates/git_ui/src/diff_multibuffer.rs
@@ -0,0 +1,1016 @@
+use crate::{
+    conflict_view,
+    git_panel::{GitPanel, GitStatusEntry},
+    git_panel_settings::GitPanelSettings,
+};
+use anyhow::Result;
+use buffer_diff::BufferDiff;
+use collections::{HashMap, HashSet};
+use editor::{
+    EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk,
+    multibuffer_context_lines, scroll::Autoscroll,
+};
+use futures_lite::future::yield_now;
+use git::{repository::RepoPath, status::FileStatus};
+use gpui::{
+    App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    SharedString, Subscription, Task, WeakEntity,
+};
+use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
+use multi_buffer::{MultiBuffer, PathKey};
+use project::{
+    ConflictSet, Project, ProjectPath,
+    git_store::{
+        Repository,
+        diff_buffer_list::{self, BranchDiffEvent, DiffBase},
+    },
+};
+use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore};
+use std::{collections::BTreeMap, sync::Arc};
+use theme::ActiveTheme;
+use ui::{CommonAnimationExt as _, KeyBinding, prelude::*};
+use util::{ResultExt as _, rel_path::RelPath};
+use workspace::{
+    CloseActiveItem, ItemNavHistory, Workspace,
+    item::{Item, SaveOptions},
+};
+use ztracing::instrument;
+
+struct BufferSubscriptions {
+    _diff: Entity,
+    display_buffer: Entity,
+    _diff_subscription: Subscription,
+    _conflict_set: Option>,
+    _conflict_set_subscription: Option,
+}
+
+pub struct DiffMultibuffer {
+    multibuffer: Entity,
+    branch_diff: Entity,
+    editor: Entity,
+    buffer_subscriptions: HashMap,
+    workspace: WeakEntity,
+    focus_handle: FocusHandle,
+    pending_scroll: Option,
+    review_comment_count: usize,
+    empty_label: SharedString,
+    _task: Task>,
+    _subscription: Subscription,
+}
+
+impl DiffMultibuffer {
+    pub(crate) fn new(
+        branch_diff: Entity,
+        multibuffer_capability: Capability,
+        empty_label: impl Into,
+        configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context) + 'static,
+        project: Entity,
+        workspace: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+        let multibuffer = cx.new(|cx| {
+            let mut multibuffer = MultiBuffer::new(multibuffer_capability);
+            multibuffer.set_all_diff_hunks_expanded(cx);
+            multibuffer
+        });
+        let editor = cx.new(|cx| {
+            let mut diff_display_editor = SplittableEditor::new(
+                EditorSettings::get_global(cx).diff_view_style,
+                multibuffer.clone(),
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            );
+            configure_editor(&mut diff_display_editor, cx);
+            diff_display_editor.rhs_editor().update(cx, |editor, cx| {
+                editor.set_show_diff_review_button(true, cx);
+            });
+            diff_display_editor
+        });
+        let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
+
+        let primary_editor = editor.read(cx).rhs_editor().clone();
+        let review_comment_subscription =
+            cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
+                if let EditorEvent::ReviewCommentsChanged { total_count } = event {
+                    this.review_comment_count = *total_count;
+                    cx.notify();
+                }
+            });
+
+        let branch_diff_subscription = cx.subscribe_in(
+            &branch_diff,
+            window,
+            move |this, _git_store, event, window, cx| match event {
+                BranchDiffEvent::FileListChanged => {
+                    this._task = window.spawn(cx, {
+                        let this = cx.weak_entity();
+                        async |cx| Self::refresh(this, cx).await
+                    })
+                }
+                BranchDiffEvent::DiffBaseChanged => {
+                    this.pending_scroll.take();
+                    this._task = window.spawn(cx, {
+                        let this = cx.weak_entity();
+                        async |cx| Self::refresh(this, cx).await
+                    })
+                }
+            },
+        );
+
+        let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by;
+        let mut was_group_by = GitPanelSettings::get_global(cx).group_by;
+        let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
+        let mut was_collapse_untracked_diff =
+            GitPanelSettings::get_global(cx).collapse_untracked_diff;
+        cx.observe_global_in::(window, move |this, window, cx| {
+            let settings = GitPanelSettings::get_global(cx);
+            let sort_by = settings.sort_by;
+            let group_by = settings.group_by;
+            let tree_view = settings.tree_view;
+            let is_collapse_untracked_diff = settings.collapse_untracked_diff;
+            if sort_by != was_sort_by
+                || group_by != was_group_by
+                || tree_view != was_tree_view
+                || is_collapse_untracked_diff != was_collapse_untracked_diff
+            {
+                this._task = {
+                    window.spawn(cx, {
+                        let this = cx.weak_entity();
+                        async |cx| Self::refresh(this, cx).await
+                    })
+                }
+            }
+            was_sort_by = sort_by;
+            was_group_by = group_by;
+            was_tree_view = tree_view;
+            was_collapse_untracked_diff = is_collapse_untracked_diff;
+        })
+        .detach();
+
+        let task = window.spawn(cx, {
+            let this = cx.weak_entity();
+            async |cx| Self::refresh(this, cx).await
+        });
+
+        Self {
+            workspace: workspace.downgrade(),
+            branch_diff,
+            focus_handle,
+            editor,
+            multibuffer,
+            buffer_subscriptions: Default::default(),
+            pending_scroll: None,
+            review_comment_count: 0,
+            empty_label: empty_label.into(),
+            _task: task,
+            _subscription: Subscription::join(
+                branch_diff_subscription,
+                Subscription::join(editor_subscription, review_comment_subscription),
+            ),
+        }
+    }
+
+    pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
+        self.branch_diff.read(cx).diff_base()
+    }
+
+    pub(crate) fn branch_diff(&self) -> &Entity {
+        &self.branch_diff
+    }
+
+    pub(crate) fn repo(&self, cx: &App) -> Option> {
+        self.branch_diff.read(cx).repo().cloned()
+    }
+
+    pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) {
+        self.branch_diff.update(cx, |branch_diff, cx| {
+            branch_diff.set_repo(repo, cx);
+        });
+    }
+
+    pub(crate) fn is_dirty(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).is_dirty(cx)
+    }
+
+    pub(crate) fn has_conflict(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_conflict(cx)
+    }
+
+    pub(crate) fn multibuffer(&self) -> &Entity {
+        &self.multibuffer
+    }
+
+    pub(crate) fn move_to_entry(
+        &mut self,
+        entry: GitStatusEntry,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
+            return;
+        };
+        let repo = git_repo.read(cx);
+        let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx);
+
+        self.move_to_path(path_key, window, cx)
+    }
+
+    pub(crate) fn move_to_project_path(
+        &mut self,
+        project_path: &ProjectPath,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
+            return;
+        };
+        let Some(repo_path) = git_repo
+            .read(cx)
+            .project_path_to_repo_path(project_path, cx)
+        else {
+            return;
+        };
+        let status = git_repo
+            .read(cx)
+            .status_for_path(&repo_path)
+            .map(|entry| entry.status)
+            .unwrap_or(FileStatus::Untracked);
+        let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx);
+        self.move_to_path(path_key, window, cx)
+    }
+
+    pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |editor, cx| {
+                editor.change_selections(Default::default(), window, cx, |s| {
+                    s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
+                });
+            });
+        });
+    }
+
+    pub(crate) fn move_to_path(
+        &mut self,
+        path_key: PathKey,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
+            self.editor.update(cx, |editor, cx| {
+                editor.rhs_editor().update(cx, |editor, cx| {
+                    editor.change_selections(
+                        SelectionEffects::scroll(Autoscroll::focused()),
+                        window,
+                        cx,
+                        |s| {
+                            s.select_ranges([position..position]);
+                        },
+                    )
+                })
+            });
+        } else {
+            self.pending_scroll = Some(path_key);
+        }
+    }
+
+    pub(crate) fn autoscroll(&self, cx: &mut Context) {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |editor, cx| {
+                editor.request_autoscroll(Autoscroll::fit(), cx);
+            })
+        })
+    }
+
+    pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
+        self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
+    }
+
+    /// Returns the total count of review comments across all hunks/files.
+    pub(crate) fn total_review_comment_count(&self) -> usize {
+        self.review_comment_count
+    }
+
+    /// Returns a reference to the splittable editor.
+    pub(crate) fn editor(&self) -> &Entity {
+        &self.editor
+    }
+
+    pub(crate) fn selected_ranges(
+        &self,
+        cx: &App,
+    ) -> (bool, Vec>) {
+        let editor = self.editor.read(cx).rhs_editor().read(cx);
+        let snapshot = self.multibuffer.read(cx).snapshot(cx);
+        let mut selection = true;
+        let mut ranges = editor
+            .selections
+            .disjoint_anchor_ranges()
+            .collect::>();
+        if !ranges.iter().any(|range| range.start != range.end) {
+            selection = false;
+            let anchor = editor.selections.newest_anchor().head();
+            if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
+                && let Some(range) = snapshot
+                    .anchor_in_buffer(excerpt_range.context.start)
+                    .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
+                    .map(|(start, end)| start..end)
+            {
+                ranges = vec![range];
+            } else {
+                ranges = Vec::default();
+            };
+        }
+
+        (selection, ranges)
+    }
+
+    /// Ranges for a toolbar stage/unstage action: the selection, or the cursor
+    /// (a zero-width range that resolves to the single hunk under it) when
+    /// there is no selection. Unlike [`Self::selected_ranges`], this never
+    /// widens to the whole excerpt, so actions affect one hunk at a time.
+    fn hunk_action_ranges(&self, cx: &App) -> Vec> {
+        self.editor
+            .read(cx)
+            .rhs_editor()
+            .read(cx)
+            .selections
+            .disjoint_anchor_ranges()
+            .collect()
+    }
+
+    pub(crate) fn stage_or_unstage_selected_hunks(
+        &mut self,
+        stage: bool,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let editor = self.editor.read(cx).rhs_editor().clone();
+        let ranges = self.hunk_action_ranges(cx);
+        // Route through the editor's delegated stage path, the same path taken
+        // by the hunk buttons (on either side of a split) and the keyboard.
+        // For staging, dirty buffers are saved first, exactly as they are when
+        // staging from the uncommitted diff or a normal editor.
+        editor.update(cx, |editor, cx| {
+            editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
+        });
+        if move_to_next {
+            editor
+                .focus_handle(cx)
+                .dispatch_action(&GoToHunk, window, cx);
+        }
+    }
+
+    fn handle_editor_event(
+        &mut self,
+        editor: &Entity,
+        event: &EditorEvent,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        match event {
+            EditorEvent::SelectionsChanged { local: true } => {
+                // Only follow the git panel selection from the view the user is
+                // actually interacting with. Background (non-active) diff views
+                // refresh on their own and must not hijack the panel selection.
+                if !editor.focus_handle(cx).contains_focused(window, cx) {
+                    return;
+                }
+                let Some(project_path) = self.active_project_path(cx) else {
+                    return;
+                };
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        if let Some(git_panel) = workspace.panel::(cx) {
+                            git_panel.update(cx, |git_panel, cx| {
+                                git_panel.select_entry_by_path(project_path, window, cx)
+                            })
+                        }
+                    })
+                    .ok();
+            }
+            EditorEvent::Saved => {
+                self._task =
+                    cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await);
+            }
+
+            _ => {}
+        }
+        if editor.focus_handle(cx).contains_focused(window, cx)
+            && self.multibuffer.read(cx).is_empty()
+        {
+            self.focus_handle.focus(window, cx)
+        }
+    }
+
+    #[instrument(skip_all)]
+    fn register_buffer(
+        &mut self,
+        repo_path: RepoPath,
+        path_key: PathKey,
+        file_status: FileStatus,
+        display_buffer: Entity,
+        main_buffer: Entity,
+        diff: Entity,
+        conflict_set: Option>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Option {
+        let diff_subscription = cx.subscribe_in(&diff, window, {
+            let repo_path = repo_path.clone();
+            let path_key = path_key.clone();
+            let display_buffer = display_buffer.clone();
+            let main_buffer = main_buffer.clone();
+            let diff = diff.clone();
+            let conflict_set = conflict_set.clone();
+            move |this, _, event, window, cx| match event {
+                buffer_diff::BufferDiffEvent::DiffChanged(_) => {
+                    this.buffer_ranges_changed(
+                        repo_path.clone(),
+                        path_key.clone(),
+                        file_status,
+                        display_buffer.clone(),
+                        main_buffer.clone(),
+                        diff.clone(),
+                        conflict_set.clone(),
+                        window,
+                        cx,
+                    );
+                }
+                buffer_diff::BufferDiffEvent::BaseTextChanged => {}
+            }
+        });
+        let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| {
+            cx.subscribe_in(conflict_set, window, {
+                let repo_path = repo_path.clone();
+                let path_key = path_key.clone();
+                let display_buffer = display_buffer.clone();
+                let main_buffer = main_buffer.clone();
+                let diff = diff.clone();
+                let conflict_set = Some(conflict_set.clone());
+                move |this, _, _, window, cx| {
+                    this.buffer_ranges_changed(
+                        repo_path.clone(),
+                        path_key.clone(),
+                        file_status,
+                        display_buffer.clone(),
+                        main_buffer.clone(),
+                        diff.clone(),
+                        conflict_set.clone(),
+                        window,
+                        cx,
+                    )
+                }
+            })
+        });
+        self.buffer_subscriptions.insert(
+            repo_path,
+            BufferSubscriptions {
+                _diff: diff.clone(),
+                display_buffer: display_buffer.clone(),
+                _diff_subscription: diff_subscription,
+                _conflict_set: conflict_set.clone(),
+                _conflict_set_subscription: conflict_set_subscription,
+            },
+        );
+
+        let snapshot = display_buffer.read(cx).snapshot();
+        let diff_snapshot = diff.read(cx).snapshot(cx);
+
+        let excerpt_ranges = {
+            let diff_hunk_ranges = diff_snapshot
+                .hunks_intersecting_range(
+                    Anchor::min_max_range_for_buffer(snapshot.remote_id()),
+                    &snapshot,
+                )
+                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
+            let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| {
+                let conflicts = conflict_set.read(cx).snapshot();
+                let conflicts = conflicts
+                    .conflicts
+                    .iter()
+                    .map(|conflict| conflict.range.to_point(&snapshot))
+                    .collect::>();
+                (!conflicts.is_empty()).then_some(conflicts)
+            });
+
+            conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect())
+        };
+
+        let buffer_id = snapshot.text.remote_id();
+        let mut needs_fold = false;
+
+        let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
+            let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
+            let is_newly_added = editor.update_excerpts_for_path(
+                path_key.clone(),
+                display_buffer,
+                excerpt_ranges,
+                multibuffer_context_lines(cx),
+                diff,
+                cx,
+            );
+            if let Some(conflict_set) = conflict_set {
+                editor.rhs_editor().update(cx, |editor, cx| {
+                    conflict_view::buffer_ranges_updated(editor, conflict_set, cx);
+                });
+            }
+            (was_empty, is_newly_added)
+        });
+
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |editor, cx| {
+                if was_empty {
+                    editor.change_selections(
+                        SelectionEffects::no_scroll(),
+                        window,
+                        cx,
+                        |selections| {
+                            selections.select_ranges([
+                                multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
+                            ])
+                        },
+                    );
+                }
+                if is_excerpt_newly_added
+                    && (file_status.is_deleted()
+                        || (file_status.is_untracked()
+                            && GitPanelSettings::get_global(cx).collapse_untracked_diff))
+                {
+                    needs_fold = true;
+                }
+            })
+        });
+
+        if self.multibuffer.read(cx).is_empty()
+            && self
+                .editor
+                .read(cx)
+                .focus_handle(cx)
+                .contains_focused(window, cx)
+        {
+            self.focus_handle.focus(window, cx);
+        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
+            self.editor.update(cx, |editor, cx| {
+                editor.focus_handle(cx).focus(window, cx);
+            });
+        }
+        if self.pending_scroll.as_ref() == Some(&path_key) {
+            self.move_to_path(path_key, window, cx);
+        }
+
+        needs_fold.then_some(buffer_id)
+    }
+
+    fn buffer_ranges_changed(
+        &mut self,
+        repo_path: RepoPath,
+        path_key: PathKey,
+        file_status: FileStatus,
+        display_buffer: Entity,
+        main_buffer: Entity,
+        diff: Entity,
+        conflict_set: Option>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if display_buffer.read(cx).is_dirty() {
+            return;
+        }
+        self.register_buffer(
+            repo_path,
+            path_key,
+            file_status,
+            display_buffer,
+            main_buffer,
+            diff,
+            conflict_set,
+            window,
+            cx,
+        );
+    }
+
+    #[instrument(skip(this, cx))]
+    pub(crate) async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> {
+        let entries = this.update(cx, |this, cx| {
+            let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
+                let load_buffers = branch_diff.load_buffers(cx);
+                (branch_diff.repo().cloned(), load_buffers)
+            });
+            let mut previous_paths = this
+                .multibuffer
+                .read(cx)
+                .snapshot(cx)
+                .buffers_with_paths()
+                .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
+                .collect::>();
+
+            let mut entries = BTreeMap::new();
+            let mut live_repo_paths = HashSet::default();
+            if let Some(repo) = repo {
+                let repo = repo.read(cx);
+                for diff_buffer in buffers_to_load {
+                    live_repo_paths.insert(diff_buffer.repo_path.clone());
+                    let path_key = project_diff_path_key(
+                        &repo,
+                        &diff_buffer.repo_path,
+                        diff_buffer.file_status,
+                        cx,
+                    );
+                    previous_paths.remove(&path_key);
+                    entries.insert(path_key, diff_buffer);
+                }
+            }
+
+            let repo_path_by_display_id = this
+                .buffer_subscriptions
+                .iter()
+                .map(|(repo_path, sub)| {
+                    (sub.display_buffer.read(cx).remote_id(), repo_path.clone())
+                })
+                .collect::>();
+
+            this.editor.update(cx, |editor, cx| {
+                for (path, buffer_id) in previous_paths {
+                    if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) {
+                        this.buffer_subscriptions.remove(repo_path);
+                    }
+                    editor.rhs_editor().update(cx, |editor, cx| {
+                        conflict_view::buffers_removed(editor, &[buffer_id], cx);
+                    });
+                    let _span = ztracing::info_span!("remove_excerpts_for_path");
+                    _span.enter();
+                    editor.remove_excerpts_for_path(path, cx);
+                }
+            });
+
+            this.buffer_subscriptions
+                .retain(|repo_path, _| live_repo_paths.contains(repo_path));
+
+            entries
+        })?;
+
+        let mut buffers_to_fold = Vec::new();
+
+        for (path_key, entry) in entries {
+            if let Some(loaded_buffer) = entry.load.await.log_err() {
+                // We might be lagging behind enough that all future entry.load futures are no longer pending.
+                // If that is the case, this task will never yield, starving the foreground thread of execution time.
+                yield_now().await;
+                cx.update(|window, cx| {
+                    this.update(cx, |this, cx| {
+                        if let Some(buffer_id) = this.register_buffer(
+                            entry.repo_path,
+                            path_key,
+                            entry.file_status,
+                            loaded_buffer.display_buffer,
+                            loaded_buffer.main_buffer,
+                            loaded_buffer.diff,
+                            loaded_buffer.conflict_set,
+                            window,
+                            cx,
+                        ) {
+                            buffers_to_fold.push(buffer_id);
+                        }
+                    })
+                    .ok();
+                })?;
+            }
+        }
+        this.update(cx, |this, cx| {
+            if !buffers_to_fold.is_empty() {
+                this.editor.update(cx, |editor, cx| {
+                    editor
+                        .rhs_editor()
+                        .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
+                });
+            }
+            this.pending_scroll.take();
+            cx.notify();
+        })?;
+
+        Ok(())
+    }
+
+    pub(crate) fn active_project_path(&self, cx: &App) -> Option {
+        let editor = self.editor.read(cx).focused_editor().read(cx);
+        let multibuffer = editor.buffer().read(cx);
+        let position = editor.selections.newest_anchor().head();
+        let snapshot = multibuffer.snapshot(cx);
+        let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
+        let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
+
+        let file = buffer.read(cx).file()?;
+        Some(ProjectPath {
+            worktree_id: file.worktree_id(cx),
+            path: file.path().clone(),
+        })
+    }
+
+    pub(crate) fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+
+    pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |primary_editor, cx| {
+                primary_editor.deactivated(window, cx);
+            })
+        });
+    }
+
+    pub(crate) fn navigate(
+        &mut self,
+        data: Arc,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> bool {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |primary_editor, cx| {
+                primary_editor.navigate(data, window, cx)
+            })
+        })
+    }
+
+    pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context) {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |primary_editor, _| {
+                primary_editor.set_nav_history(Some(nav_history));
+            })
+        });
+    }
+
+    pub(crate) fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.editor
+            .read(cx)
+            .rhs_editor()
+            .read(cx)
+            .for_each_project_item(cx, f)
+    }
+
+    pub(crate) fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |primary_editor, cx| {
+                primary_editor.save(options, project, window, cx)
+            })
+        })
+    }
+
+    pub(crate) fn reload(
+        &mut self,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.editor.update(cx, |editor, cx| {
+            editor.rhs_editor().update(cx, |primary_editor, cx| {
+                primary_editor.reload(project, window, cx)
+            })
+        })
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn excerpt_paths(&self, cx: &App) -> Vec> {
+        let snapshot = self
+            .editor()
+            .read(cx)
+            .rhs_editor()
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .snapshot(cx);
+        snapshot
+            .excerpts()
+            .map(|excerpt| {
+                snapshot
+                    .path_for_buffer(excerpt.context.start.buffer_id)
+                    .unwrap()
+                    .path
+                    .clone()
+            })
+            .collect()
+    }
+
+    /// Returns the real (worktree-relative) path of each excerpted buffer, in
+    /// the order the excerpts appear in the multibuffer. Unlike
+    /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather
+    /// than the (possibly synthetic) `PathKey` path used for sorting.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn excerpt_file_paths(&self, cx: &App) -> Vec {
+        let multibuffer = self
+            .editor()
+            .read(cx)
+            .rhs_editor()
+            .read(cx)
+            .buffer()
+            .clone();
+        let snapshot = multibuffer.read(cx).snapshot(cx);
+        let mut result = Vec::new();
+        let mut last_buffer_id = None;
+        for excerpt in snapshot.excerpts() {
+            let buffer_id = excerpt.context.start.buffer_id;
+            if last_buffer_id == Some(buffer_id) {
+                continue;
+            }
+            last_buffer_id = Some(buffer_id);
+            if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id)
+                && let Some(file) = buffer.read(cx).file()
+            {
+                result.push(file.path().as_unix_str().to_string());
+            }
+        }
+        result
+    }
+}
+
+impl EventEmitter for DiffMultibuffer {}
+
+impl Focusable for DiffMultibuffer {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        if self.multibuffer.read(cx).is_empty() {
+            self.focus_handle.clone()
+        } else {
+            self.editor.focus_handle(cx)
+        }
+    }
+}
+
+impl Render for DiffMultibuffer {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let is_empty = self.multibuffer.read(cx).is_empty();
+        let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready();
+        let empty_label = self.empty_label.clone();
+
+        div()
+            .track_focus(&self.focus_handle)
+            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
+            .bg(cx.theme().colors().editor_background)
+            .flex()
+            .items_center()
+            .justify_center()
+            .size_full()
+            .when(is_empty && is_loading, |el| {
+                let rems = TextSize::Large.rems(cx);
+                el.child(
+                    Icon::new(IconName::LoadCircle)
+                        .size(IconSize::Custom(rems))
+                        .color(Color::Accent)
+                        .with_rotate_animation(3)
+                        .into_any_element(),
+                )
+            })
+            .when(is_empty && !is_loading, |el| {
+                let remote_button = if let Some(panel) = self
+                    .workspace
+                    .upgrade()
+                    .and_then(|workspace| workspace.read(cx).panel::(cx))
+                {
+                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
+                } else {
+                    None
+                };
+                let keybinding_focus_handle = self.focus_handle(cx);
+                el.child(
+                    v_flex()
+                        .gap_1()
+                        .child(h_flex().justify_around().child(Label::new(empty_label)))
+                        .map(|el| match remote_button {
+                            Some(button) => el.child(h_flex().justify_around().child(button)),
+                            None => el.child(
+                                h_flex()
+                                    .justify_around()
+                                    .child(Label::new("Remote up to date")),
+                            ),
+                        })
+                        .child(
+                            h_flex().justify_around().mt_1().child(
+                                Button::new("project-diff-close-button", "Close")
+                                    .key_binding(KeyBinding::for_action_in(
+                                        &CloseActiveItem::default(),
+                                        &keybinding_focus_handle,
+                                        cx,
+                                    ))
+                                    .on_click(move |_, window, cx| {
+                                        window.focus(&keybinding_focus_handle, cx);
+                                        window.dispatch_action(
+                                            Box::new(CloseActiveItem::default()),
+                                            cx,
+                                        );
+                                    }),
+                            ),
+                        ),
+                )
+            })
+            .when(!is_empty, |el| el.child(self.editor.clone()))
+    }
+}
+
+const CONFLICT_SORT_PREFIX: u64 = 1;
+const TRACKED_SORT_PREFIX: u64 = 2;
+const NEW_SORT_PREFIX: u64 = 3;
+
+/// Computes a stable [`PathKey`] for a buffer in the project diff.
+///
+/// The key is an intrinsic function of the file's own repo path and status; it
+/// never depends on which other buffers happen to be present in the
+/// multibuffer. This is required because the multibuffer uses the path key both
+/// to order excerpts and to identify which excerpts belong to a given buffer, so
+/// a key that shifted as files were added or removed would break that identity.
+///
+/// Status grouping is encoded in the `sort_prefix`, and the within-group order
+/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural
+/// ordering reproduces the git panel's order. The path here is only ever used
+/// for sorting and multibuffer identity; the path shown in the UI comes from the
+/// buffer's own `File`.
+pub(crate) fn project_diff_path_key(
+    repo: &Repository,
+    repo_path: &RepoPath,
+    status: FileStatus,
+    cx: &App,
+) -> PathKey {
+    let settings = GitPanelSettings::get_global(cx);
+    let sort_prefix = if settings.group_by != GitPanelGroupBy::Status {
+        TRACKED_SORT_PREFIX
+    } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
+        CONFLICT_SORT_PREFIX
+    } else if status.is_created() {
+        NEW_SORT_PREFIX
+    } else {
+        TRACKED_SORT_PREFIX
+    };
+    let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by);
+    PathKey::with_sort_prefix(sort_prefix, path)
+}
+
+fn project_diff_sort_path(
+    repo_path: &RelPath,
+    tree_view: bool,
+    sort_by: GitPanelSortBy,
+) -> Arc {
+    if tree_view {
+        tree_sort_path(repo_path)
+    } else {
+        match sort_by {
+            GitPanelSortBy::Path => repo_path.into_arc(),
+            GitPanelSortBy::Name => name_sort_path(repo_path),
+        }
+    }
+}
+
+/// Builds a synthetic path that sorts by file name first, falling back to the
+/// full path to keep the key unique per file.
+fn name_sort_path(repo_path: &RelPath) -> Arc {
+    let Some(file_name) = repo_path.file_name() else {
+        return repo_path.into_arc();
+    };
+    let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str());
+    RelPath::unix(&synthetic)
+        .map(|path| path.into_arc())
+        .unwrap_or_else(|_| repo_path.into_arc())
+}
+
+/// Builds a synthetic path whose natural component-wise ordering reproduces a
+/// folder-first tree order. Each directory component is prefixed with a NUL
+/// byte, which can never appear in a real path component and sorts before every
+/// printable character, so at each level directories sort before files.
+fn tree_sort_path(repo_path: &RelPath) -> Arc {
+    let components: Vec<&str> = repo_path.components().collect();
+    if components.len() <= 1 {
+        return repo_path.into_arc();
+    }
+    let last = components.len() - 1;
+    let mut synthetic = String::new();
+    for (index, component) in components.into_iter().enumerate() {
+        if index > 0 {
+            synthetic.push('/');
+        }
+        if index < last {
+            synthetic.push('\0');
+        }
+        synthetic.push_str(component);
+    }
+    RelPath::unix(&synthetic)
+        .map(|path| path.into_arc())
+        .unwrap_or_else(|_| repo_path.into_arc())
+}
diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs
index 477f18be545..a8159566da4 100644
--- a/crates/git_ui/src/file_diff_view.rs
+++ b/crates/git_ui/src/file_diff_view.rs
@@ -2,7 +2,10 @@
 
 use anyhow::Result;
 use buffer_diff::BufferDiff;
-use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor};
+use editor::{
+    Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
+    SplittableEditor,
+};
 use futures::{FutureExt, select_biased};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
@@ -103,11 +106,8 @@ impl FileDiffView {
                 window,
                 cx,
             );
-            splittable.rhs_editor().update(cx, |editor, _| {
-                editor.start_temporary_diff_override();
-            });
-            splittable.disable_diff_hunk_controls(cx);
-            splittable.set_render_diff_hunks_as_unstaged(cx);
+            splittable
+                .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
             splittable
         });
 
@@ -236,7 +236,7 @@ impl Item for FileDiffView {
                             .to_string(),
                     )
                 })
-                .unwrap_or_else(|| "untitled".into())
+                .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into())
         };
         let old_filename = title_text(&self.old_buffer);
         let new_filename = title_text(&self.new_buffer);
@@ -250,7 +250,7 @@ impl Item for FileDiffView {
                 .read(cx)
                 .file()
                 .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
-                .unwrap_or_else(|| "untitled".into())
+                .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into())
         };
         let old_path = path(&self.old_buffer);
         let new_path = path(&self.new_buffer);
diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs
index 384474e2105..c28b7644316 100644
--- a/crates/git_ui/src/git_graph.rs
+++ b/crates/git_ui/src/git_graph.rs
@@ -19,12 +19,13 @@ use git::{
 use gpui::{
     Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength,
     DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable,
-    Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy,
+    Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy,
     ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement,
     UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*,
     px, uniform_list,
 };
 use language::line_diff;
+use markdown::{Markdown, MarkdownElement};
 use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use picker::{Picker, PickerDelegate};
 use project::{
@@ -51,12 +52,13 @@ use theme::AccentColors;
 use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
 use ui::{
     Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat,
-    Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing,
+    Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing,
     RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
     TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns,
-    prelude::*, render_redistributable_columns_resize_handles, render_table_header,
-    table_row::TableRow,
+    prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths,
+    render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow,
 };
+use util::{ResultExt, debug_panic};
 use workspace::{
     ModalView, Workspace,
     item::{Item, ItemEvent, TabTooltipContent},
@@ -71,8 +73,8 @@ const RESIZE_HANDLE_WIDTH: f32 = 8.0;
 const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
 const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.);
 const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands";
-// Extra vertical breathing room added to the UI line height when computing
-// the git graph's row height, so commit dots and lines have space around them.
+const TREE_INDENT: f32 = 20.0;
+const TABLE_COLUMN_COUNT: usize = 4;
 const ROW_VERTICAL_PADDING: Pixels = px(4.0);
 
 struct CopiedState {
@@ -275,8 +277,6 @@ impl ChangedFileEntry {
         workspace: WeakEntity,
         _cx: &App,
     ) -> AnyElement {
-        const TREE_INDENT: f32 = 12.0;
-
         let file_name = self.file_name.clone();
         let dir_path = self.dir_path.clone();
 
@@ -355,8 +355,6 @@ struct ChangedFileDirectoryEntry {
 
 impl ChangedFileDirectoryEntry {
     fn render(&self, ix: usize, git_graph: WeakEntity, cx: &App) -> AnyElement {
-        const TREE_INDENT: f32 = 12.0;
-
         let path = self.path.clone();
         let expanded = self.expanded;
         let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx)
@@ -378,22 +376,6 @@ impl ChangedFileDirectoryEntry {
             .spacing(ListItemSpacing::Sparse)
             .indent_level(self.depth)
             .indent_step_size(px(TREE_INDENT))
-            .toggle(Some(expanded))
-            .always_show_disclosure_icon(true)
-            .on_toggle({
-                let path = path.clone();
-                let git_graph = git_graph.clone();
-                move |_, _, cx| {
-                    git_graph
-                        .update(cx, |git_graph, cx| {
-                            git_graph
-                                .changed_files_expanded_dirs
-                                .insert(path.clone(), !expanded);
-                            cx.notify();
-                        })
-                        .ok();
-                }
-            })
             .start_slot(folder_icon)
             .child(
                 Label::new(self.name.clone())
@@ -1228,32 +1210,32 @@ pub fn open_or_reuse_graph(
         graph.repo_id == repo_id && graph.log_source == log_source
     });
 
-    if let Some(existing) = existing {
-        if let Some(sha) = sha {
-            existing.update(cx, |graph, cx| {
+    let git_graph = if let Some(existing) = existing {
+        workspace.activate_item(&existing, true, true, window, cx);
+        existing
+    } else {
+        let workspace_handle = workspace.weak_handle();
+        let git_graph = cx.new(|cx| {
+            GitGraph::new(
+                repo_id,
+                git_store,
+                workspace_handle,
+                Some(log_source),
+                window,
+                cx,
+            )
+        });
+        workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
+        git_graph
+    };
+
+    if let Some(sha) = sha {
+        cx.defer(move |cx| {
+            git_graph.update(cx, |graph, cx| {
                 graph.select_commit_by_sha(sha.as_str(), cx);
             });
-        }
-        workspace.activate_item(&existing, true, true, window, cx);
-        return;
+        });
     }
-
-    let workspace_handle = workspace.weak_handle();
-    let git_graph = cx.new(|cx| {
-        let mut graph = GitGraph::new(
-            repo_id,
-            git_store,
-            workspace_handle,
-            Some(log_source),
-            window,
-            cx,
-        );
-        if let Some(sha) = sha {
-            graph.select_commit_by_sha(sha.as_str(), cx);
-        }
-        graph
-    });
-    workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx);
 }
 
 fn lane_center_x(bounds: Bounds, lane: f32) -> Pixels {
@@ -1318,10 +1300,16 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) {
 struct GitGraphContextMenu {
     menu: Entity,
     position: Point,
-    entry_idx: usize,
+    entry_idx: Option,
     _subscription: Subscription,
 }
 
+struct DetailPanelCommitMessage {
+    sha: Oid,
+    message: Entity,
+    scroll_handle: ScrollHandle,
+}
+
 pub struct GitGraph {
     focus_handle: FocusHandle,
     search_state: SearchState,
@@ -1331,6 +1319,9 @@ pub struct GitGraph {
     context_menu: Option,
     table_interaction_state: Entity,
     column_widths: Entity,
+    /// Per-column visibility mask owned by the view (not the resize state) so columns can be
+    /// hidden regardless of whether the table is resizable. `true` means the column is hidden.
+    column_visibility: TableRow,
     selected_entry_idx: Option,
     hovered_entry_idx: Option,
     graph_canvas_bounds: Rc>>>,
@@ -1339,6 +1330,8 @@ pub struct GitGraph {
     selected_commit_diff: Option,
     selected_commit_diff_stats: Option<(usize, usize)>,
     _commit_diff_task: Option>,
+    selected_commit_message: Option,
+    _selected_commit_message_task: Option>,
     commit_details_split_state: Entity,
     repo_id: RepositoryId,
     changed_files_scroll_handle: UniformListScrollHandle,
@@ -1392,22 +1385,32 @@ impl GitGraph {
     }
 
     fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
-        // todo(git_graph): We should make a column/table api that allows removing table columns
-        let fractions = self
+        let raw = self
             .column_widths
             .read(cx)
             .preview_fractions(window.rem_size());
+        let fractions = redistribute_hidden_fractions(&raw, Some(&self.column_visibility));
+
+        // Hidden columns occupy no space in the layout, so report them as zero here even though
+        // the shared redistribution helper preserves their stored width for when they return.
+        let value = |idx: usize| {
+            if self.column_visibility.get(idx).copied().unwrap_or(false) {
+                0.0
+            } else {
+                fractions[idx]
+            }
+        };
 
         let is_path_history = matches!(self.log_source, LogSource::Path(_));
-        let graph_fraction = if is_path_history { 0.0 } else { fractions[0] };
+        let graph_fraction = if is_path_history { 0.0 } else { value(0) };
         let offset = if is_path_history { 0 } else { 1 };
 
         [
             graph_fraction,
-            fractions[offset],
-            fractions[offset + 1],
-            fractions[offset + 2],
-            fractions[offset + 3],
+            value(offset),
+            value(offset + 1),
+            value(offset + 2),
+            value(offset + 3),
         ]
     }
 
@@ -1435,10 +1438,13 @@ impl GitGraph {
     }
 
     fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
-        self.column_widths
-            .read(cx)
-            .preview_column_width(0, window)
-            .unwrap_or_else(|| self.graph_canvas_content_width())
+        let container = self.column_widths.read(cx).cached_container_width();
+        let graph_fraction = self.preview_column_fractions(window, cx)[0];
+        if container > px(0.) && graph_fraction > 0.0 {
+            container * graph_fraction
+        } else {
+            self.graph_canvas_content_width()
+        }
     }
 
     pub fn new(
@@ -1521,6 +1527,14 @@ impl GitGraph {
                 )
             })
         };
+        let column_visibility = TableRow::from_element(
+            false,
+            if matches!(log_source, LogSource::Path(_)) {
+                TABLE_COLUMN_COUNT
+            } else {
+                TABLE_COLUMN_COUNT + 1
+            },
+        );
         let mut row_height = Self::row_height(window, cx);
 
         cx.observe_global_in::(window, move |this, window, cx| {
@@ -1554,11 +1568,14 @@ impl GitGraph {
             context_menu: None,
             table_interaction_state,
             column_widths,
+            column_visibility,
             selected_entry_idx: None,
             hovered_entry_idx: None,
             graph_canvas_bounds: Rc::new(Cell::new(None)),
             selected_commit_diff: None,
             selected_commit_diff_stats: None,
+            selected_commit_message: None,
+            _selected_commit_message_task: None,
             log_source,
             log_order,
             commit_details_split_state: cx.new(|_cx| SplitState::new()),
@@ -2179,13 +2196,16 @@ impl GitGraph {
             return;
         };
 
-        let sha = commit.data.sha.to_string();
-
         let Some(repository) = self.get_repository(cx) else {
             return;
         };
 
-        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
+        let commit_message_handle = commit.data.sha;
+        let diff_handle = commit.data.sha.to_string();
+
+        self.load_selected_commit_message(cx, &commit_message_handle, &repository);
+
+        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(diff_handle));
 
         self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
             if let Ok(Ok(diff)) = diff_receiver.await {
@@ -2203,6 +2223,70 @@ impl GitGraph {
         cx.notify();
     }
 
+    fn load_selected_commit_message(
+        &mut self,
+        cx: &mut Context<'_, Self>,
+        sha: &Oid,
+        repository: &Entity,
+    ) {
+        if self
+            .selected_commit_message
+            .as_ref()
+            .is_some_and(|old| old.sha == *sha)
+        {
+            return;
+        }
+
+        self._selected_commit_message_task = None;
+        match repository.update(cx, |repo, cx| {
+            repo.fetch_commit_data(*sha, true, cx).clone()
+        }) {
+            CommitDataState::Loaded(commit_data) => {
+                self.set_selected_commit_message(cx, commit_data.sha, commit_data.message.clone());
+            }
+            CommitDataState::Loading(Some(receiver)) => {
+                self._selected_commit_message_task = Some(cx.spawn(async move |this, cx| {
+                    if let Ok(commit_data) = receiver.await {
+                        this.update(cx, |this, cx| {
+                            this.set_selected_commit_message(
+                                cx,
+                                commit_data.sha,
+                                commit_data.message.clone(),
+                            );
+                        })
+                        .log_err();
+                    }
+                }))
+            }
+            _ => {
+                debug_panic!(
+                    "Fetched commit data asynchronously, but was not given a listener or cached commit data."
+                );
+            }
+        };
+    }
+
+    fn set_selected_commit_message(
+        &mut self,
+        cx: &mut Context<'_, GitGraph>,
+        sha: Oid,
+        message: SharedString,
+    ) {
+        let languages = self
+            .workspace
+            .read_with(cx, |workspace, cx| {
+                workspace.project().read(cx).languages().clone()
+            })
+            .log_err();
+        self.selected_commit_message = Some(DetailPanelCommitMessage {
+            sha,
+            message: cx.new(|cx| Markdown::new(message, languages, None, cx)),
+            scroll_handle: ScrollHandle::new(),
+        });
+        self._selected_commit_message_task = None;
+        cx.notify();
+    }
+
     fn select_previous_match(&mut self, cx: &mut Context) {
         if self.search_state.matches.is_empty() {
             return;
@@ -2595,14 +2679,14 @@ impl GitGraph {
                     menu
                 })
         });
-        self.set_context_menu(context_menu, position, index, window, cx);
+        self.set_context_menu(context_menu, position, Some(index), window, cx);
     }
 
     fn set_context_menu(
         &mut self,
         context_menu: Entity,
         position: Point,
-        entry_idx: usize,
+        entry_idx: Option,
         window: &mut Window,
         cx: &mut Context,
     ) {
@@ -2633,6 +2717,63 @@ impl GitGraph {
         cx.notify();
     }
 
+    fn toggle_column_visibility(&mut self, col_idx: usize, cx: &mut Context) {
+        if let Some(slot) = self.column_visibility.as_mut_slice().get_mut(col_idx) {
+            *slot = !*slot;
+            // Column visibility is persisted per item, so schedule a workspace serialization.
+            cx.emit(ItemEvent::Edit);
+        }
+    }
+
+    fn deploy_header_context_menu(
+        &mut self,
+        position: Point,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let is_path_history = matches!(self.log_source, LogSource::Path(_));
+        let columns: &[&str] = if is_path_history {
+            &["Description", "Date", "Author", "Commit"]
+        } else {
+            &["Graph", "Description", "Date", "Author", "Commit"]
+        };
+
+        let filter = self.column_visibility.clone();
+        let visible_count = filter
+            .as_slice()
+            .iter()
+            .filter(|filtered| !**filtered)
+            .count();
+
+        let focus_handle = self.focus_handle.clone();
+        let git_graph = cx.entity();
+        let context_menu = ContextMenu::build(window, cx, |mut context_menu, _window, _cx| {
+            context_menu = context_menu.context(focus_handle).header("Columns");
+            for (col_idx, label) in columns.iter().enumerate() {
+                let is_visible = !filter.get(col_idx).copied().unwrap_or(false);
+                // Disable hiding the last remaining visible column.
+                let can_toggle = !is_visible || visible_count > 1;
+                let git_graph = git_graph.clone();
+                context_menu = context_menu.toggleable_entry_disabled_when(
+                    label.to_string(),
+                    is_visible,
+                    !can_toggle,
+                    IconPosition::End,
+                    None,
+                    move |_window, cx| {
+                        git_graph.update(cx, |this, cx| {
+                            this.toggle_column_visibility(col_idx, cx);
+                            cx.notify();
+                        });
+                    },
+                );
+            }
+            context_menu
+        });
+
+        self.set_context_menu(context_menu, position, None, window, cx);
+    }
+
     fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement {
         let color = cx.theme().colors();
         let query_focus_handle = self
@@ -2815,15 +2956,13 @@ impl GitGraph {
             .copied()
             .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
 
-        // todo(git graph): We should use the full commit message here
-        let (author_name, author_email, commit_timestamp, commit_message) = match &data {
+        let (author_name, author_email, commit_timestamp) = match &data {
             CommitDataState::Loaded(data) => (
                 data.author_name.clone(),
                 data.author_email.clone(),
                 Some(data.commit_timestamp),
-                data.subject.clone(),
             ),
-            CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()),
+            CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None),
         };
 
         let date_string = commit_timestamp
@@ -2858,7 +2997,7 @@ impl GitGraph {
             };
 
             CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref())
-                .size(px(40.))
+                .size(px(32.))
                 .render(window, cx)
         };
 
@@ -2896,6 +3035,25 @@ impl GitGraph {
             Rc::default()
         };
 
+        let is_tree_view = self.changed_files_view_mode.is_tree();
+        let view_toggle = IconButton::new("toggle-changed-files-view", IconName::ListTree)
+            .icon_size(IconSize::Small)
+            .toggle_state(self.changed_files_view_mode.is_tree())
+            .tooltip({
+                let tooltip = if is_tree_view {
+                    "Show Flat View"
+                } else {
+                    "Show Tree View"
+                };
+                move |_, cx| Tooltip::for_action(tooltip, &ToggleChangedFilesView, cx)
+            })
+            .on_click(cx.listener(|this, _, _window, cx| {
+                this.changed_files_view_mode = this.changed_files_view_mode.toggled();
+                this.changed_files_scroll_handle
+                    .scroll_to_item(0, ScrollStrategy::Top);
+                cx.notify();
+            }));
+
         v_flex()
             .min_w(px(300.))
             .h_full()
@@ -2917,6 +3075,8 @@ impl GitGraph {
                                     this.selected_entry_idx = None;
                                     this.selected_commit_diff = None;
                                     this.selected_commit_diff_stats = None;
+                                    this.selected_commit_message = None;
+                                    this._selected_commit_message_task = None;
                                     this.changed_files_expanded_dirs.clear();
                                     this._commit_diff_task = None;
                                     cx.notify();
@@ -2928,17 +3088,12 @@ impl GitGraph {
                             .py_1()
                             .w_full()
                             .items_center()
-                            .gap_1()
                             .child(avatar)
+                            .child(Label::new(author_name).mt_1p5())
                             .child(
-                                v_flex()
-                                    .items_center()
-                                    .child(Label::new(author_name))
-                                    .child(
-                                        Label::new(date_string)
-                                            .color(Color::Muted)
-                                            .size(LabelSize::Small),
-                                    ),
+                                Label::new(date_string)
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
                             ),
                     )
                     .children((!ref_names.is_empty()).then(|| {
@@ -3091,74 +3246,45 @@ impl GitGraph {
                     ),
             )
             .child(Divider::horizontal())
-            .child(div().p_2().child(Label::new(commit_message)))
+            .child(self.render_commit_message(window, cx))
             .child(Divider::horizontal())
             .child(
                 v_flex()
                     .min_w_0()
-                    .p_2()
                     .flex_1()
-                    .gap_1()
+                    .overflow_hidden()
                     .child(
                         h_flex()
+                            .p_2()
+                            .pr_3()
+                            .pb_1()
                             .gap_1()
                             .w_full()
                             .justify_between()
-                            .child(
-                                Label::new(format!(
-                                    "{} Changed {}",
-                                    changed_files_count,
-                                    if changed_files_count == 1 {
-                                        "File"
-                                    } else {
-                                        "Files"
-                                    }
-                                ))
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                            )
                             .child(
                                 h_flex()
                                     .gap_1()
-                                    .child(DiffStat::new(
-                                        "commit-diff-stat",
-                                        total_lines_added,
-                                        total_lines_removed,
-                                    ))
                                     .child(
-                                        IconButton::new(
-                                            "toggle-changed-files-view",
-                                            IconName::ListTree,
-                                        )
-                                        .shape(ui::IconButtonShape::Square)
-                                        .icon_size(IconSize::Small)
-                                        .toggle_state(self.changed_files_view_mode.is_tree())
-                                        .tooltip({
-                                            let tooltip = if self.changed_files_view_mode.is_tree()
-                                            {
-                                                "Show Flat View"
+                                        Label::new(format!(
+                                            "{} Changed {}",
+                                            changed_files_count,
+                                            if changed_files_count == 1 {
+                                                "File"
                                             } else {
-                                                "Show Tree View"
-                                            };
-                                            move |_, cx| {
-                                                Tooltip::for_action(
-                                                    tooltip,
-                                                    &ToggleChangedFilesView,
-                                                    cx,
-                                                )
+                                                "Files"
                                             }
-                                        })
-                                        .on_click(
-                                            cx.listener(|this, _, _window, cx| {
-                                                this.changed_files_view_mode =
-                                                    this.changed_files_view_mode.toggled();
-                                                this.changed_files_scroll_handle
-                                                    .scroll_to_item(0, ScrollStrategy::Top);
-                                                cx.notify();
-                                            }),
-                                        ),
-                                    ),
-                            ),
+                                        ))
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                    )
+                                    .child(Divider::vertical())
+                                    .child(view_toggle),
+                            )
+                            .child(DiffStat::new(
+                                "commit-diff-stat",
+                                total_lines_added,
+                                total_lines_removed,
+                            )),
                     )
                     .child(
                         div()
@@ -3167,7 +3293,7 @@ impl GitGraph {
                             .min_h_0()
                             .child({
                                 let flat_entries = changed_file_entries;
-                                let is_tree_view = self.changed_files_view_mode.is_tree();
+
                                 let entry_count = if is_tree_view {
                                     tree_entries.len()
                                 } else {
@@ -3177,6 +3303,8 @@ impl GitGraph {
                                 let repository = repository.downgrade();
                                 let workspace = self.workspace.clone();
                                 let git_graph = cx.weak_entity();
+                                let indent_tree_entries = tree_entries.clone();
+
                                 uniform_list(
                                     "changed-files-list",
                                     entry_count,
@@ -3219,8 +3347,34 @@ impl GitGraph {
                                             .collect()
                                     },
                                 )
+                                .when(is_tree_view, |list| {
+                                    list.with_decoration(
+                                        ui::indent_guides(
+                                            px(TREE_INDENT),
+                                            IndentGuideColors::panel(cx),
+                                        )
+                                        .with_left_offset(
+                                            ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET - px(2.),
+                                        )
+                                        .with_compute_indents_fn(
+                                            cx.entity(),
+                                            move |_, range, _window, _cx| {
+                                                range
+                                                    .map(|ix| match indent_tree_entries.get(ix) {
+                                                        Some(ChangedFileTreeEntry::Directory(
+                                                            entry,
+                                                        )) => entry.depth,
+                                                        Some(ChangedFileTreeEntry::File(entry)) => {
+                                                            entry.depth
+                                                        }
+                                                        None => 0,
+                                                    })
+                                                    .collect()
+                                            },
+                                        ),
+                                    )
+                                })
                                 .size_full()
-                                .ml_neg_1()
                                 .track_scroll(&self.changed_files_scroll_handle)
                             })
                             .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx),
@@ -3231,6 +3385,11 @@ impl GitGraph {
                 h_flex().p_1p5().w_full().child(
                     Button::new("view-commit", "View Commit")
                         .full_width()
+                        .start_icon(
+                            Icon::new(IconName::GitCommit)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .style(ButtonStyle::OutlinedGhost)
                         .on_click(cx.listener(|this, _, window, cx| {
                             this.open_selected_commit_view(window, cx);
@@ -3286,7 +3445,7 @@ impl GitGraph {
 
         let hovered_entry_idx = self.hovered_entry_idx;
         let selected_entry_idx = self.selected_entry_idx;
-        let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx);
+        let context_menu_entry_idx = self.context_menu.as_ref().and_then(|menu| menu.entry_idx);
         let is_focused = self.focus_handle.is_focused(window);
         let graph_canvas_bounds = self.graph_canvas_bounds.clone();
 
@@ -3723,6 +3882,57 @@ impl GitGraph {
             )
             .into_any_element()
     }
+
+    fn render_commit_message(
+        &self,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> impl IntoElement {
+        let Some(DetailPanelCommitMessage {
+            message,
+            scroll_handle,
+            ..
+        }) = self.selected_commit_message.as_ref()
+        else {
+            return Empty.into_any_element();
+        };
+
+        let message_style = editor::hover_markdown_style(window, cx);
+        let rem_size = window.rem_size();
+        let line_height = message_style
+            .base_text_style
+            .line_height_in_pixels(rem_size);
+
+        div()
+            // Using grid over flexbox because the structure of this side
+            // panel prvents taffy from calculating a concrete width correctly,
+            // which causes problems with text reflow when using flexbox.
+            // grid, on the other hand, doesn't appear to give taffy the same
+            // problems.
+            .w_full()
+            .max_h(line_height * 12.)
+            .py_2()
+            .pl_2()
+            .grid()
+            .grid_cols(1)
+            .gap_1()
+            .child(
+                div()
+                    .relative()
+                    .size_full()
+                    .child(
+                        div()
+                            .id("commit-message")
+                            .text_sm()
+                            .size_full()
+                            .overflow_y_scroll()
+                            .track_scroll(scroll_handle)
+                            .child(MarkdownElement::new(message.clone(), message_style)),
+                    )
+                    .vertical_scrollbar_for(scroll_handle, window, cx),
+            )
+            .into_any_element()
+    }
 }
 
 impl Render for GitGraph {
@@ -3752,11 +3962,10 @@ impl Render for GitGraph {
             let label = Label::new(message)
                 .color(Color::Muted)
                 .size(LabelSize::Large);
-            div()
+
+            h_flex()
                 .size_full()
-                .h_flex()
                 .gap_1()
-                .items_center()
                 .justify_center()
                 .child(label)
                 .when(is_loading && error.is_none(), |this| {
@@ -3766,10 +3975,27 @@ impl Render for GitGraph {
             let is_path_history = matches!(self.log_source, LogSource::Path(_));
             let header_resize_info =
                 HeaderResizeInfo::from_redistributable(&self.column_widths, cx);
-            let header_context = TableRenderContext::for_column_widths(
-                Some(self.column_widths.read(cx).widths_to_render()),
-                true,
+
+            let column_filter = self.column_visibility.clone();
+
+            // The graph column (index 0) only exists in the non-path-history layout and is
+            // rendered as a separate canvas outside the table.
+            let graph_visible =
+                is_path_history || !column_filter.get(0usize).copied().unwrap_or(false);
+
+            let table_offset = if is_path_history { 0 } else { 1 };
+            let table_filter = column_filter
+                .as_slice()
+                .get(table_offset..table_offset + TABLE_COLUMN_COUNT)
+                .map(|slice| TableRow::from_vec(slice.to_vec(), TABLE_COLUMN_COUNT))
+                .unwrap_or_else(|| TableRow::from_element(false, TABLE_COLUMN_COUNT));
+            let header_widths = redistribute_hidden_widths(
+                &self.column_widths.read(cx).widths_to_render(),
+                Some(&column_filter),
             );
+            let header_context = TableRenderContext::for_column_widths(Some(header_widths), true)
+                .with_column_filter(Some(column_filter));
+
             let [
                 graph_fraction,
                 description_fraction,
@@ -3781,56 +4007,81 @@ impl Render for GitGraph {
                 description_fraction + date_fraction + author_fraction + commit_fraction;
             let table_width_config = self.table_column_width_config(window, cx);
 
+            let table_collapsed = table_fraction <= f32::EPSILON;
+            let graph_content_width = self.graph_canvas_content_width();
+
             h_flex()
                 .size_full()
                 .child(
-                    div()
+                    v_flex()
                         .flex_1()
                         .min_w_0()
                         .size_full()
                         .flex()
                         .flex_col()
-                        .child(render_table_header(
-                            if !is_path_history {
-                                TableRow::from_vec(
-                                    vec![
-                                        Label::new("Graph")
-                                            .color(Color::Muted)
-                                            .truncate()
-                                            .into_any_element(),
-                                        Label::new("Description")
-                                            .color(Color::Muted)
-                                            .into_any_element(),
-                                        Label::new("Date").color(Color::Muted).into_any_element(),
-                                        Label::new("Author").color(Color::Muted).into_any_element(),
-                                        Label::new("Commit").color(Color::Muted).into_any_element(),
-                                    ],
-                                    5,
+                        .child(
+                            div()
+                                .on_mouse_down(
+                                    MouseButton::Right,
+                                    cx.listener(|this, event: &MouseDownEvent, window, cx| {
+                                        this.deploy_header_context_menu(event.position, window, cx);
+                                        cx.stop_propagation();
+                                    }),
                                 )
-                            } else {
-                                TableRow::from_vec(
-                                    vec![
-                                        Label::new("Description")
-                                            .color(Color::Muted)
-                                            .into_any_element(),
-                                        Label::new("Date").color(Color::Muted).into_any_element(),
-                                        Label::new("Author").color(Color::Muted).into_any_element(),
-                                        Label::new("Commit").color(Color::Muted).into_any_element(),
-                                    ],
-                                    4,
-                                )
-                            },
-                            header_context,
-                            Some(header_resize_info),
-                            Some(self.column_widths.entity_id()),
-                            cx,
-                        ))
+                                .child(render_table_header(
+                                    if !is_path_history {
+                                        TableRow::from_vec(
+                                            vec![
+                                                Label::new("Graph")
+                                                    .color(Color::Muted)
+                                                    .truncate()
+                                                    .into_any_element(),
+                                                Label::new("Description")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Date")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Author")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Commit")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                            ],
+                                            5,
+                                        )
+                                    } else {
+                                        TableRow::from_vec(
+                                            vec![
+                                                Label::new("Description")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Date")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Author")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                                Label::new("Commit")
+                                                    .color(Color::Muted)
+                                                    .into_any_element(),
+                                            ],
+                                            4,
+                                        )
+                                    },
+                                    header_context,
+                                    Some(header_resize_info),
+                                    Some(self.column_widths.entity_id()),
+                                    cx,
+                                )),
+                        )
                         .child({
                             let row_height = Self::row_height(window, cx);
                             let selected_entry_idx = self.selected_entry_idx;
                             let hovered_entry_idx = self.hovered_entry_idx;
                             let context_menu_entry_idx =
-                                self.context_menu.as_ref().map(|menu| menu.entry_idx);
+                                self.context_menu.as_ref().and_then(|menu| menu.entry_idx);
                             let weak_self = cx.weak_entity();
                             let focus_handle = self.focus_handle.clone();
                             let table_focus_handle =
@@ -3865,6 +4116,7 @@ impl Render for GitGraph {
                                 .hide_row_borders()
                                 .hide_row_hover()
                                 .width_config(table_width_config)
+                                .column_filter(table_filter)
                                 .map_row(move |(index, row), window, cx| {
                                     let is_selected = selected_entry_idx == Some(index);
                                     let is_hovered = hovered_entry_idx == Some(index);
@@ -3951,10 +4203,18 @@ impl Render for GitGraph {
                                     .child(
                                         h_flex()
                                             .size_full()
-                                            .when(!is_path_history, |this| {
+                                            .when(!is_path_history && graph_visible, |this| {
                                                 this.child(
                                                     div()
-                                                        .w(DefiniteLength::Fraction(graph_fraction))
+                                                        .map(|this| {
+                                                            if table_collapsed {
+                                                                this.w(graph_content_width)
+                                                            } else {
+                                                                this.w(DefiniteLength::Fraction(
+                                                                    graph_fraction,
+                                                                ))
+                                                            }
+                                                        })
                                                         .h_full()
                                                         .min_w_0()
                                                         .overflow_hidden()
@@ -3966,7 +4226,15 @@ impl Render for GitGraph {
                                                     .tab_index(2)
                                                     .tab_group()
                                                     .tab_stop(false)
-                                                    .w(DefiniteLength::Fraction(table_fraction))
+                                                    .map(|this| {
+                                                        if table_collapsed {
+                                                            this.flex_1()
+                                                        } else {
+                                                            this.w(DefiniteLength::Fraction(
+                                                                table_fraction,
+                                                            ))
+                                                        }
+                                                    })
                                                     .h_full()
                                                     .min_w_0()
                                                     .child(commits_table),
@@ -3974,10 +4242,12 @@ impl Render for GitGraph {
                                     )
                                     .child(render_redistributable_columns_resize_handles(
                                         &self.column_widths,
+                                        Some(&self.column_visibility),
                                         window,
                                         cx,
                                     )),
                                 self.column_widths.clone(),
+                                Some(self.column_visibility.clone()),
                             )
                         }),
                 )
@@ -4169,6 +4439,7 @@ impl workspace::SerializableItem for GitGraph {
             selected_sha,
             search_query,
             search_case_sensitive,
+            hidden_columns,
         )) = db.get_git_graph(item_id, workspace_id).ok().flatten()
         else {
             return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")));
@@ -4181,6 +4452,7 @@ impl workspace::SerializableItem for GitGraph {
             selected_sha,
             search_query,
             search_case_sensitive,
+            hidden_columns,
         };
 
         let window_handle = window.window_handle();
@@ -4223,6 +4495,16 @@ impl workspace::SerializableItem for GitGraph {
                 });
 
                 git_graph.update(cx, |graph, cx| {
+                    if let Some(bits) = state.hidden_columns {
+                        let cols = graph.column_visibility.cols();
+                        let mask = persistence::deserialize_hidden_columns(bits, cols);
+                        // Never restore an all-hidden mask (e.g. from corrupt data); the UI
+                        // guarantees at least one column stays visible.
+                        if mask.iter().any(|is_hidden| !is_hidden) {
+                            graph.column_visibility = TableRow::from_vec(mask, cols);
+                        }
+                    }
+
                     graph.search_state.case_sensitive =
                         state.search_case_sensitive.unwrap_or(false);
 
@@ -4275,6 +4557,9 @@ impl workspace::SerializableItem for GitGraph {
         let log_source_value = persistence::serialize_log_source_value(&self.log_source);
         let log_order = Some(persistence::serialize_log_order(&self.log_order));
         let search_case_sensitive = Some(self.search_state.case_sensitive);
+        let hidden_columns = Some(persistence::serialize_hidden_columns(
+            self.column_visibility.as_slice(),
+        ));
 
         let db = persistence::GitGraphsDb::global(cx);
         Some(cx.background_spawn(async move {
@@ -4288,6 +4573,7 @@ impl workspace::SerializableItem for GitGraph {
                 selected_sha,
                 search_query,
                 search_case_sensitive,
+                hidden_columns,
             )
             .await
         }))
@@ -4343,6 +4629,9 @@ mod persistence {
                 ALTER TABLE git_graphs ADD COLUMN search_query TEXT;
                 ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER;
             ),
+            sql!(
+                ALTER TABLE git_graphs ADD COLUMN hidden_columns INTEGER;
+            ),
         ];
     }
 
@@ -4419,6 +4708,23 @@ mod persistence {
         }
     }
 
+    /// Packs the per-column visibility mask into a bitmask (bit `i` set means column `i` is
+    /// hidden), so it fits in a single integer database column regardless of column count.
+    pub fn serialize_hidden_columns(hidden: &[bool]) -> i32 {
+        hidden.iter().enumerate().fold(
+            0,
+            |bits, (idx, &is_hidden)| {
+                if is_hidden { bits | (1 << idx) } else { bits }
+            },
+        )
+    }
+
+    /// Inverse of [`serialize_hidden_columns`]. Bits beyond `cols` are ignored, and missing
+    /// bits default to visible, so a mask saved with a different column count degrades safely.
+    pub fn deserialize_hidden_columns(bits: i32, cols: usize) -> Vec {
+        (0..cols).map(|idx| bits & (1 << idx) != 0).collect()
+    }
+
     #[derive(Debug, Default, Clone)]
     pub struct SerializedGitGraphState {
         pub log_source_type: Option,
@@ -4427,6 +4733,7 @@ mod persistence {
         pub selected_sha: Option,
         pub search_query: Option,
         pub search_case_sensitive: Option,
+        pub hidden_columns: Option,
     }
 
     impl GitGraphsDb {
@@ -4440,14 +4747,16 @@ mod persistence {
                 log_order: Option,
                 selected_sha: Option,
                 search_query: Option,
-                search_case_sensitive: Option
+                search_case_sensitive: Option,
+                hidden_columns: Option
             ) -> Result<()> {
                 INSERT OR REPLACE INTO git_graphs(
                     item_id, workspace_id, repo_working_path,
                     log_source_type, log_source_value, log_order,
-                    selected_sha, search_query, search_case_sensitive
+                    selected_sha, search_query, search_case_sensitive,
+                    hidden_columns
                 )
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             }
         }
 
@@ -4462,7 +4771,8 @@ mod persistence {
                 Option,
                 Option,
                 Option,
-                Option
+                Option,
+                Option
             )>> {
                 SELECT
                     repo_working_path,
@@ -4471,7 +4781,8 @@ mod persistence {
                     log_order,
                     selected_sha,
                     search_query,
-                    search_case_sensitive
+                    search_case_sensitive,
+                    hidden_columns
                 FROM git_graphs
                 WHERE item_id = ? AND workspace_id = ?
             }
@@ -5760,6 +6071,7 @@ mod tests {
             selected_sha: Some(sha.to_string()),
             search_query: Some("fix bug".to_string()),
             search_case_sensitive: Some(true),
+            hidden_columns: None,
         };
 
         assert_eq!(
@@ -5784,6 +6096,7 @@ mod tests {
             selected_sha: None,
             search_query: None,
             search_case_sensitive: None,
+            hidden_columns: None,
         };
         assert_eq!(
             persistence::deserialize_log_source(&all_state),
@@ -5825,6 +6138,34 @@ mod tests {
         ));
     }
 
+    #[gpui::test]
+    fn test_hidden_columns_bitmask_roundtrip(_cx: &mut TestAppContext) {
+        let mask = [false, true, false, true, false];
+        let bits = persistence::serialize_hidden_columns(&mask);
+        assert_eq!(
+            persistence::deserialize_hidden_columns(bits, mask.len()),
+            mask.to_vec()
+        );
+
+        assert_eq!(persistence::serialize_hidden_columns(&[false; 5]), 0);
+        assert_eq!(
+            persistence::deserialize_hidden_columns(0, 4),
+            vec![false; 4]
+        );
+
+        // A mask saved with more columns than we restore with is truncated safely, and one
+        // saved with fewer columns defaults the extra columns to visible.
+        let bits = persistence::serialize_hidden_columns(&[true, false, true, false, true]);
+        assert_eq!(
+            persistence::deserialize_hidden_columns(bits, 4),
+            vec![true, false, true, false]
+        );
+        assert_eq!(
+            persistence::deserialize_hidden_columns(bits, 6),
+            vec![true, false, true, false, true, false]
+        );
+    }
+
     #[gpui::test]
     async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) {
         init_test(cx);
@@ -5900,6 +6241,9 @@ mod tests {
             .await
             .expect("should create workspace id");
         let db = cx.read(|cx| persistence::GitGraphsDb::global(cx));
+        // Hide the "Date" column (index 2 in the non-path-history layout).
+        let hidden_columns =
+            persistence::serialize_hidden_columns(&[false, false, true, false, false]);
         db.save_git_graph(
             item_id,
             workspace_id,
@@ -5910,6 +6254,7 @@ mod tests {
             selected_sha.clone(),
             Some("some query".to_string()),
             Some(true),
+            Some(hidden_columns),
         )
         .await
         .expect("save should succeed");
@@ -5963,6 +6308,12 @@ mod tests {
                 graph.search_state.case_sensitive, true,
                 "search case sensitivity should be restored"
             );
+
+            assert_eq!(
+                graph.column_visibility.as_slice(),
+                &[false, false, true, false, false],
+                "hidden columns should be restored"
+            );
         });
 
         restored_graph.read_with(&*cx, |graph, cx| {
@@ -6560,6 +6911,106 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            Path::new("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+        let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
+        let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA");
+        fs.set_graph_commits(
+            Path::new("/project/.git"),
+            vec![
+                Arc::new(InitialGraphCommitData {
+                    sha: second_sha,
+                    parents: smallvec![first_sha],
+                    ref_names: vec!["HEAD -> main".into()],
+                }),
+                Arc::new(InitialGraphCommitData {
+                    sha: first_sha,
+                    parents: smallvec![],
+                    ref_names: Vec::new(),
+                }),
+            ],
+        );
+        fs.set_commit_data(
+            Path::new("/project/.git"),
+            [first_sha, second_sha].map(|sha| {
+                (
+                    CommitData {
+                        sha,
+                        parents: smallvec![],
+                        author_name: "Author".into(),
+                        author_email: "author@example.com".into(),
+                        commit_timestamp: 1_700_000_000,
+                        subject: "Commit subject".into(),
+                        message: "Commit message".into(),
+                    },
+                    false,
+                )
+            }),
+        );
+
+        let project = Project::test(fs, [Path::new("/project")], cx).await;
+        cx.run_until_parked();
+
+        let repository = project.read_with(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .expect("should have a repository")
+        });
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+        let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
+        let git_graph = cx.new_window_entity(|window, cx| {
+            GitGraph::new(
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                workspace.downgrade(),
+                None,
+                window,
+                cx,
+            )
+        });
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
+        });
+        cx.run_until_parked();
+
+        git_graph.update(cx, |graph, cx| {
+            graph.select_commit_by_sha(first_sha, cx);
+        });
+        cx.run_until_parked();
+        git_graph.update(cx, |graph, cx| {
+            graph.select_commit_by_sha(second_sha, cx);
+        });
+        cx.run_until_parked();
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            open_or_reuse_graph(
+                workspace,
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                LogSource::All,
+                Some(first_sha.to_string()),
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        git_graph.read_with(&*cx, |graph, _| {
+            assert_eq!(graph.selected_entry_idx, Some(1));
+        });
+    }
+
     #[gpui::test]
     async fn test_git_graph_navigation(cx: &mut TestAppContext) {
         init_test(cx);
@@ -7032,4 +7483,176 @@ mod tests {
         );
         assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None);
     }
+
+    #[gpui::test]
+    async fn test_commit_message_rendered_as_markdown(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            Path::new("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+        let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
+        let commits = vec![Arc::new(InitialGraphCommitData {
+            sha: commit_sha,
+            parents: smallvec![],
+            ref_names: vec!["HEAD -> main".into()],
+        })];
+        fs.set_graph_commits(Path::new("/project/.git"), commits);
+        fs.set_commit_data(
+            Path::new("/project/.git"),
+            [(
+                CommitData {
+                    sha: commit_sha,
+                    parents: smallvec![],
+                    author_name: "Author".into(),
+                    author_email: "author@example.com".into(),
+                    commit_timestamp: 1_700_000_000,
+                    subject: "Fix crash".into(),
+                    message: "Fix crash\n\nThis fixes a crash that occurred when...".into(),
+                },
+                false,
+            )],
+        );
+
+        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+        cx.run_until_parked();
+
+        let repository = project.read_with(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .expect("should have a repository")
+        });
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+        let workspace_weak =
+            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
+
+        let git_graph = cx.new_window_entity(|window, cx| {
+            GitGraph::new(
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                workspace_weak,
+                None,
+                window,
+                cx,
+            )
+        });
+        cx.run_until_parked();
+
+        // Select the commit to trigger loading the commit message
+        git_graph.update_in(cx, |graph, window, cx| {
+            graph.select_first(&menu::SelectFirst, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify the commit message was loaded as markdown
+        git_graph.read_with(&*cx, |graph, app| {
+            let message = graph
+                .selected_commit_message
+                .as_ref()
+                .expect("selected_commit_message should be Some");
+            assert_eq!(message.sha, commit_sha);
+            let source = message.message.read_with(app, |m, _| m.source().to_owned());
+            assert!(source.contains("Fix crash"));
+            assert!(source.contains("This fixes a crash"));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_commit_message_not_reloaded_for_same_sha(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            Path::new("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+        let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
+        let commits = vec![Arc::new(InitialGraphCommitData {
+            sha: commit_sha,
+            parents: smallvec![],
+            ref_names: vec!["HEAD -> main".into()],
+        })];
+        fs.set_graph_commits(Path::new("/project/.git"), commits);
+        fs.set_commit_data(
+            Path::new("/project/.git"),
+            [(
+                CommitData {
+                    sha: commit_sha,
+                    parents: smallvec![],
+                    author_name: "Author".into(),
+                    author_email: "author@example.com".into(),
+                    commit_timestamp: 1_700_000_000,
+                    subject: "Fix crash".into(),
+                    message: "Fix crash\n\nBody text.".into(),
+                },
+                false,
+            )],
+        );
+
+        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+        cx.run_until_parked();
+
+        let repository = project.read_with(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .expect("should have a repository")
+        });
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+        let workspace_weak =
+            multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
+
+        let git_graph = cx.new_window_entity(|window, cx| {
+            GitGraph::new(
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                workspace_weak,
+                None,
+                window,
+                cx,
+            )
+        });
+        cx.run_until_parked();
+
+        // Select the commit to load the message
+        git_graph.update_in(cx, |graph, window, cx| {
+            graph.select_first(&menu::SelectFirst, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify message is loaded
+        let message_entity_id = git_graph.read_with(&*cx, |graph, _| {
+            graph
+                .selected_commit_message
+                .as_ref()
+                .map(|m| m.message.entity_id())
+        });
+        assert!(message_entity_id.is_some());
+
+        // Select the same commit again to trigger the early-return logic
+        git_graph.update_in(cx, |graph, window, cx| {
+            graph.select_first(&menu::SelectFirst, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify the message entity is the same (not replaced)
+        git_graph.read_with(&*cx, |graph, _| {
+            let new_entity_id = graph
+                .selected_commit_message
+                .as_ref()
+                .map(|m| m.message.entity_id());
+            assert_eq!(message_entity_id, new_entity_id);
+        });
+    }
 }
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 0ace54e100e..781dd31b896 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -3,9 +3,11 @@ use crate::commit_modal::CommitModal;
 use crate::commit_tooltip::{CommitAvatar, CommitTooltip};
 use crate::commit_view::CommitView;
 use crate::git_panel_settings::GitPanelScrollbarAccessor;
-use crate::project_diff::{BranchDiff, Diff, ProjectDiff};
+use crate::project_diff::{DeployBranchDiff, Diff, ProjectDiff};
 use crate::remote_output::{self, RemoteAction, SuccessMessage};
 use crate::solo_diff_view::SoloDiffView;
+use crate::staged_diff::StagedDiff;
+use crate::unstaged_diff::UnstagedDiff;
 use crate::{branch_picker, picker_prompt, render_remote_button};
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@@ -13,6 +15,7 @@ use crate::{
 use agent_settings::{AgentSettings, UserAgentsMd};
 use anyhow::Context as _;
 use askpass::AskPassDelegate;
+use client::zed_urls;
 use collections::{BTreeMap, HashMap, HashSet};
 use db::kvp::KeyValueStore;
 use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
@@ -24,8 +27,9 @@ use git::Oid;
 use git::commit::ParsedCommitMessage;
 use git::repository::{
     Branch, CommitData, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions,
-    GitCommitTemplate, GitCommitter, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput,
-    ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, get_git_committer,
+    GitCommitTemplate, GitCommitter, InitialGraphCommitData, LogOrder, LogSource, PushOptions,
+    Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
+    get_git_committer,
 };
 use git::stash::GitStash;
 use git::status::{DiffStat, StageStatus};
@@ -36,10 +40,10 @@ use git::{
     ViewFile, parse_git_remote_url,
 };
 use gpui::{
-    AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent,
-    Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent,
-    Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, TextStyle,
-    UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list,
+    AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent,
+    DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton,
+    MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt,
+    TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, BufferEvent, File};
@@ -77,8 +81,8 @@ use strum::{IntoEnumIterator, VariantNames};
 use theme_settings::ThemeSettings;
 use time::OffsetDateTime;
 use ui::{
-    ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex,
-    IndentGuideColors, KeyBinding, PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes,
+    ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex,
+    IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes,
     Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
 };
 use util::paths::PathStyle;
@@ -89,12 +93,17 @@ use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
 };
-use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
+use zed_actions::{
+    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, git_panel::ToggleFocus,
+};
 
 const GIT_PANEL_KEY: &str = "GitPanel";
 const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 // TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
 const TREE_INDENT: f32 = 16.0;
+const MAX_HISTORY_TAG_CHIPS: usize = 3;
+// Horizontal offset that aligns the tree indent guides with the row icon column.
+const INDENT_GUIDE_LEFT_OFFSET: gpui::Pixels = gpui::px(19.);
 
 actions!(
     git_panel,
@@ -103,8 +112,6 @@ actions!(
         Close,
         /// Toggles the git panel.
         Toggle,
-        /// Toggles focus on the git panel.
-        ToggleFocus,
         /// Opens the git panel menu.
         OpenMenu,
         /// Focuses on the commit message editor.
@@ -135,6 +142,10 @@ actions!(
         ExpandSelectedEntry,
         /// Collapses the selected entry to hide its children.
         CollapseSelectedEntry,
+        /// View unstaged changes
+        ViewUnstagedChanges,
+        /// View staged changes
+        ViewStagedChanges,
         /// Activates the Changes tab.
         ActivateChangesTab,
         /// Activates the History tab.
@@ -395,6 +406,31 @@ enum GitPanelTab {
     History,
 }
 
+#[derive(Debug, PartialEq, Eq, Clone)]
+enum CommitHistory {
+    Loading,
+    /// A non-empty list can still grow on later fetches.
+    /// An empty list means the repository has no commits.
+    Loaded(Rc<[CommitHistoryEntry]>),
+    Error(SharedString),
+}
+
+fn commit_history_from_response(
+    entries: Rc<[CommitHistoryEntry]>,
+    is_loading: bool,
+    error: Option,
+) -> CommitHistory {
+    if !entries.is_empty() {
+        CommitHistory::Loaded(entries)
+    } else if let Some(error) = error {
+        CommitHistory::Error(error)
+    } else if is_loading {
+        CommitHistory::Loading
+    } else {
+        CommitHistory::Loaded(Rc::from([]))
+    }
+}
+
 #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
 enum Section {
     Conflict,
@@ -497,7 +533,7 @@ struct TreeViewState {
     // Length equals the number of visible entries.
     // This is needed because some entries (like collapsed directories) may be hidden.
     logical_indices: Vec,
-    expanded_dirs: HashMap,
+    expanded_dirs: HashMap,
     directory_descendants: HashMap>,
 }
 
@@ -572,8 +608,8 @@ impl TreeViewState {
             let (child_flattened, mut child_statuses) =
                 self.flatten_tree(terminal, section, depth + 1, seen_directories);
             let key = TreeKey { section, path };
-            let expanded = *self.expanded_dirs.get(&key.path).unwrap_or(&true);
-            self.expanded_dirs.entry(key.path.clone()).or_insert(true);
+            let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true);
+            self.expanded_dirs.entry(key.clone()).or_insert(true);
             seen_directories.insert(key.clone());
 
             self.directory_descendants
@@ -755,7 +791,7 @@ pub struct GitPanel {
     generate_commit_message_task: Option>>,
     entries: Vec,
     view_mode: GitPanelViewMode,
-    tree_expanded_dirs: HashMap,
+    tree_expanded_dirs: HashMap,
     entries_indices: HashMap,
     single_staged_entry: Option,
     single_tracked_entry: Option,
@@ -795,14 +831,16 @@ pub struct GitPanel {
     stash_entries: GitStash,
     active_tab: GitPanelTab,
     commit_history_scroll_handle: UniformListScrollHandle,
-    commit_history_shas: Option>,
+    commit_history: CommitHistory,
     focused_history_entry: Option,
     history_keyboard_nav: bool,
     _commit_message_buffer_subscription: Option,
     _repo_subscriptions: Vec,
-
     _settings_subscription: Subscription,
-    git_access: GitAccess,
+    git_access: Option,
+    commit_menu_handle: PopoverMenuHandle,
+    changes_actions_menu_handle: PopoverMenuHandle,
+    remote_action_menu_handle: PopoverMenuHandle,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -811,6 +849,25 @@ struct BulkStaging {
     anchor: RepoPath,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct CommitHistoryEntry {
+    sha: Oid,
+    tag_names: Vec,
+}
+
+impl From<&Arc> for CommitHistoryEntry {
+    fn from(commit: &Arc) -> Self {
+        Self {
+            sha: commit.sha,
+            tag_names: commit
+                .tag_names()
+                .into_iter()
+                .map(|tag_name| SharedString::from(tag_name.to_string()))
+                .collect(),
+        }
+    }
+}
+
 const MAX_PANEL_EDITOR_LINES: usize = 6;
 
 pub(crate) fn commit_message_editor(
@@ -1003,10 +1060,18 @@ impl GitPanel {
                     )
                     | GitStoreEvent::RepositoryAdded
                     | GitStoreEvent::RepositoryRemoved(_)
-                    | GitStoreEvent::GlobalConfigurationUpdated
                     | GitStoreEvent::ActiveRepositoryChanged(_) => {
                         this.schedule_update(window, cx);
                     }
+                    GitStoreEvent::RepositoryUpdated(
+                        _,
+                        RepositoryEvent::GitDirectoryChanged,
+                        true,
+                    )
+                    | GitStoreEvent::GlobalConfigurationUpdated => {
+                        this.git_access = None;
+                        this.schedule_update(window, cx);
+                    }
                     GitStoreEvent::IndexWriteError(error) => {
                         this.workspace
                             .update(cx, |workspace, cx| {
@@ -1068,13 +1133,16 @@ impl GitPanel {
                 stash_entries: Default::default(),
                 active_tab: GitPanelTab::Changes,
                 commit_history_scroll_handle: UniformListScrollHandle::new(),
-                commit_history_shas: None,
+                commit_history: CommitHistory::Loading,
                 focused_history_entry: None,
                 history_keyboard_nav: false,
                 _commit_message_buffer_subscription: None,
                 _repo_subscriptions: Vec::new(),
                 _settings_subscription,
-                git_access: GitAccess::Yes,
+                git_access: None,
+                commit_menu_handle: PopoverMenuHandle::default(),
+                changes_actions_menu_handle: PopoverMenuHandle::default(),
+                remote_action_menu_handle: PopoverMenuHandle::default(),
             };
 
             this.schedule_update(window, cx);
@@ -1127,8 +1195,8 @@ impl GitPanel {
                     path: RepoPath::from_rel_path(dir),
                 };
 
-                if tree_state.expanded_dirs.get(&key.path) == Some(&false) {
-                    tree_state.expanded_dirs.insert(key.path.clone(), true);
+                if tree_state.expanded_dirs.get(&key) == Some(&false) {
+                    tree_state.expanded_dirs.insert(key, true);
                     needs_rebuild = true;
                 }
 
@@ -3868,6 +3936,40 @@ impl GitPanel {
         }
     }
 
+    fn view_staged_changes(
+        &mut self,
+        _: &ViewStagedChanges,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let entry = self
+            .get_selected_entry()
+            .and_then(|entry| entry.status_entry())
+            .cloned();
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                StagedDiff::deploy_at(workspace, entry, window, cx);
+            });
+        }
+    }
+
+    fn view_unstaged_changes(
+        &mut self,
+        _: &ViewUnstagedChanges,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let entry = self
+            .get_selected_entry()
+            .and_then(|entry| entry.status_entry())
+            .cloned();
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                UnstagedDiff::deploy_at(workspace, entry, window, cx);
+            });
+        }
+    }
+
     fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) {
         let current_setting = GitPanelSettings::get_global(cx).tree_view;
         if let Some(workspace) = self.workspace.upgrade() {
@@ -3931,7 +4033,7 @@ impl GitPanel {
 
     fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) {
         if let Some(state) = self.view_mode.tree_state_mut() {
-            let expanded = state.expanded_dirs.entry(key.path.clone()).or_insert(true);
+            let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
             *expanded = !*expanded;
             self.tree_expanded_dirs = state.expanded_dirs.clone();
             self.update_visible_entries(window, cx);
@@ -3993,13 +4095,20 @@ impl GitPanel {
         let new_active_repository = self.project.read(cx).active_repository(cx);
         let active_repository_changed = self.active_repository.as_ref().map(Entity::entity_id)
             != new_active_repository.as_ref().map(Entity::entity_id);
-        if active_repository_changed && self.amend_pending {
-            // Leaving a repository with a pending amend: undo it so the amend
-            // state doesn't carry over to the newly active repository. The
-            // commit editor still holds the previous repository's buffer here
-            // (`reopen_commit_buffer` swaps it asynchronously below), so this
-            // restores the pre-amend draft into that repository's buffer.
-            self.set_amend_pending(false, cx);
+        if active_repository_changed {
+            if self.amend_pending {
+                // Leaving a repository with a pending amend: undo it so the amend
+                // state doesn't carry over to the newly active repository. The
+                // commit editor still holds the previous repository's buffer here
+                // (`reopen_commit_buffer` swaps it asynchronously below), so this
+                // restores the pre-amend draft into that repository's buffer.
+                self.set_amend_pending(false, cx);
+            }
+            self.git_access = None;
+            self._repo_subscriptions.clear();
+            if self.active_tab == GitPanelTab::History {
+                self.set_commit_history(CommitHistory::Loading, cx);
+            }
         }
         self.active_repository = new_active_repository;
         self.reopen_commit_buffer(window, cx);
@@ -4121,7 +4230,6 @@ impl GitPanel {
             .as_ref()
             .and_then(|op| self.entry_by_path(&op.anchor));
 
-        self.active_repository = self.project.read(cx).active_repository(cx);
         self.entries.clear();
         self.entries_indices.clear();
         self.single_staged_entry.take();
@@ -4136,7 +4244,6 @@ impl GitPanel {
         self.tracked_staged_count = 0;
         self.entry_count = 0;
         self.max_width_item_index = None;
-        self.git_access = GitAccess::Yes;
 
         let settings = GitPanelSettings::get_global(cx);
         let sort_by = settings.sort_by;
@@ -4144,28 +4251,30 @@ impl GitPanel {
         let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
 
         if let Some(active_repo) = self.active_repository.as_ref() {
-            let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx));
+            if self.git_access.is_none() {
+                let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx));
 
-            cx.spawn_in(window, async move |git_panel, cx| {
-                // When the user does not own the `.git` folder, the
-                // `GitStore.spawn_local_git_worker` will fail to create the
-                // receiver for Git jobs, so this access check will be
-                // cancelled.
-                //
-                // We assume `GitAccess::No` on cancellation. I believe this is
-                // imprecise, other failures could also cause cancellation, but
-                // the consequence is just showing the "unsafe repo" UI, which
-                // seems acceptable for this edge case.
-                let access = match access.await {
-                    Ok(access) => access,
-                    Err(Canceled) => GitAccess::No,
-                };
+                cx.spawn_in(window, async move |git_panel, cx| {
+                    // When the user does not own the `.git` folder, the
+                    // `GitStore.spawn_local_git_worker` will fail to create the
+                    // receiver for Git jobs, so this access check will be
+                    // cancelled.
+                    //
+                    // We assume `GitAccess::No` on cancellation. I believe this is
+                    // imprecise, other failures could also cause cancellation, but
+                    // the consequence is just showing the "unsafe repo" UI, which
+                    // seems acceptable for this edge case.
+                    let access = match access.await {
+                        Ok(access) => access,
+                        Err(Canceled) => GitAccess::No,
+                    };
 
-                git_panel.update(cx, |this, _cx| {
-                    this.git_access = access;
+                    git_panel.update(cx, |this, _cx| {
+                        this.git_access = Some(access);
+                    })
                 })
-            })
-            .detach_and_log_err(cx);
+                .detach_and_log_err(cx);
+            }
         }
 
         let mut changed_entries = Vec::new();
@@ -4339,13 +4448,9 @@ impl GitPanel {
                     }
                 }
 
-                let seen_directory_paths = seen_directories
-                    .iter()
-                    .map(|directory| directory.path.clone())
-                    .collect::>();
                 tree_state
                     .expanded_dirs
-                    .retain(|path, _| seen_directory_paths.contains(path));
+                    .retain(|key, _| seen_directories.contains(key));
                 self.tree_expanded_dirs = tree_state.expanded_dirs.clone();
                 self.view_mode = GitPanelViewMode::Tree(tree_state);
             }
@@ -4615,7 +4720,14 @@ impl GitPanel {
                         .color(Color::Muted),
                 );
                 match (style, is_push) {
+                    (PushPrLink { label, url }, _) => {
+                        this.action(label, move |_window, cx| cx.open_url(&url))
+                    }
                     (Toast | ToastWithLog { .. }, true) => {
+                        // If we were not able to parse a valid URL from the
+                        // output of a push command, we'll simply dispatch the
+                        // generic `CreatePullRequest` action when the toast
+                        // button is pressed.
                         this.action("Create Pull Request", move |window, cx| {
                             window
                                 .dispatch_action(Box::new(zed_actions::git::CreatePullRequest), cx);
@@ -4774,34 +4886,38 @@ impl GitPanel {
 
         let editor_focus_handle = self.commit_editor.focus_handle(cx);
 
-        Some(
-            IconButton::new("generate-commit-message", IconName::AiEdit)
-                .shape(ui::IconButtonShape::Square)
-                .icon_color(if has_commit_model_configuration_error {
-                    Color::Disabled
+        let button = IconButton::new("generate-commit-message", IconName::AiEdit)
+            .shape(ui::IconButtonShape::Square)
+            .icon_color(if has_commit_model_configuration_error {
+                Color::Disabled
+            } else {
+                Color::Muted
+            })
+            .disabled(!can_commit || has_commit_model_configuration_error)
+            .on_click(cx.listener(move |this, _event, _window, cx| {
+                this.generate_commit_message(cx);
+            }));
+
+        let button = if can_commit && has_commit_model_configuration_error {
+            button.hoverable_tooltip(move |_window, cx| {
+                cx.new(|_| GenerateCommitMessageConfigurationTooltip).into()
+            })
+        } else {
+            button.tooltip(move |_window, cx| {
+                if !can_commit {
+                    Tooltip::simple("No Changes to Commit", cx)
                 } else {
-                    Color::Muted
-                })
-                .tooltip(move |_window, cx| {
-                    if !can_commit {
-                        Tooltip::simple("No Changes to Commit", cx)
-                    } else if has_commit_model_configuration_error {
-                        Tooltip::simple("Configure an LLM provider to generate commit messages", cx)
-                    } else {
-                        Tooltip::for_action_in(
-                            "Generate Commit Message",
-                            &git::GenerateCommitMessage,
-                            &editor_focus_handle,
-                            cx,
-                        )
-                    }
-                })
-                .disabled(!can_commit || has_commit_model_configuration_error)
-                .on_click(cx.listener(move |this, _event, _window, cx| {
-                    this.generate_commit_message(cx);
-                }))
-                .into_any_element(),
-        )
+                    Tooltip::for_action_in(
+                        "Generate Commit Message",
+                        &git::GenerateCommitMessage,
+                        &editor_focus_handle,
+                        cx,
+                    )
+                }
+            })
+        };
+
+        Some(button.into_any_element())
     }
 
     pub(crate) fn render_co_authors(&self, cx: &Context) -> Option {
@@ -4853,21 +4969,14 @@ impl GitPanel {
         keybinding_target: Option,
         cx: &mut Context,
     ) -> impl IntoElement {
+        let menu_open = self.commit_menu_handle.is_deployed();
+
         PopoverMenu::new(id.into())
-            .trigger(
-                ui::ButtonLike::new_rounded_right("commit-split-button-right")
-                    .layer(ui::ElevationIndex::ModalSurface)
-                    .size(ButtonSize::None)
-                    .child(
-                        h_flex()
-                            .px_1()
-                            .h_full()
-                            .justify_center()
-                            .border_l_1()
-                            .border_color(cx.theme().colors().border)
-                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
-                    ),
-            )
+            .trigger(crate::render_split_button_chevron_trigger(
+                "commit-split-button-right",
+                menu_open,
+            ))
+            .with_handle(self.commit_menu_handle.clone())
             .menu({
                 let git_panel = cx.entity();
                 let has_previous_commit = self.head_commit(cx).is_some();
@@ -4909,6 +5018,10 @@ impl GitPanel {
                 }
             })
             .anchor(Anchor::TopRight)
+            .offset(gpui::Point {
+                x: px(0.),
+                y: px(2.),
+            })
     }
 
     pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) {
@@ -4994,19 +5107,16 @@ impl GitPanel {
         let has_unstaged_changes = self.has_unstaged_changes();
         let has_new_changes = self.new_count > 0;
         let has_stash_items = self.stash_entries.entries.len() > 0;
+
         let focus_handle = self.focus_handle.clone();
+        let menu_open = self.changes_actions_menu_handle.is_deployed();
 
         PopoverMenu::new(id.into())
-            .trigger(
-                ui::ButtonLike::new_rounded_right("git-changes-actions-split-button-right")
-                    .layer(ui::ElevationIndex::ModalSurface)
-                    .size(ButtonSize::None)
-                    .child(
-                        div()
-                            .px_1()
-                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
-                    ),
-            )
+            .trigger(crate::render_split_button_chevron_trigger(
+                "changes-actions-split-button-right",
+                menu_open,
+            ))
+            .with_handle(self.changes_actions_menu_handle.clone())
             .menu(move |window, cx| {
                 Some(git_panel_context_menu(
                     has_tracked_changes,
@@ -5020,6 +5130,10 @@ impl GitPanel {
                 ))
             })
             .anchor(Anchor::TopRight)
+            .offset(gpui::Point {
+                x: px(0.),
+                y: px(2.),
+            })
     }
 
     fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement {
@@ -5061,7 +5175,7 @@ impl GitPanel {
         _window: &mut Window,
         cx: &mut Context,
     ) -> Option {
-        if matches!(self.git_access, GitAccess::No) {
+        if matches!(self.git_access, Some(GitAccess::No)) {
             return None;
         }
 
@@ -5144,6 +5258,7 @@ impl GitPanel {
                         focus_handle,
                         true,
                         self.pending_remote_operation,
+                        self.remote_action_menu_handle.clone(),
                     ))
                 })
                 .into_any_element(),
@@ -5164,14 +5279,6 @@ impl GitPanel {
         let branch = active_repository.read(cx).branch.clone();
         let head_commit = active_repository.read(cx).head_commit.clone();
 
-        let footer_size = px(32.);
-        let gap = px(9.0);
-        let max_height = panel_editor_style
-            .text
-            .line_height_in_pixels(window.rem_size())
-            * MAX_PANEL_EDITOR_LINES
-            + gap;
-
         let git_panel = cx.entity();
         let display_name = SharedString::from(Arc::from(
             active_repository
@@ -5195,6 +5302,58 @@ impl GitPanel {
             false
         };
 
+        let vertical_buttons = v_flex()
+            .h_full()
+            .gap_px()
+            .p_1p5()
+            .opacity(0.6)
+            .hover(|s| s.opacity(1.0))
+            .child(
+                IconButton::new("expand-commit-editor", IconName::MaximizeAlt)
+                    .icon_size(IconSize::Small)
+                    .tooltip({
+                        move |_window, cx| {
+                            Tooltip::for_action_in(
+                                "Open Commit Modal",
+                                &git::ExpandCommitEditor,
+                                &editor_focus_handle,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click(cx.listener({
+                        move |_, _, window, cx| {
+                            window.dispatch_action(git::ExpandCommitEditor.boxed_clone(), cx)
+                        }
+                    })),
+            )
+            .child({
+                let (icon, label) = if self.commit_editor_expanded {
+                    (IconName::Minimize, "Collapse Commit Editor")
+                } else {
+                    (IconName::Maximize, "Expand Commit Editor")
+                };
+                let focus_handle = self.focus_handle.clone();
+
+                IconButton::new("fill-commit-editor", icon)
+                    .icon_size(IconSize::Small)
+                    .tooltip({
+                        move |_window, cx| {
+                            Tooltip::for_action_in(
+                                label,
+                                &git::ToggleFillCommitEditor,
+                                &focus_handle,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click(cx.listener({
+                        move |_, _, window, cx| {
+                            window.dispatch_action(git::ToggleFillCommitEditor.boxed_clone(), cx)
+                        }
+                    }))
+            });
+
         let footer = v_flex()
             .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0())
             .child(PanelRepoFooter::new(
@@ -5228,13 +5387,8 @@ impl GitPanel {
             .child(
                 panel_editor_container(window, cx)
                     .id("commit-editor-container")
-                    .cursor_text()
-                    .relative()
                     .w_full()
                     .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0())
-                    .when(!self.commit_editor_expanded, |this| {
-                        this.h(max_height + footer_size)
-                    })
                     .border_t_1()
                     .border_color(if title_exceeds_limit {
                         cx.theme().status().warning_border
@@ -5244,20 +5398,38 @@ impl GitPanel {
                     .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
                         window.focus(&this.commit_editor.focus_handle(cx), cx);
                     }))
+                    .child(
+                        h_flex()
+                            .size_full()
+                            .child(
+                                div()
+                                    .pt_2()
+                                    .px_2()
+                                    .h_full()
+                                    .flex_grow_1()
+                                    .cursor_text()
+                                    .on_action(|&zed_actions::editor::MoveUp, _, cx| {
+                                        cx.stop_propagation();
+                                    })
+                                    .on_action(|&zed_actions::editor::MoveDown, _, cx| {
+                                        cx.stop_propagation();
+                                    })
+                                    .child(EditorElement::new(
+                                        &self.commit_editor,
+                                        panel_editor_style,
+                                    )),
+                            )
+                            .child(vertical_buttons),
+                    )
                     .child(
                         h_flex()
                             .id("commit-footer")
+                            .w_full()
+                            .p_1p5()
                             .border_t_1()
                             .when(editor_is_long, |el| {
                                 el.border_color(cx.theme().colors().border_variant)
                             })
-                            .absolute()
-                            .bottom_0()
-                            .left_0()
-                            .w_full()
-                            .px_2()
-                            .h(footer_size)
-                            .flex_none()
                             .justify_between()
                             .child(
                                 self.render_generate_commit_message_button(cx)
@@ -5269,80 +5441,6 @@ impl GitPanel {
                                     .children(enable_coauthors)
                                     .child(self.render_commit_button(cx)),
                             ),
-                    )
-                    .child(
-                        div()
-                            .when(self.commit_editor_expanded, |this| {
-                                this.flex_1().min_h_0().pb(footer_size)
-                            })
-                            .pr_2p5()
-                            .on_action(|&zed_actions::editor::MoveUp, _, cx| {
-                                cx.stop_propagation();
-                            })
-                            .on_action(|&zed_actions::editor::MoveDown, _, cx| {
-                                cx.stop_propagation();
-                            })
-                            .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
-                    )
-                    .child(
-                        v_flex()
-                            .absolute()
-                            .top_2()
-                            .right_2()
-                            .gap_px()
-                            .opacity(0.6)
-                            .hover(|s| s.opacity(1.0))
-                            .child(
-                                IconButton::new("expand-commit-editor", IconName::MaximizeAlt)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip({
-                                        move |_window, cx| {
-                                            Tooltip::for_action_in(
-                                                "Open Commit Modal",
-                                                &git::ExpandCommitEditor,
-                                                &editor_focus_handle,
-                                                cx,
-                                            )
-                                        }
-                                    })
-                                    .on_click(cx.listener({
-                                        move |_, _, window, cx| {
-                                            window.dispatch_action(
-                                                git::ExpandCommitEditor.boxed_clone(),
-                                                cx,
-                                            )
-                                        }
-                                    })),
-                            )
-                            .child({
-                                let (icon, label) = if self.commit_editor_expanded {
-                                    (IconName::Minimize, "Collapse Commit Editor")
-                                } else {
-                                    (IconName::Maximize, "Expand Commit Editor")
-                                };
-                                let focus_handle = self.focus_handle.clone();
-
-                                IconButton::new("fill-commit-editor", icon)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip({
-                                        move |_window, cx| {
-                                            Tooltip::for_action_in(
-                                                label,
-                                                &git::ToggleFillCommitEditor,
-                                                &focus_handle,
-                                                cx,
-                                            )
-                                        }
-                                    })
-                                    .on_click(cx.listener({
-                                        move |_, _, window, cx| {
-                                            window.dispatch_action(
-                                                git::ToggleFillCommitEditor.boxed_clone(),
-                                                cx,
-                                            )
-                                        }
-                                    }))
-                            }),
                     ),
             );
 
@@ -5362,7 +5460,7 @@ impl GitPanel {
             Color::Default
         };
 
-        div()
+        h_flex()
             .id("commit-wrapper")
             .on_hover(cx.listener(move |this, hovered, _, cx| {
                 this.show_placeholders =
@@ -5370,57 +5468,55 @@ impl GitPanel {
                 cx.notify()
             }))
             .child(SplitButton::new(
-                ButtonLike::new_rounded_left(ElementId::Name(
-                    format!("split-button-left-{}", title).into(),
-                ))
-                .layer(ElevationIndex::ModalSurface)
-                .size(ButtonSize::Compact)
-                .child(
-                    Label::new(title)
-                        .size(LabelSize::Small)
-                        .color(label_color)
-                        .mr_0p5(),
-                )
-                .on_click({
-                    let git_panel = cx.weak_entity();
-                    move |_, window, cx| {
-                        telemetry::event!("Git Committed", source = "Git Panel");
-                        git_panel
-                            .update(cx, |git_panel, cx| {
-                                git_panel.commit_changes(
-                                    CommitOptions {
-                                        amend,
-                                        signoff,
-                                        allow_empty: false,
-                                    },
-                                    window,
-                                    cx,
-                                );
-                            })
-                            .ok();
-                    }
-                })
-                .disabled(!can_commit || self.modal_open)
-                .tooltip({
-                    let handle = commit_tooltip_focus_handle.clone();
-                    move |_window, cx| {
-                        if can_commit {
-                            Tooltip::with_meta_in(
-                                tooltip,
-                                Some(&git::Commit),
-                                format!(
-                                    "git commit{}{}",
-                                    if amend { " --amend" } else { "" },
-                                    if signoff { " --signoff" } else { "" }
-                                ),
-                                &handle.clone(),
-                                cx,
-                            )
-                        } else {
-                            Tooltip::simple(tooltip, cx)
+                ButtonLike::new_rounded_left(format!("split-button-left-{}", title))
+                    .layer(ElevationIndex::ModalSurface)
+                    .size(ButtonSize::Compact)
+                    .disabled(!can_commit || self.modal_open)
+                    .child(
+                        Label::new(title)
+                            .size(LabelSize::Small)
+                            .color(label_color)
+                            .mr_0p5(),
+                    )
+                    .on_click({
+                        let git_panel = cx.weak_entity();
+                        move |_, window, cx| {
+                            telemetry::event!("Git Committed", source = "Git Panel");
+                            git_panel
+                                .update(cx, |git_panel, cx| {
+                                    git_panel.commit_changes(
+                                        CommitOptions {
+                                            amend,
+                                            signoff,
+                                            allow_empty: false,
+                                        },
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .ok();
                         }
-                    }
-                }),
+                    })
+                    .tooltip({
+                        let handle = commit_tooltip_focus_handle.clone();
+                        move |_window, cx| {
+                            if can_commit {
+                                Tooltip::with_meta_in(
+                                    tooltip,
+                                    Some(&git::Commit),
+                                    format!(
+                                        "git commit{}{}",
+                                        if amend { " --amend" } else { "" },
+                                        if signoff { " --signoff" } else { "" }
+                                    ),
+                                    &handle.clone(),
+                                    cx,
+                                )
+                            } else {
+                                Tooltip::simple(tooltip, cx)
+                            }
+                        }
+                    }),
                 self.render_git_commit_menu(
                     ElementId::Name(format!("split-button-right-{}", title).into()),
                     Some(commit_tooltip_focus_handle),
@@ -5618,7 +5714,11 @@ impl GitPanel {
                 GitPanelTab::Changes,
                 ActivateChangesTab.boxed_clone(),
             ))
-            .child(Divider::vertical().color(ui::DividerColor::BorderFaded))
+            .child(
+                Divider::vertical()
+                    .color(ui::DividerColor::BorderFaded)
+                    .h_full(),
+            )
             .child(tab(
                 ElementId::Name("history-tab".into()),
                 active_tab != GitPanelTab::Changes,
@@ -5632,41 +5732,43 @@ impl GitPanel {
     fn render_history_tab(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
         v_flex().flex_1().size_full().overflow_hidden().map(|this| {
             let has_repo = self.active_repository.is_some();
-            let has_commits = self
-                .commit_history_shas
-                .as_ref()
-                .map_or(false, |shas| !shas.is_empty());
-            let is_loading = self.commit_history_shas.is_none() && has_repo;
-            if is_loading {
-                this.child(
-                    h_flex()
-                        .flex_1()
-                        .justify_center()
-                        .child(Label::new("Loading Commit History…").color(Color::Muted)),
-                )
-            } else if !has_repo || !has_commits {
-                this.child(
-                    h_flex()
-                        .flex_1()
-                        .justify_center()
-                        .child(Label::new("No commits yet").color(Color::Muted)),
-                )
-            } else {
-                match self.render_commit_history(window, cx) {
-                    Some(history) => this.child(history),
-                    None => this.child(
-                        h_flex()
-                            .flex_1()
-                            .justify_center()
-                            .child(Label::new("Failed to load commits").color(Color::Muted)),
-                    ),
+            match &self.commit_history {
+                _ if !has_repo => {
+                    this.child(Self::render_history_placeholder("No repository found"))
                 }
+                CommitHistory::Error(_) => this.child(Self::render_history_placeholder(
+                    "Failed to load commit history",
+                )),
+                CommitHistory::Loading => {
+                    this.child(Self::render_history_placeholder("Loading Commit History…"))
+                }
+                CommitHistory::Loaded(entries) if entries.is_empty() => {
+                    this.child(Self::render_history_placeholder("No commits yet"))
+                }
+                CommitHistory::Loaded(_) => match self.render_commit_history(window, cx) {
+                    Some(history) => this.child(history),
+                    None => this.child(Self::render_history_placeholder("Failed to load commits")),
+                },
             }
         })
     }
 
+    fn render_history_placeholder(message: &'static str) -> impl IntoElement {
+        h_flex()
+            .flex_1()
+            .justify_center()
+            .child(Label::new(message).color(Color::Muted))
+    }
+
+    fn commit_history_entries(&self) -> &[CommitHistoryEntry] {
+        match &self.commit_history {
+            CommitHistory::Loaded(entries) => entries,
+            CommitHistory::Loading | CommitHistory::Error(_) => &[],
+        }
+    }
+
     fn select_next_history_entry(&mut self, cx: &mut Context) {
-        let count = self.commit_history_shas.as_ref().map_or(0, Vec::len);
+        let count = self.commit_history_entries().len();
         if count == 0 {
             return;
         }
@@ -5682,7 +5784,7 @@ impl GitPanel {
     }
 
     fn select_previous_history_entry(&mut self, cx: &mut Context) {
-        let count = self.commit_history_shas.as_ref().map_or(0, Vec::len);
+        let count = self.commit_history_entries().len();
         if count == 0 {
             return;
         }
@@ -5701,14 +5803,14 @@ impl GitPanel {
         let Some(index) = self.focused_history_entry else {
             return;
         };
-        let Some(sha) = self.commit_history_shas.as_ref().and_then(|s| s.get(index)) else {
+        let Some(entry) = self.commit_history_entries().get(index) else {
             return;
         };
         let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
         CommitView::open(
-            sha.to_string(),
+            entry.sha.to_string(),
             active_repository.downgrade(),
             self.workspace.clone(),
             None,
@@ -5745,12 +5847,10 @@ impl GitPanel {
             GitPanelTab::History => {
                 self.focus_handle.focus(window, cx);
                 self.load_commit_history(cx);
-                self.focused_history_entry = Some(0);
             }
             GitPanelTab::Changes => {
                 self.focus_handle.focus(window, cx);
-                self.commit_history_shas.take();
-                self.focused_history_entry = None;
+                self.set_commit_history(CommitHistory::Loading, cx);
                 self._repo_subscriptions.clear();
             }
         }
@@ -5762,12 +5862,9 @@ impl GitPanel {
             return;
         };
 
-        let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+        let Some(log_source) = Self::commit_history_log_source(active_repository, cx) else {
             return;
         };
-
-        let branch_name = branch.name().to_string();
-        let log_source = LogSource::Branch(branch_name.into());
         let log_order = LogOrder::DateOrder;
 
         // Kick off the git log fetch so data is ready when the user switches to History.
@@ -5778,47 +5875,78 @@ impl GitPanel {
     }
 
     fn load_commit_history(&mut self, cx: &mut Context) {
-        let Some(active_repository) = self.active_repository.as_ref() else {
+        let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
 
         if self._repo_subscriptions.is_empty() {
             self._repo_subscriptions.push(cx.subscribe(
-                active_repository,
+                &active_repository,
                 |this, _repo, event, cx| {
                     if let RepositoryEvent::GraphEvent(_, _) = event {
                         if this.active_tab == GitPanelTab::History {
-                            this.fetch_commit_history_shas(cx);
+                            this.fetch_commit_history_entries(cx);
                         }
                     }
                 },
             ));
             self._repo_subscriptions
-                .push(cx.observe(active_repository, |_this, _repo, cx| {
+                .push(cx.observe(&active_repository, |_this, _repo, cx| {
                     cx.notify();
                 }));
         }
 
-        self.fetch_commit_history_shas(cx);
+        self.fetch_commit_history_entries(cx);
     }
 
-    fn fetch_commit_history_shas(&mut self, cx: &mut Context) {
-        let Some(active_repository) = self.active_repository.as_ref() else {
+    fn fetch_commit_history_entries(&mut self, cx: &mut Context) {
+        let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
 
-        let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+        let Some(log_source) = Self::commit_history_log_source(&active_repository, cx) else {
+            // No HEAD commit at all (unborn/empty repository).
+            self.set_commit_history(CommitHistory::Loaded(Rc::from([])), cx);
             return;
         };
-
-        let branch_name = branch.name().to_string();
-        let log_source = LogSource::Branch(branch_name.into());
         let log_order = LogOrder::DateOrder;
 
-        self.commit_history_shas = Some(active_repository.update(cx, |repository, cx| {
+        let (entries, is_loading, error) = active_repository.update(cx, |repository, cx| {
             let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx);
-            response.commits.iter().map(|commit| commit.sha).collect()
-        }));
+            let entries: Rc<[CommitHistoryEntry]> = response
+                .commits
+                .iter()
+                .map(CommitHistoryEntry::from)
+                .collect();
+            (entries, response.is_loading, response.error)
+        });
+
+        self.set_commit_history(commit_history_from_response(entries, is_loading, error), cx);
+    }
+
+    fn set_commit_history(&mut self, commit_history: CommitHistory, cx: &mut Context) {
+        let changed = self.commit_history != commit_history;
+        self.commit_history = commit_history;
+        // Keep the focused entry within range as the history grows or clears.
+        let count = self.commit_history_entries().len();
+        let focused = self.focused_history_entry.unwrap_or(0);
+        self.focused_history_entry = (count > 0).then(|| focused.min(count - 1));
+        if changed {
+            cx.notify();
+        }
+    }
+
+    fn commit_history_log_source(
+        active_repository: &Entity,
+        cx: &App,
+    ) -> Option {
+        let repository = active_repository.read(cx);
+        let head_commit = repository.head_commit.as_ref()?;
+        if let Some(branch) = repository.branch.as_ref() {
+            Some(LogSource::Branch(branch.name().to_string().into()))
+        } else {
+            Some(LogSource::Sha(head_commit.sha.as_ref().parse().ok()?))
+        }
     }
 
     fn git_remote(&self, cx: &mut App) -> Option {
@@ -5838,11 +5966,14 @@ impl GitPanel {
         window: &mut Window,
         cx: &mut Context,
     ) -> Option {
-        let shas = self.commit_history_shas.clone()?;
+        let CommitHistory::Loaded(entries) = &self.commit_history else {
+            return None;
+        };
+        let entries = entries.clone();
         let active_repository = self.active_repository.as_ref()?;
         let workspace = self.workspace.clone();
         let repo_weak = active_repository.downgrade();
-        let item_count = shas.len();
+        let item_count = entries.len();
         let commit_history_scroll_handle = self.commit_history_scroll_handle.clone();
         let remote = self.git_remote(cx);
 
@@ -5876,10 +6007,11 @@ impl GitPanel {
 
                             let visible_data: Vec>> = repo_weak
                                 .update(cx, |repository, cx| {
-                                    shas[range.clone()]
+                                    entries[range.clone()]
                                         .iter()
-                                        .map(|sha| {
-                                            match repository.fetch_commit_data(*sha, false, cx) {
+                                        .map(|entry| {
+                                            match repository.fetch_commit_data(entry.sha, false, cx)
+                                            {
                                                 CommitDataState::Loaded(data) => Some(data.clone()),
                                                 CommitDataState::Loading(_) => None,
                                             }
@@ -5888,16 +6020,17 @@ impl GitPanel {
                                 })
                                 .unwrap_or_default();
 
-                            shas[range.clone()]
+                            entries[range.clone()]
                                 .iter()
                                 .zip(visible_data)
                                 .enumerate()
-                                .map(|(ix, (sha, data))| {
+                                .map(|(ix, (entry, data))| {
                                     let index = range.start + ix;
-                                    let sha_string = sha.to_string();
+                                    let sha_string = entry.sha.to_string();
                                     let sha_shared: SharedString = sha_string.clone().into();
                                     let short_sha: SharedString =
                                         sha_string[..7.min(sha_string.len())].to_string().into();
+                                    let tag_names = entry.tag_names.clone();
 
                                     let (subject, author_name, author_email, timestamp): (
                                         SharedString,
@@ -5972,7 +6105,42 @@ impl GitPanel {
                                             h_flex()
                                                 .gap_1()
                                                 .w_full()
+                                                .min_w_0()
                                                 .child(Label::new(subject).truncate())
+                                                .children((!tag_names.is_empty()).then(|| {
+                                                    let hidden_tag_count = tag_names
+                                                        .len()
+                                                        .saturating_sub(MAX_HISTORY_TAG_CHIPS);
+                                                    h_flex()
+                                                        .gap_1()
+                                                        .min_w_0()
+                                                        .children(
+                                                            tag_names
+                                                                .iter()
+                                                                .take(MAX_HISTORY_TAG_CHIPS)
+                                                                .cloned()
+                                                                .map(|tag_name| {
+                                                                    Chip::new(tag_name.clone())
+                                                                        .truncate()
+                                                                        .tooltip(Tooltip::text(
+                                                                            tag_name,
+                                                                        ))
+                                                                }),
+                                                        )
+                                                        .when(hidden_tag_count > 0, |this| {
+                                                            let hidden_tag_names = tag_names
+                                                                [MAX_HISTORY_TAG_CHIPS..]
+                                                                .join(", ");
+                                                            this.child(
+                                                                Chip::new(format!(
+                                                                    "+{hidden_tag_count}"
+                                                                ))
+                                                                .tooltip(Tooltip::text(
+                                                                    hidden_tag_names,
+                                                                )),
+                                                            )
+                                                        })
+                                                }))
                                                 .when(is_unpushed, |this| {
                                                     this.child(
                                                         Icon::new(IconName::ArrowUp)
@@ -6051,7 +6219,7 @@ impl GitPanel {
 
     fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement {
         let content = match (self.git_access, &self.active_repository) {
-            (GitAccess::No, Some(repository)) => self.render_unsafe_repo_ui(repository, cx),
+            (Some(GitAccess::No), Some(repository)) => self.render_unsafe_repo_ui(repository, cx),
             (_, None) => self.render_uninitialized_ui(cx),
             (_, Some(_)) => self.render_no_changes_ui(cx),
         };
@@ -6078,7 +6246,7 @@ impl GitPanel {
                         .style(ButtonStyle::Outlined)
                         .on_click(move |_, _, cx| {
                             cx.defer(move |cx| {
-                                cx.dispatch_action(&BranchDiff);
+                                cx.dispatch_action(&DeployBranchDiff);
                             })
                         }),
                 )
@@ -6336,43 +6504,15 @@ impl GitPanel {
                             }),
                         )
                         .when(is_tree_view, |list| {
-                            let indent_size = px(TREE_INDENT);
                             list.with_decoration(
-                                ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
+                                ui::indent_guides(px(TREE_INDENT), IndentGuideColors::panel(cx))
+                                    .with_left_offset(INDENT_GUIDE_LEFT_OFFSET)
                                     .with_compute_indents_fn(
                                         cx.entity(),
                                         |this, range, _window, _cx| {
                                             this.compute_visible_depths(range)
                                         },
-                                    )
-                                    .with_render_fn(cx.entity(), |_, params, _, _| {
-                                        // Magic number to align the tree item is 3 here
-                                        // because we're using 12px as the left-side padding
-                                        // and 3 makes the alignment work with the bounding box of the icon
-                                        let left_offset = px(TREE_INDENT + 3_f32);
-                                        let indent_size = params.indent_size;
-                                        let item_height = params.item_height;
-
-                                        params
-                                            .indent_guides
-                                            .into_iter()
-                                            .map(|layout| {
-                                                let bounds = Bounds::new(
-                                                    point(
-                                                        layout.offset.x * indent_size + left_offset,
-                                                        layout.offset.y * item_height,
-                                                    ),
-                                                    size(px(1.), layout.length * item_height),
-                                                );
-                                                RenderedIndentGuide {
-                                                    bounds,
-                                                    layout,
-                                                    is_active: false,
-                                                    hitbox: None,
-                                                }
-                                            })
-                                            .collect()
-                                    }),
+                                    ),
                             )
                         })
                         .group("entries")
@@ -6506,6 +6646,9 @@ impl GitPanel {
                 .action(stage_title, ToggleStaged.boxed_clone())
                 .action(restore_title, git::RestoreFile::default().boxed_clone())
                 .separator()
+                .action("Unstaged Changes", ViewUnstagedChanges.boxed_clone())
+                .action("Staged Changes", ViewStagedChanges.boxed_clone())
+                .separator()
                 .action_disabled_when(
                     !is_created,
                     "Add to .gitignore",
@@ -6518,7 +6661,7 @@ impl GitPanel {
                 )
                 .separator()
                 .action("Open Diff", menu::Confirm.boxed_clone())
-                .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone())
+                .action("Open File Diff", menu::SecondaryConfirm.boxed_clone())
                 .action("View File", ViewFile.boxed_clone())
                 .when(!is_created, |context_menu| {
                     context_menu
@@ -7157,6 +7300,53 @@ impl GitPanel {
     }
 }
 
+struct GenerateCommitMessageConfigurationTooltip;
+
+impl Render for GenerateCommitMessageConfigurationTooltip {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        ui::tooltip_container(cx, |container, _cx| {
+            container
+                .gap_1p5()
+                .child(Label::new(
+                    "Configure an LLM provider to generate commit messages.",
+                ))
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .child(
+                            Button::new("configure-commit-message-provider", "Configure Provider")
+                                .style(ButtonStyle::Filled)
+                                .layer(ElevationIndex::ModalSurface)
+                                .label_size(LabelSize::Small)
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(
+                                        zed_actions::OpenSettingsAt {
+                                            path: "llm_providers".to_string(),
+                                            target: None,
+                                        }
+                                        .boxed_clone(),
+                                        cx,
+                                    );
+                                }),
+                        )
+                        .child(
+                            Button::new("llm-provider-docs", "See Docs")
+                                .style(ButtonStyle::OutlinedGhost)
+                                .end_icon(
+                                    Icon::new(IconName::ArrowUpRight)
+                                        .color(Color::Muted)
+                                        .size(IconSize::Small),
+                                )
+                                .label_size(LabelSize::Small)
+                                .on_click(move |_, _, cx| {
+                                    cx.open_url(&zed_urls::llm_provider_docs(cx))
+                                }),
+                        ),
+                )
+        })
+    }
+}
+
 impl GitPanel {
     pub fn selected_file_history_target(&self) -> Option<(Entity, RepoPath)> {
         let entry = self.get_selected_entry()?.status_entry()?;
@@ -7243,6 +7433,8 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::open_diff))
             .on_action(cx.listener(Self::open_solo_diff))
             .on_action(cx.listener(Self::view_file))
+            .on_action(cx.listener(Self::view_unstaged_changes))
+            .on_action(cx.listener(Self::view_staged_changes))
             .on_action(cx.listener(Self::focus_changes_list))
             .on_action(cx.listener(Self::focus_editor))
             .on_action(cx.listener(Self::expand_commit_editor))
@@ -7416,8 +7608,6 @@ impl PanelHeader for GitPanel {}
 pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
     v_flex()
         .size_full()
-        .gap(px(8.))
-        .p_2()
         .bg(cx.theme().colors().editor_background)
 }
 
@@ -7637,9 +7827,9 @@ impl RenderOnce for PanelRepoFooter {
             });
 
         h_flex()
-            .h_9()
             .w_full()
             .px_2()
+            .py_1p5()
             .justify_between()
             .gap_1()
             .child(
@@ -8046,6 +8236,61 @@ mod tests {
         });
     }
 
+    #[test]
+    fn test_tree_view_directory_expansion_is_scoped_to_section() {
+        let entry = |path, status| GitStatusEntry {
+            repo_path: repo_path(path),
+            status,
+            staging: StageStatus::Unstaged,
+            diff_stat: None,
+        };
+        let mut state = TreeViewState::default();
+        let mut seen_directories = HashSet::default();
+
+        state.build_tree_entries(
+            Section::Tracked,
+            vec![entry("src/tracked.rs", StatusCode::Modified.worktree())],
+            &mut seen_directories,
+        );
+        state.build_tree_entries(
+            Section::New,
+            vec![entry("src/new.rs", FileStatus::Untracked)],
+            &mut seen_directories,
+        );
+
+        let tracked_key = TreeKey {
+            section: Section::Tracked,
+            path: repo_path("src"),
+        };
+        let new_key = TreeKey {
+            section: Section::New,
+            path: repo_path("src"),
+        };
+        state.expanded_dirs.insert(tracked_key.clone(), false);
+
+        let tracked_entries = state.build_tree_entries(
+            Section::Tracked,
+            vec![entry("src/tracked.rs", StatusCode::Modified.worktree())],
+            &mut seen_directories,
+        );
+        let new_entries = state.build_tree_entries(
+            Section::New,
+            vec![entry("src/new.rs", FileStatus::Untracked)],
+            &mut seen_directories,
+        );
+
+        assert_eq!(state.expanded_dirs.get(&tracked_key), Some(&false));
+        assert_eq!(state.expanded_dirs.get(&new_key), Some(&true));
+        assert!(matches!(
+            tracked_entries.first(),
+            Some((GitListEntry::Directory(entry), _)) if !entry.expanded
+        ));
+        assert!(matches!(
+            new_entries.first(),
+            Some((GitListEntry::Directory(entry), _)) if entry.expanded
+        ));
+    }
+
     fn register_git_commit_language(project: &Entity, cx: &mut VisualTestContext) {
         project.read_with(cx, |project, _| {
             project.languages().add(Arc::new(language::Language::new(
@@ -8314,6 +8559,47 @@ mod tests {
         assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx);
     }
 
+    async fn history_panel_for_project(
+        fs: Arc,
+        cx: &mut TestAppContext,
+    ) -> Entity {
+        let project = Project::test(fs, [Path::new(path!("/root/project"))], cx).await;
+        let window_handle =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window_handle
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .next()
+                .unwrap()
+                .read(cx)
+                .as_local()
+                .unwrap()
+                .scan_complete()
+        })
+        .await;
+        cx.executor().run_until_parked();
+
+        let panel = workspace.update_in(cx, GitPanel::new);
+        panel.update_in(cx, |panel, window, cx| {
+            panel.activate_history_tab(&ActivateHistoryTab, window, cx);
+        });
+        cx.run_until_parked();
+        panel
+    }
+
+    async fn wait_for_commit_history_to_settle(panel: &Entity, cx: &mut TestAppContext) {
+        cx.condition(panel, |panel, _| {
+            !matches!(panel.commit_history, CommitHistory::Loading)
+        })
+        .await;
+    }
+
     #[test]
     fn test_format_git_error_toast_message_prefers_raw_rpc_message() {
         let rpc_error = RpcError::from_proto(
@@ -8355,6 +8641,154 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_history_tab_stops_loading_for_unborn_branch(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
+            .await;
+
+        let dot_git = Path::new(path!("/root/project/.git"));
+        fs.set_branch_name(dot_git, Some("main"));
+        fs.with_git_state(dot_git, false, |state| {
+            state.refs.remove("HEAD");
+        })
+        .unwrap();
+
+        let panel = history_panel_for_project(fs.clone(), cx).await;
+
+        wait_for_commit_history_to_settle(&panel, cx).await;
+        panel.read_with(cx, |panel, _| {
+            assert_eq!(panel.commit_history, CommitHistory::Loaded(Rc::from([])));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_history_tab_loads_detached_head(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
+            .await;
+
+        let dot_git = Path::new(path!("/root/project/.git"));
+        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
+        fs.with_git_state(dot_git, false, |state| {
+            state.current_branch_name = None;
+            state.refs.insert("HEAD".into(), sha.to_string());
+            state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData {
+                sha,
+                parents: SmallVec::new(),
+                ref_names: Vec::new(),
+            })];
+        })
+        .unwrap();
+
+        let panel = history_panel_for_project(fs.clone(), cx).await;
+
+        wait_for_commit_history_to_settle(&panel, cx).await;
+        panel.read_with(cx, |panel, _| {
+            assert_eq!(
+                panel.commit_history,
+                CommitHistory::Loaded(Rc::from([CommitHistoryEntry {
+                    sha,
+                    tag_names: Vec::new(),
+                }]))
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_history_tab_surfaces_load_error(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
+            .await;
+
+        let dot_git = Path::new(path!("/root/project/.git"));
+        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
+        fs.with_git_state(dot_git, false, |state| {
+            state.current_branch_name = None;
+            state.refs.insert("HEAD".into(), sha.to_string());
+            state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData {
+                sha,
+                parents: SmallVec::new(),
+                ref_names: Vec::new(),
+            })];
+        })
+        .unwrap();
+        fs.set_graph_error(dot_git, Some("simulated git log failure".into()));
+
+        let panel = history_panel_for_project(fs.clone(), cx).await;
+
+        wait_for_commit_history_to_settle(&panel, cx).await;
+        panel.read_with(cx, |panel, _| {
+            assert!(matches!(panel.commit_history, CommitHistory::Error(_)));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_history_tab_without_repository(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree("/root", json!({ "project": {} })).await;
+
+        let panel = history_panel_for_project(fs.clone(), cx).await;
+
+        panel.read_with(cx, |panel, _| {
+            assert_eq!(panel.commit_history, CommitHistory::Loading);
+        });
+    }
+
+    #[test]
+    fn test_commit_history_from_response() {
+        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
+        let error = SharedString::from("git log failed");
+        let entries: Rc<[CommitHistoryEntry]> = Rc::from([CommitHistoryEntry {
+            sha,
+            tag_names: Vec::new(),
+        }]);
+        let no_entries: Rc<[CommitHistoryEntry]> = Rc::from([]);
+
+        // Commits win even while the fetch task still reports `is_loading`.
+        assert_eq!(
+            commit_history_from_response(entries.clone(), true, None),
+            CommitHistory::Loaded(entries.clone())
+        );
+        assert_eq!(
+            commit_history_from_response(entries.clone(), false, None),
+            CommitHistory::Loaded(entries.clone())
+        );
+        // Commits also take precedence over a concurrently reported error.
+        assert_eq!(
+            commit_history_from_response(entries.clone(), true, Some(error.clone())),
+            CommitHistory::Loaded(entries)
+        );
+
+        // With no commits a terminal error beats the loading state.
+        assert_eq!(
+            commit_history_from_response(no_entries.clone(), true, Some(error.clone())),
+            CommitHistory::Error(error.clone())
+        );
+        assert_eq!(
+            commit_history_from_response(no_entries.clone(), false, Some(error.clone())),
+            CommitHistory::Error(error)
+        );
+
+        // When no commits and no error, loading vs. finished-empty hinges on `is_loading`.
+        assert_eq!(
+            commit_history_from_response(no_entries.clone(), true, None),
+            CommitHistory::Loading
+        );
+        assert_eq!(
+            commit_history_from_response(no_entries.clone(), false, None),
+            CommitHistory::Loaded(no_entries)
+        );
+    }
+
     #[gpui::test]
     async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
         init_test(cx);
@@ -9730,7 +10164,7 @@ mod tests {
                 .view_mode
                 .tree_state()
                 .expect("tree view state should exist");
-            assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(false));
+            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
         });
 
         let worktree_id =
@@ -9749,7 +10183,7 @@ mod tests {
                 .view_mode
                 .tree_state()
                 .expect("tree view state should exist");
-            assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(true));
+            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
 
             let selected_ix = panel.selected_entry.expect("selection should be set");
             assert!(state.logical_indices.contains(&selected_ix));
@@ -9858,7 +10292,7 @@ mod tests {
                 .view_mode
                 .tree_state()
                 .expect("tree view state should exist");
-            assert_eq!(state.expanded_dirs.get(&foo_key.path).copied(), Some(false));
+            assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false));
 
             let foo_idx = panel
                 .entries
diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs
index a5dc98c5049..aefd0eff50a 100644
--- a/crates/git_ui/src/git_ui.rs
+++ b/crates/git_ui/src/git_ui.rs
@@ -2,10 +2,6 @@ use anyhow::anyhow;
 use commit_modal::CommitModal;
 use editor::{Editor, actions::DiffClipboardWithSelectionData};
 
-use ui::{
-    Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render,
-    Styled, StyledExt, div, h_flex, rems, v_flex,
-};
 use workspace::{Toast, notifications::NotificationId};
 
 mod blame_ui;
@@ -23,7 +19,7 @@ use menu::{Cancel, Confirm};
 use project::git_store::Repository;
 use project_diff::ProjectDiff;
 use time::OffsetDateTime;
-use ui::prelude::*;
+use ui::{ButtonLike, ContextMenu, ElevationIndex, PopoverMenuHandle, TintColor, prelude::*};
 use workspace::{
     ModalView, OpenMode, Workspace,
     notifications::{DetachAndPromptErr, NotifyTaskExt},
@@ -38,12 +34,14 @@ use crate::{
 };
 
 mod askpass_modal;
+pub mod branch_diff;
 pub mod branch_picker;
 mod commit_modal;
 pub mod commit_tooltip;
 pub mod commit_view;
 mod conflict_view;
 pub mod created_worktrees;
+mod diff_multibuffer;
 pub mod file_diff_view;
 pub mod git_graph;
 pub mod git_panel;
@@ -56,8 +54,10 @@ pub mod project_diff;
 pub(crate) mod remote_output;
 pub mod repository_selector;
 pub mod solo_diff_view;
+pub mod staged_diff;
 pub mod stash_picker;
 pub mod text_diff_view;
+pub mod unstaged_diff;
 pub mod worktree_names;
 pub mod worktree_picker;
 pub mod worktree_service;
@@ -91,6 +91,9 @@ pub fn init(cx: &mut App) {
 
     cx.observe_new(|workspace: &mut Workspace, _, cx| {
         ProjectDiff::register(workspace, cx);
+        staged_diff::StagedDiff::register(workspace, cx);
+        unstaged_diff::UnstagedDiff::register(workspace, cx);
+        branch_diff::BranchDiff::register(workspace, cx);
         CommitModal::register(workspace);
         git_panel::register(workspace);
         repository_selector::register(workspace);
@@ -766,6 +769,7 @@ fn render_remote_button(
     keybinding_target: Option,
     show_fetch_button: bool,
     in_progress_operation: Option,
+    menu_handle: PopoverMenuHandle,
 ) -> Option {
     let id = id.into();
     let upstream = branch.upstream.as_ref();
@@ -778,6 +782,7 @@ fn render_remote_button(
                 keybinding_target,
                 id,
                 in_progress_operation,
+                menu_handle,
             )),
             (0, 0) => None,
             (ahead, 0) => Some(remote_button::render_push_button(
@@ -785,6 +790,7 @@ fn render_remote_button(
                 id,
                 ahead,
                 in_progress_operation,
+                menu_handle,
             )),
             (ahead, behind) => Some(remote_button::render_pull_button(
                 keybinding_target,
@@ -792,6 +798,7 @@ fn render_remote_button(
                 ahead,
                 behind,
                 in_progress_operation,
+                menu_handle,
             )),
         },
         Some(Upstream {
@@ -801,11 +808,13 @@ fn render_remote_button(
             keybinding_target,
             id,
             in_progress_operation,
+            menu_handle,
         )),
         None => Some(remote_button::render_publish_button(
             keybinding_target,
             id,
             in_progress_operation,
+            menu_handle,
         )),
     }
 }
@@ -813,12 +822,16 @@ fn render_remote_button(
 mod remote_button {
     use crate::git_panel::RemoteOperationKind;
     use gpui::{Action, Anchor, AnyView, ClickEvent, FocusHandle};
-    use ui::{CommonAnimationExt, ContextMenu, PopoverMenu, SplitButton, Tooltip, prelude::*};
+    use ui::{
+        ButtonLike, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu,
+        PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
+    };
 
     pub fn render_fetch_button(
         keybinding_target: Option,
         id: SharedString,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> SplitButton {
         split_button(
             id,
@@ -828,6 +841,7 @@ mod remote_button {
             Some(IconName::ArrowCircle),
             keybinding_target.clone(),
             in_progress_operation,
+            menu_handle,
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Fetch), cx);
             },
@@ -848,6 +862,7 @@ mod remote_button {
         id: SharedString,
         ahead: u32,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> SplitButton {
         split_button(
             id,
@@ -857,6 +872,7 @@ mod remote_button {
             None,
             keybinding_target.clone(),
             in_progress_operation,
+            menu_handle,
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -878,6 +894,7 @@ mod remote_button {
         ahead: u32,
         behind: u32,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> SplitButton {
         split_button(
             id,
@@ -887,6 +904,7 @@ mod remote_button {
             None,
             keybinding_target.clone(),
             in_progress_operation,
+            menu_handle,
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Pull), cx);
             },
@@ -906,6 +924,7 @@ mod remote_button {
         keybinding_target: Option,
         id: SharedString,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> SplitButton {
         split_button(
             id,
@@ -915,6 +934,7 @@ mod remote_button {
             Some(IconName::ExpandUp),
             keybinding_target.clone(),
             in_progress_operation,
+            menu_handle,
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -934,6 +954,7 @@ mod remote_button {
         keybinding_target: Option,
         id: SharedString,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> SplitButton {
         split_button(
             id,
@@ -943,6 +964,7 @@ mod remote_button {
             Some(IconName::ExpandUp),
             keybinding_target.clone(),
             in_progress_operation,
+            menu_handle,
             move |_, window, cx| {
                 window.dispatch_action(Box::new(git::Push), cx);
             },
@@ -986,18 +1008,16 @@ mod remote_button {
     fn render_git_action_menu(
         id: impl Into,
         keybinding_target: Option,
+        menu_handle: PopoverMenuHandle,
     ) -> impl IntoElement {
+        let menu_open = menu_handle.is_deployed();
+
         PopoverMenu::new(id.into())
-            .trigger(
-                ui::ButtonLike::new_rounded_right("split-button-right")
-                    .layer(ui::ElevationIndex::ModalSurface)
-                    .size(ui::ButtonSize::None)
-                    .child(
-                        div()
-                            .px_1()
-                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
-                    ),
-            )
+            .trigger(crate::render_split_button_chevron_trigger(
+                "split-button-right",
+                menu_open,
+            ))
+            .with_handle(menu_handle)
             .menu(move |window, cx| {
                 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
                     context_menu
@@ -1015,6 +1035,10 @@ mod remote_button {
                 }))
             })
             .anchor(Anchor::TopRight)
+            .offset(gpui::Point {
+                x: px(0.),
+                y: px(2.),
+            })
     }
 
     #[allow(clippy::too_many_arguments)]
@@ -1026,6 +1050,7 @@ mod remote_button {
         left_icon: Option,
         keybinding_target: Option,
         in_progress_operation: Option,
+        menu_handle: PopoverMenuHandle,
         left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
         tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
     ) -> SplitButton {
@@ -1033,7 +1058,6 @@ mod remote_button {
             h_flex()
                 .ml_neg_px()
                 .h(rems(0.875))
-                .items_center()
                 .overflow_hidden()
                 .px_0p5()
                 .child(
@@ -1046,58 +1070,57 @@ mod remote_button {
         let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
         let is_in_progress = in_progress_operation.is_some();
 
-        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
-            format!("split-button-left-{}", id).into(),
-        ))
-        .layer(ui::ElevationIndex::ModalSurface)
-        .size(ui::ButtonSize::Compact)
-        .disabled(is_in_progress)
-        .when(should_render_counts, |this| {
-            this.child(
-                h_flex()
-                    .ml_neg_0p5()
-                    .when(behind_count > 0, |this| {
-                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
-                            .child(count(behind_count))
-                    })
-                    .when(ahead_count > 0, |this| {
-                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
-                            .child(count(ahead_count))
-                    }),
-            )
-        })
-        .when_some(left_icon, |this, left_icon| {
-            this.map(|this| {
-                if is_in_progress {
-                    this.child(
-                        Icon::new(IconName::LoadCircle)
-                            .size(IconSize::XSmall)
-                            .color(Color::Disabled)
-                            .with_rotate_animation(2),
-                    )
-                } else {
-                    this.child(Icon::new(left_icon).size(IconSize::XSmall))
-                }
+        let left = ButtonLike::new_rounded_left(format!("split-button-left-{}", id))
+            .layer(ElevationIndex::ModalSurface)
+            .size(ButtonSize::Compact)
+            .disabled(is_in_progress)
+            .when(should_render_counts, |this| {
+                this.child(
+                    h_flex()
+                        .ml_neg_0p5()
+                        .when(behind_count > 0, |this| {
+                            this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
+                                .child(count(behind_count))
+                        })
+                        .when(ahead_count > 0, |this| {
+                            this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
+                                .child(count(ahead_count))
+                        }),
+                )
             })
-        })
-        .child(
-            Label::new(left_label)
-                .size(LabelSize::Small)
-                .when(is_in_progress, |this| this.color(Color::Disabled))
-                .mr_0p5(),
-        )
-        .on_click(left_on_click)
-        .tooltip(move |window, cx| {
-            if let Some(operation) = in_progress_operation {
-                Tooltip::simple(in_progress_tooltip(operation), cx)
-            } else {
-                tooltip(window, cx)
-            }
-        });
+            .when_some(left_icon, |this, left_icon| {
+                this.map(|this| {
+                    if is_in_progress {
+                        this.child(
+                            Icon::new(IconName::LoadCircle)
+                                .size(IconSize::XSmall)
+                                .color(Color::Disabled)
+                                .with_rotate_animation(2),
+                        )
+                    } else {
+                        this.child(Icon::new(left_icon).size(IconSize::XSmall))
+                    }
+                })
+            })
+            .child(
+                Label::new(left_label)
+                    .size(LabelSize::Small)
+                    .when(is_in_progress, |this| this.color(Color::Disabled))
+                    .mr_0p5(),
+            )
+            .on_click(left_on_click)
+            .tooltip(move |window, cx| {
+                if let Some(operation) = in_progress_operation {
+                    Tooltip::simple(in_progress_tooltip(operation), cx)
+                } else {
+                    tooltip(window, cx)
+                }
+            });
 
         let right = render_git_action_menu(
-            ElementId::Name(format!("split-button-right-{}", id).into()),
+            format!("split-button-right-{}", id),
             keybinding_target,
+            menu_handle,
         )
         .into_any_element();
 
@@ -1105,6 +1128,25 @@ mod remote_button {
     }
 }
 
+pub(crate) fn render_split_button_chevron_trigger(
+    id: impl Into,
+    menu_open: bool,
+) -> ButtonLike {
+    let chevron_button_size = rems_from_px(20.);
+    let chevron_icon = if menu_open {
+        IconName::ChevronUp
+    } else {
+        IconName::ChevronDown
+    };
+
+    ButtonLike::new_rounded_right(id)
+        .layer(ElevationIndex::ModalSurface)
+        .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+        .width(chevron_button_size)
+        .height(chevron_button_size.into())
+        .child(Icon::new(chevron_icon).size(IconSize::XSmall))
+}
+
 /// A visual representation of a file's Git status.
 #[derive(IntoElement, RegisterComponent)]
 pub struct GitStatusIcon {
diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs
index f8097e68f5c..47bf5a2b727 100644
--- a/crates/git_ui/src/multi_diff_view.rs
+++ b/crates/git_ui/src/multi_diff_view.rs
@@ -1,7 +1,10 @@
 use crate::file_diff_view::build_buffer_diff;
 use anyhow::Result;
 use buffer_diff::BufferDiff;
-use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines};
+use editor::{
+    Editor, EditorEvent, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
+    multibuffer_context_lines,
+};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
     Focusable, Font, IntoElement, Render, SharedString, Task, Window,
@@ -91,7 +94,7 @@ fn register_entry(
             RelPath::new(rel, PathStyle::local())
                 .map(|r| r.into_owned().into())
                 .unwrap_or_else(|_| {
-                    RelPath::new(Path::new("untitled"), PathStyle::Posix)
+                    RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix)
                         .unwrap()
                         .into_owned()
                         .into()
@@ -105,7 +108,7 @@ fn register_entry(
                 .and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok())
                 .map(|r| r.into_owned().into())
                 .unwrap_or_else(|| {
-                    RelPath::new(Path::new("untitled"), PathStyle::Posix)
+                    RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix)
                         .unwrap()
                         .into_owned()
                         .into()
@@ -196,14 +199,9 @@ impl MultiDiffView {
         let editor = cx.new(|cx| {
             let mut editor =
                 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
-            editor.start_temporary_diff_override();
+            editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
             editor.disable_diagnostics(cx);
             editor.set_expand_all_diff_hunks(cx);
-            editor.set_render_diff_hunks_as_unstaged(true, cx);
-            editor.set_render_diff_hunk_controls(
-                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
-                cx,
-            );
             editor
         });
 
diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs
index 89f57e274f8..445eaf1e704 100644
--- a/crates/git_ui/src/project_diff.rs
+++ b/crates/git_ui/src/project_diff.rs
@@ -1,57 +1,41 @@
 use crate::{
-    branch_picker, conflict_view,
+    diff_multibuffer::DiffMultibuffer,
     git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
-    git_panel_settings::GitPanelSettings,
+    staged_diff::StagedDiff,
+    unstaged_diff::UnstagedDiff,
 };
-use agent_settings::AgentSettings;
-use anyhow::{Context as _, Result, anyhow};
-use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
-use collections::HashMap;
+use anyhow::{Context as _, Result};
+use buffer_diff::DiffHunkSecondaryStatus;
 use editor::{
-    Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor,
+    Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate,
     actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
-    multibuffer_context_lines,
-    scroll::Autoscroll,
-};
-use futures_lite::future::yield_now;
-use git::repository::DiffType;
-
-use git::{
-    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath,
-    status::FileStatus,
 };
+use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext};
 use gpui::{
-    Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
-    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
+    Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    Subscription, Task, WeakEntity, actions,
 };
-use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
-use multi_buffer::{MultiBuffer, PathKey};
+use language::Capability;
+use multi_buffer::MultiBuffer;
 use project::{
-    ConflictSet, Project, ProjectPath,
+    Project, ProjectPath,
     git_store::{
         Repository,
-        branch_diff::{self, BranchDiffEvent, DiffBase},
+        diff_buffer_list::{self, DiffBase},
     },
 };
-use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore};
+use schemars::JsonSchema;
+use serde::Deserialize;
 use std::any::{Any, TypeId};
-use std::collections::BTreeMap;
 use std::sync::Arc;
-use theme::ActiveTheme;
-use ui::{
-    CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*,
-    vertical_divider,
-};
-use util::{ResultExt as _, rel_path::RelPath};
+use ui::{DiffStat, Divider, Tooltip, prelude::*};
 use workspace::{
-    CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
-    ToolbarItemView, Workspace,
+    ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    Workspace,
     item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
-    notifications::NotifyTaskExt,
     searchable::SearchableItemHandle,
 };
-use zed_actions::agent::ReviewBranchDiff;
-use ztracing::instrument;
+use zed_actions::git as git_actions;
 
 actions!(
     git,
@@ -60,9 +44,6 @@ actions!(
         Diff,
         /// Adds files to the git staging area.
         Add,
-        /// Shows the diff between the working directory and your default
-        /// branch (typically main or master).
-        BranchDiff,
         /// Opens a new agent thread with the branch diff for review.
         ReviewDiff,
         LeaderAndFollower,
@@ -71,32 +52,37 @@ actions!(
     ]
 );
 
-struct BufferSubscriptions {
-    _diff: Entity,
-    _diff_subscription: Subscription,
-    _conflict_set: Entity,
-    _conflict_set_subscription: Subscription,
-}
+/// Shows the diff between the working directory and your default
+/// branch (typically main or master).
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = git, name = "BranchDiff")]
+pub(crate) struct DeployBranchDiff;
 
 pub struct ProjectDiff {
     project: Entity,
-    multibuffer: Entity,
-    branch_diff: Entity,
-    editor: Entity,
-    buffer_subscriptions: HashMap, BufferSubscriptions>,
     workspace: WeakEntity,
-    focus_handle: FocusHandle,
-    pending_scroll: Option,
-    review_comment_count: usize,
-    _task: Task>,
-    _subscription: Subscription,
+    diff: Entity,
+    _diff_observation: Subscription,
 }
 
 impl ProjectDiff {
     pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) {
         workspace.register_action(Self::deploy);
-        workspace.register_action(Self::deploy_branch_diff);
-        workspace.register_action(Self::compare_with_branch);
+        workspace.register_action(
+            |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| {
+                Self::deploy_at(workspace, None, window, cx);
+            },
+        );
+        workspace.register_action(
+            |workspace, _: &git_actions::ViewUnstagedChanges, window, cx| {
+                UnstagedDiff::deploy_at(workspace, None, window, cx);
+            },
+        );
+        workspace.register_action(
+            |workspace, _: &git_actions::ViewStagedChanges, window, cx| {
+                StagedDiff::deploy_at(workspace, None, window, cx);
+            },
+        );
         workspace.register_action(|workspace, _: &Add, window, cx| {
             Self::deploy(workspace, &Diff, window, cx);
         });
@@ -112,216 +98,6 @@ impl ProjectDiff {
         Self::deploy_at(workspace, None, window, cx)
     }
 
-    fn deploy_branch_diff(
-        workspace: &mut Workspace,
-        _: &BranchDiff,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        telemetry::event!("Git Branch Diff Opened");
-        let project = workspace.project().clone();
-        let Some(intended_repo) = project.read(cx).active_repository(cx) else {
-            let workspace = cx.entity().downgrade();
-            window
-                .spawn(cx, async |_cx| {
-                    let result: Result<()> = Err(anyhow!("No active repository"));
-                    result
-                })
-                .detach_and_notify_err(workspace, window, cx);
-            return;
-        };
-
-        let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
-        let workspace = cx.entity();
-        let workspace_weak = workspace.downgrade();
-        window
-            .spawn(cx, async move |cx| {
-                let base_ref = default_branch
-                    .await??
-                    .context("Could not determine default branch")?;
-
-                workspace.update_in(cx, |workspace, window, cx| {
-                    Self::deploy_branch_diff_with_base_ref(
-                        workspace,
-                        project,
-                        intended_repo,
-                        base_ref,
-                        window,
-                        cx,
-                    );
-                })?;
-
-                anyhow::Ok(())
-            })
-            .detach_and_notify_err(workspace_weak, window, cx);
-    }
-
-    fn compare_with_branch(
-        workspace: &mut Workspace,
-        _: &CompareWithBranch,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let project = workspace.project().clone();
-        let Some(repository) = project.read(cx).active_repository(cx) else {
-            let workspace = cx.entity().downgrade();
-            window
-                .spawn(cx, async |_cx| {
-                    let result: Result<()> = Err(anyhow!("No active repository"));
-                    result
-                })
-                .detach_and_notify_err(workspace, window, cx);
-            return;
-        };
-        let selected_branch = workspace.active_item_as::(cx).and_then(|item| {
-            match item.read(cx).diff_base(cx) {
-                DiffBase::Merge { base_ref } => Some(base_ref.clone()),
-                DiffBase::Head => None,
-            }
-        });
-        let workspace_handle = workspace.weak_handle();
-        let on_select = Arc::new({
-            let repository = repository.clone();
-            let workspace = workspace_handle.clone();
-            move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
-                let base_ref: SharedString = branch.name().to_owned().into();
-                workspace
-                    .update(cx, |workspace, cx| {
-                        Self::deploy_branch_diff_with_base_ref(
-                            workspace,
-                            project.clone(),
-                            repository.clone(),
-                            base_ref,
-                            window,
-                            cx,
-                        );
-                    })
-                    .ok();
-            }
-        });
-
-        workspace.toggle_modal(window, cx, |window, cx| {
-            branch_picker::select_modal(
-                workspace_handle,
-                Some(repository),
-                selected_branch,
-                on_select,
-                window,
-                cx,
-            )
-        });
-    }
-
-    fn deploy_branch_diff_with_base_ref(
-        workspace: &mut Workspace,
-        project: Entity,
-        intended_repo: Entity,
-        base_ref: SharedString,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let existing = workspace.items_of_type::(cx).find(|item| {
-            let item = item.read(cx);
-            matches!(
-                item.diff_base(cx),
-                DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
-            )
-        });
-        if let Some(existing) = existing {
-            workspace.activate_item(&existing, true, true, window, cx);
-
-            let needs_switch = existing
-                .read(cx)
-                .branch_diff
-                .read(cx)
-                .repo()
-                .map_or(true, |current| {
-                    current.read(cx).id != intended_repo.read(cx).id
-                });
-
-            if needs_switch {
-                existing.update(cx, |project_diff, cx| {
-                    project_diff.branch_diff.update(cx, |branch_diff, cx| {
-                        branch_diff.set_repo(Some(intended_repo), cx);
-                    });
-                });
-            }
-
-            return;
-        }
-
-        let workspace = cx.entity();
-        let workspace_weak = workspace.downgrade();
-        window
-            .spawn(cx, async move |cx| {
-                let this = cx
-                    .update(|window, cx| {
-                        Self::new_with_branch_base(
-                            project,
-                            workspace.clone(),
-                            base_ref,
-                            intended_repo,
-                            window,
-                            cx,
-                        )
-                    })?
-                    .await?;
-                workspace
-                    .update_in(cx, |workspace, window, cx| {
-                        workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
-                    })
-                    .ok();
-                anyhow::Ok(())
-            })
-            .detach_and_notify_err(workspace_weak, window, cx);
-    }
-
-    fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) {
-        let diff_base = self.diff_base(cx).clone();
-        let DiffBase::Merge { base_ref } = diff_base else {
-            return;
-        };
-
-        let Some(repo) = self.branch_diff.read(cx).repo().cloned() else {
-            return;
-        };
-
-        let diff_receiver = repo.update(cx, |repo, cx| {
-            repo.diff(
-                DiffType::MergeBase {
-                    base_ref: base_ref.clone(),
-                },
-                cx,
-            )
-        });
-
-        let workspace = self.workspace.clone();
-
-        window
-            .spawn(cx, {
-                let workspace = workspace.clone();
-                async move |cx| {
-                    let diff_text = diff_receiver.await??;
-
-                    if let Some(workspace) = workspace.upgrade() {
-                        workspace.update_in(cx, |_workspace, window, cx| {
-                            window.dispatch_action(
-                                ReviewBranchDiff {
-                                    diff_text: diff_text.into(),
-                                    base_ref,
-                                }
-                                .boxed_clone(),
-                                cx,
-                            );
-                        })?;
-                    }
-
-                    anyhow::Ok(())
-                }
-            })
-            .detach_and_notify_err(workspace, window, cx);
-    }
-
     pub fn deploy_at(
         workspace: &mut Workspace,
         entry: Option,
@@ -338,9 +114,7 @@ impl ProjectDiff {
         );
         let intended_repo = workspace.project().read(cx).active_repository(cx);
 
-        let existing = workspace
-            .items_of_type::(cx)
-            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
+        let existing = workspace.items_of_type::(cx).next();
         let project_diff = if let Some(existing) = existing {
             existing.update(cx, |project_diff, cx| {
                 project_diff.move_to_beginning(window, cx);
@@ -365,15 +139,11 @@ impl ProjectDiff {
         if let Some(intended) = &intended_repo {
             let needs_switch = project_diff
                 .read(cx)
-                .branch_diff
-                .read(cx)
-                .repo()
+                .repo(cx)
                 .map_or(true, |current| current.read(cx).id != intended.read(cx).id);
             if needs_switch {
                 project_diff.update(cx, |project_diff, cx| {
-                    project_diff.branch_diff.update(cx, |branch_diff, cx| {
-                        branch_diff.set_repo(Some(intended.clone()), cx);
-                    });
+                    project_diff.set_repo(Some(intended.clone()), cx);
                 });
             }
         }
@@ -392,9 +162,7 @@ impl ProjectDiff {
         cx: &mut Context,
     ) {
         telemetry::event!("Git Diff Opened", source = "Agent Panel");
-        let existing = workspace
-            .items_of_type::(cx)
-            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
+        let existing = workspace.items_of_type::(cx).next();
         let project_diff = if let Some(existing) = existing {
             workspace.activate_item(&existing, true, true, window, cx);
             existing
@@ -417,71 +185,7 @@ impl ProjectDiff {
     }
 
     pub fn autoscroll(&self, cx: &mut Context) {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |editor, cx| {
-                editor.request_autoscroll(Autoscroll::fit(), cx);
-            })
-        })
-    }
-
-    #[cfg(test)]
-    #[allow(dead_code)]
-    fn new_with_default_branch(
-        project: Entity,
-        workspace: Entity,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Task>> {
-        let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
-            return Task::ready(Err(anyhow!("No active repository")));
-        };
-        let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
-        window.spawn(cx, async move |cx| {
-            let main_branch = main_branch
-                .await??
-                .context("Could not determine default branch")?;
-
-            let branch_diff = cx.new_window_entity(|window, cx| {
-                let mut branch_diff = branch_diff::BranchDiff::new(
-                    DiffBase::Merge {
-                        base_ref: main_branch,
-                    },
-                    project.clone(),
-                    window,
-                    cx,
-                );
-                branch_diff.set_repo(Some(repo.clone()), cx);
-                branch_diff
-            })?;
-            cx.new_window_entity(|window, cx| {
-                Self::new_impl(branch_diff, project, workspace, window, cx)
-            })
-        })
-    }
-
-    fn new_with_branch_base(
-        project: Entity,
-        workspace: Entity,
-        base_ref: SharedString,
-        repo: Entity,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Task>> {
-        window.spawn(cx, async move |cx| {
-            let branch_diff = cx.new_window_entity(|window, cx| {
-                let mut branch_diff = branch_diff::BranchDiff::new(
-                    DiffBase::Merge { base_ref },
-                    project.clone(),
-                    window,
-                    cx,
-                );
-                branch_diff.set_repo(Some(repo.clone()), cx);
-                branch_diff
-            })?;
-            cx.new_window_entity(|window, cx| {
-                Self::new_impl(branch_diff, project, workspace, window, cx)
-            })
-        })
+        self.diff.update(cx, |diff, cx| diff.autoscroll(cx));
     }
 
     fn new(
@@ -490,142 +194,69 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) -> Self {
-        let branch_diff =
-            cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
+        let branch_diff = cx.new(|cx| {
+            diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx)
+        });
         Self::new_impl(branch_diff, project, workspace, window, cx)
     }
 
     fn new_impl(
-        branch_diff: Entity,
+        branch_diff: Entity,
         project: Entity,
         workspace: Entity,
         window: &mut Window,
         cx: &mut Context,
     ) -> Self {
-        let focus_handle = cx.focus_handle();
-        let multibuffer = cx.new(|cx| {
-            let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
-            multibuffer.set_all_diff_hunks_expanded(cx);
-            multibuffer
-        });
-
-        let editor = cx.new(|cx| {
-            let diff_display_editor = SplittableEditor::new(
-                EditorSettings::get_global(cx).diff_view_style,
-                multibuffer.clone(),
+        let workspace_handle = workspace.downgrade();
+        let diff = cx.new(|cx| {
+            DiffMultibuffer::new(
+                branch_diff,
+                Capability::ReadWrite,
+                "No uncommitted changes",
+                move |editor, cx| {
+                    editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx);
+                    editor.rhs_editor().update(cx, |rhs_editor, _cx| {
+                        rhs_editor.set_read_only(false);
+                        rhs_editor.register_addon(GitPanelAddon {
+                            workspace: workspace_handle,
+                        });
+                    });
+                },
                 project.clone(),
                 workspace.clone(),
                 window,
                 cx,
-            );
-            match branch_diff.read(cx).diff_base() {
-                DiffBase::Head => {}
-                DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx),
-            }
-            diff_display_editor.rhs_editor().update(cx, |editor, cx| {
-                editor.set_show_diff_review_button(true, cx);
-
-                match branch_diff.read(cx).diff_base() {
-                    DiffBase::Head => {
-                        editor.register_addon(GitPanelAddon {
-                            workspace: workspace.downgrade(),
-                        });
-                    }
-                    DiffBase::Merge { .. } => {
-                        editor.register_addon(BranchDiffAddon {
-                            branch_diff: branch_diff.clone(),
-                        });
-                    }
-                }
-            });
-            diff_display_editor
-        });
-        let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
-
-        let primary_editor = editor.read(cx).rhs_editor().clone();
-        let review_comment_subscription =
-            cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
-                if let EditorEvent::ReviewCommentsChanged { total_count } = event {
-                    this.review_comment_count = *total_count;
-                    cx.notify();
-                }
-            });
-
-        let branch_diff_subscription = cx.subscribe_in(
-            &branch_diff,
-            window,
-            move |this, _git_store, event, window, cx| match event {
-                BranchDiffEvent::FileListChanged => {
-                    this._task = window.spawn(cx, {
-                        let this = cx.weak_entity();
-                        async |cx| Self::refresh(this, cx).await
-                    })
-                }
-                BranchDiffEvent::DiffBaseChanged => {
-                    this.pending_scroll.take();
-                    this._task = window.spawn(cx, {
-                        let this = cx.weak_entity();
-                        async |cx| Self::refresh(this, cx).await
-                    })
-                }
-            },
-        );
-
-        let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by;
-        let mut was_group_by = GitPanelSettings::get_global(cx).group_by;
-        let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
-        let mut was_collapse_untracked_diff =
-            GitPanelSettings::get_global(cx).collapse_untracked_diff;
-        cx.observe_global_in::(window, move |this, window, cx| {
-            let settings = GitPanelSettings::get_global(cx);
-            let sort_by = settings.sort_by;
-            let group_by = settings.group_by;
-            let tree_view = settings.tree_view;
-            let is_collapse_untracked_diff = settings.collapse_untracked_diff;
-            if sort_by != was_sort_by
-                || group_by != was_group_by
-                || tree_view != was_tree_view
-                || is_collapse_untracked_diff != was_collapse_untracked_diff
-            {
-                this._task = {
-                    window.spawn(cx, {
-                        let this = cx.weak_entity();
-                        async |cx| Self::refresh(this, cx).await
-                    })
-                }
-            }
-            was_sort_by = sort_by;
-            was_group_by = group_by;
-            was_tree_view = tree_view;
-            was_collapse_untracked_diff = is_collapse_untracked_diff;
-        })
-        .detach();
-
-        let task = window.spawn(cx, {
-            let this = cx.weak_entity();
-            async |cx| Self::refresh(this, cx).await
+            )
         });
+        Self::from_diff(diff, project, workspace, cx)
+    }
 
+    fn from_diff(
+        diff: Entity,
+        project: Entity,
+        workspace: Entity,
+        cx: &mut Context,
+    ) -> Self {
+        let observation = cx.observe(&diff, |_, _, cx| cx.notify());
         Self {
             project,
             workspace: workspace.downgrade(),
-            branch_diff,
-            focus_handle,
-            editor,
-            multibuffer,
-            buffer_subscriptions: Default::default(),
-            pending_scroll: None,
-            review_comment_count: 0,
-            _task: task,
-            _subscription: Subscription::join(
-                branch_diff_subscription,
-                Subscription::join(editor_subscription, review_comment_subscription),
-            ),
+            diff,
+            _diff_observation: observation,
         }
     }
 
     pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
-        self.branch_diff.read(cx).diff_base()
+        self.diff.read(cx).diff_base(cx)
+    }
+
+    pub(crate) fn repo(&self, cx: &App) -> Option> {
+        self.diff.read(cx).repo(cx)
+    }
+
+    pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.set_repo(repo.clone(), cx));
     }
 
     pub fn move_to_entry(
@@ -634,13 +265,8 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
-            return;
-        };
-        let repo = git_repo.read(cx);
-        let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx);
-
-        self.move_to_path(path_key, window, cx)
+        self.diff
+            .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
     }
 
     pub fn move_to_project_path(
@@ -649,91 +275,42 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
-            return;
-        };
-        let Some(repo_path) = git_repo
-            .read(cx)
-            .project_path_to_repo_path(project_path, cx)
-        else {
-            return;
-        };
-        let status = git_repo
-            .read(cx)
-            .status_for_path(&repo_path)
-            .map(|entry| entry.status)
-            .unwrap_or(FileStatus::Untracked);
-        let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx);
-        self.move_to_path(path_key, window, cx)
-    }
-
-    fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |editor, cx| {
-                editor.change_selections(Default::default(), window, cx, |s| {
-                    s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
-                });
-            });
+        self.diff.update(cx, |diff, cx| {
+            diff.move_to_project_path(project_path, window, cx)
         });
     }
 
-    fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) {
-        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
-            self.editor.update(cx, |editor, cx| {
-                editor.rhs_editor().update(cx, |editor, cx| {
-                    editor.change_selections(
-                        SelectionEffects::scroll(Autoscroll::focused()),
-                        window,
-                        cx,
-                        |s| {
-                            s.select_ranges([position..position]);
-                        },
-                    )
-                })
-            });
-        } else {
-            self.pending_scroll = Some(path_key);
-        }
+    fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.move_to_beginning(window, cx));
     }
 
     pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
-        self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
+        self.diff.read(cx).calculate_changed_lines(cx)
     }
 
     /// Returns the total count of review comments across all hunks/files.
-    pub fn total_review_comment_count(&self) -> usize {
-        self.review_comment_count
+    pub fn total_review_comment_count(&self, cx: &App) -> usize {
+        self.diff.read(cx).total_review_comment_count()
     }
 
-    /// Returns a reference to the splittable editor.
-    pub fn editor(&self) -> &Entity {
-        &self.editor
+    /// Returns the splittable editor of the currently-shown diff view.
+    pub fn editor(&self, cx: &App) -> Entity {
+        self.diff.read(cx).editor().clone()
+    }
+
+    /// Returns the multibuffer of the currently-shown diff view.
+    pub fn multibuffer(&self, cx: &App) -> Entity {
+        self.diff.read(cx).multibuffer().clone()
     }
 
     fn button_states(&self, cx: &App) -> ButtonStates {
-        let editor = self.editor.read(cx).rhs_editor().read(cx);
-        let snapshot = self.multibuffer.read(cx).snapshot(cx);
+        let diff = self.diff.read(cx);
+        let editor = diff.editor().read(cx).rhs_editor().clone();
+        let editor = editor.read(cx);
+        let snapshot = diff.multibuffer().read(cx).snapshot(cx);
         let prev_next = snapshot.diff_hunks().nth(1).is_some();
-        let mut selection = true;
-
-        let mut ranges = editor
-            .selections
-            .disjoint_anchor_ranges()
-            .collect::>();
-        if !ranges.iter().any(|range| range.start != range.end) {
-            selection = false;
-            let anchor = editor.selections.newest_anchor().head();
-            if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
-                && let Some(range) = snapshot
-                    .anchor_in_buffer(excerpt_range.context.start)
-                    .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
-                    .map(|(start, end)| start..end)
-            {
-                ranges = vec![range];
-            } else {
-                ranges = Vec::default();
-            };
-        }
+        let (selection, ranges) = diff.selected_ranges(cx);
         let mut has_staged_hunks = false;
         let mut has_unstaged_hunks = false;
         for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
@@ -774,448 +351,31 @@ impl ProjectDiff {
         }
     }
 
-    fn handle_editor_event(
-        &mut self,
-        editor: &Entity,
-        event: &EditorEvent,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        match event {
-            EditorEvent::SelectionsChanged { local: true } => {
-                let Some(project_path) = self.active_project_path(cx) else {
-                    return;
-                };
-                self.workspace
-                    .update(cx, |workspace, cx| {
-                        if let Some(git_panel) = workspace.panel::(cx) {
-                            git_panel.update(cx, |git_panel, cx| {
-                                git_panel.select_entry_by_path(project_path, window, cx)
-                            })
-                        }
-                    })
-                    .ok();
-            }
-            EditorEvent::Saved => {
-                self._task =
-                    cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await);
-            }
-            _ => {}
-        }
-        if editor.focus_handle(cx).contains_focused(window, cx)
-            && self.multibuffer.read(cx).is_empty()
-        {
-            self.focus_handle.focus(window, cx)
-        }
-    }
-
-    #[instrument(skip_all)]
-    fn register_buffer(
-        &mut self,
-        path_key: PathKey,
-        file_status: FileStatus,
-        buffer: Entity,
-        diff: Entity,
-        conflict_set: Entity,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> Option {
-        let diff_subscription = cx.subscribe_in(&diff, window, {
-            let path_key = path_key.clone();
-            let buffer = buffer.clone();
-            let diff = diff.clone();
-            let conflict_set = conflict_set.clone();
-            move |this, _, event, window, cx| match event {
-                buffer_diff::BufferDiffEvent::DiffChanged(_) => {
-                    this.buffer_ranges_changed(
-                        path_key.clone(),
-                        file_status,
-                        buffer.clone(),
-                        diff.clone(),
-                        conflict_set.clone(),
-                        window,
-                        cx,
-                    );
-                }
-                buffer_diff::BufferDiffEvent::BaseTextChanged
-                | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {}
-            }
-        });
-        let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, {
-            let path_key = path_key.clone();
-            let buffer = buffer.clone();
-            let diff = diff.clone();
-            let conflict_set = conflict_set.clone();
-            move |this, _, _, window, cx| {
-                this.buffer_ranges_changed(
-                    path_key.clone(),
-                    file_status,
-                    buffer.clone(),
-                    diff.clone(),
-                    conflict_set.clone(),
-                    window,
-                    cx,
-                )
-            }
-        });
-        self.buffer_subscriptions.insert(
-            path_key.path.clone(),
-            BufferSubscriptions {
-                _diff: diff.clone(),
-                _diff_subscription: diff_subscription,
-                _conflict_set: conflict_set.clone(),
-                _conflict_set_subscription: conflict_set_subscription,
-            },
-        );
-
-        let snapshot = buffer.read(cx).snapshot();
-        let diff_snapshot = diff.read(cx).snapshot(cx);
-
-        let excerpt_ranges = {
-            let diff_hunk_ranges = diff_snapshot
-                .hunks_intersecting_range(
-                    Anchor::min_max_range_for_buffer(snapshot.remote_id()),
-                    &snapshot,
-                )
-                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
-            let conflicts = conflict_set.read(cx).snapshot();
-            let mut conflicts = conflicts
-                .conflicts
-                .iter()
-                .map(|conflict| conflict.range.to_point(&snapshot))
-                .peekable();
-
-            if conflicts.peek().is_some() {
-                conflicts.collect::>()
-            } else {
-                diff_hunk_ranges.collect()
-            }
-        };
-
-        let buffer_id = snapshot.text.remote_id();
-        let mut needs_fold = false;
-
-        let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
-            let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
-            let is_newly_added = editor.update_excerpts_for_path(
-                path_key.clone(),
-                buffer,
-                excerpt_ranges,
-                multibuffer_context_lines(cx),
-                diff,
-                cx,
-            );
-            editor.rhs_editor().update(cx, |editor, cx| {
-                conflict_view::buffer_ranges_updated(editor, conflict_set, cx);
-            });
-            (was_empty, is_newly_added)
-        });
-
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |editor, cx| {
-                if was_empty {
-                    editor.change_selections(
-                        SelectionEffects::no_scroll(),
-                        window,
-                        cx,
-                        |selections| {
-                            selections.select_ranges([
-                                multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
-                            ])
-                        },
-                    );
-                }
-                if is_excerpt_newly_added
-                    && (file_status.is_deleted()
-                        || (file_status.is_untracked()
-                            && GitPanelSettings::get_global(cx).collapse_untracked_diff))
-                {
-                    needs_fold = true;
-                }
-            })
-        });
-
-        if self.multibuffer.read(cx).is_empty()
-            && self
-                .editor
-                .read(cx)
-                .focus_handle(cx)
-                .contains_focused(window, cx)
-        {
-            self.focus_handle.focus(window, cx);
-        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
-            self.editor.update(cx, |editor, cx| {
-                editor.focus_handle(cx).focus(window, cx);
-            });
-        }
-        if self.pending_scroll.as_ref() == Some(&path_key) {
-            self.move_to_path(path_key, window, cx);
-        }
-
-        needs_fold.then_some(buffer_id)
-    }
-
-    fn buffer_ranges_changed(
-        &mut self,
-        path_key: PathKey,
-        file_status: FileStatus,
-        buffer: Entity,
-        diff: Entity,
-        conflict_set: Entity,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        if buffer.read(cx).is_dirty() {
-            return;
-        }
-        self.register_buffer(
-            path_key,
-            file_status,
-            buffer,
-            diff,
-            conflict_set,
-            window,
-            cx,
-        );
-    }
-
-    #[instrument(skip(this, cx))]
-    pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> {
-        let entries = this.update(cx, |this, cx| {
-            let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
-                let load_buffers = branch_diff.load_buffers(cx);
-                (branch_diff.repo().cloned(), load_buffers)
-            });
-            let mut previous_paths = this
-                .multibuffer
-                .read(cx)
-                .snapshot(cx)
-                .buffers_with_paths()
-                .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
-                .collect::>();
-
-            let mut entries = BTreeMap::new();
-            if let Some(repo) = repo {
-                let repo = repo.read(cx);
-                for diff_buffer in buffers_to_load {
-                    let path_key = project_diff_path_key(
-                        &repo,
-                        &diff_buffer.repo_path,
-                        diff_buffer.file_status,
-                        cx,
-                    );
-                    previous_paths.remove(&path_key);
-                    entries.insert(path_key, diff_buffer);
-                }
-            }
-
-            this.editor.update(cx, |editor, cx| {
-                for (path, buffer_id) in previous_paths {
-                    this.buffer_subscriptions.remove(&path.path);
-                    editor.rhs_editor().update(cx, |editor, cx| {
-                        conflict_view::buffers_removed(editor, &[buffer_id], cx);
-                    });
-                    let _span = ztracing::info_span!("remove_excerpts_for_path");
-                    _span.enter();
-                    editor.remove_excerpts_for_path(path, cx);
-                }
-            });
-
-            entries
-        })?;
-
-        let mut buffers_to_fold = Vec::new();
-
-        for (path_key, entry) in entries {
-            if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() {
-                // We might be lagging behind enough that all future entry.load futures are no longer pending.
-                // If that is the case, this task will never yield, starving the foreground thread of execution time.
-                yield_now().await;
-                cx.update(|window, cx| {
-                    this.update(cx, |this, cx| {
-                        if let Some(buffer_id) = this.register_buffer(
-                            path_key,
-                            entry.file_status,
-                            buffer,
-                            diff,
-                            conflict_set,
-                            window,
-                            cx,
-                        ) {
-                            buffers_to_fold.push(buffer_id);
-                        }
-                    })
-                    .ok();
-                })?;
-            }
-        }
-        this.update(cx, |this, cx| {
-            if !buffers_to_fold.is_empty() {
-                this.editor.update(cx, |editor, cx| {
-                    editor
-                        .rhs_editor()
-                        .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
-                });
-            }
-            this.pending_scroll.take();
-            cx.notify();
-        })?;
-
-        Ok(())
-    }
-
     #[cfg(any(test, feature = "test-support"))]
     pub fn excerpt_paths(&self, cx: &App) -> Vec> {
-        let snapshot = self
-            .editor()
-            .read(cx)
-            .rhs_editor()
-            .read(cx)
-            .buffer()
-            .read(cx)
-            .snapshot(cx);
-        snapshot
-            .excerpts()
-            .map(|excerpt| {
-                snapshot
-                    .path_for_buffer(excerpt.context.start.buffer_id)
-                    .unwrap()
-                    .path
-                    .clone()
-            })
-            .collect()
+        self.diff.read(cx).excerpt_paths(cx)
     }
 
-    /// Returns the real (worktree-relative) path of each excerpted buffer, in
-    /// the order the excerpts appear in the multibuffer. Unlike
-    /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather
-    /// than the (possibly synthetic) `PathKey` path used for sorting.
     #[cfg(any(test, feature = "test-support"))]
     pub fn excerpt_file_paths(&self, cx: &App) -> Vec {
-        let multibuffer = self
-            .editor()
-            .read(cx)
-            .rhs_editor()
-            .read(cx)
-            .buffer()
-            .clone();
-        let snapshot = multibuffer.read(cx).snapshot(cx);
-        let mut result = Vec::new();
-        let mut last_buffer_id = None;
-        for excerpt in snapshot.excerpts() {
-            let buffer_id = excerpt.context.start.buffer_id;
-            if last_buffer_id == Some(buffer_id) {
-                continue;
-            }
-            last_buffer_id = Some(buffer_id);
-            if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id)
-                && let Some(file) = buffer.read(cx).file()
-            {
-                result.push(file.path().as_unix_str().to_string());
-            }
-        }
-        result
+        self.diff.read(cx).excerpt_file_paths(cx)
     }
 }
 
-const CONFLICT_SORT_PREFIX: u64 = 1;
-const TRACKED_SORT_PREFIX: u64 = 2;
-const NEW_SORT_PREFIX: u64 = 3;
-
-/// Computes a stable [`PathKey`] for a buffer in the project diff.
-///
-/// The key is an intrinsic function of the file's own repo path and status; it
-/// never depends on which other buffers happen to be present in the
-/// multibuffer. This is required because the multibuffer uses the path key both
-/// to order excerpts and to identify which excerpts belong to a given buffer, so
-/// a key that shifted as files were added or removed would break that identity.
-///
-/// Status grouping is encoded in the `sort_prefix`, and the within-group order
-/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural
-/// ordering reproduces the git panel's order. The path here is only ever used
-/// for sorting and multibuffer identity; the path shown in the UI comes from the
-/// buffer's own `File`.
-fn project_diff_path_key(
-    repo: &Repository,
-    repo_path: &RepoPath,
-    status: FileStatus,
-    cx: &App,
-) -> PathKey {
-    let settings = GitPanelSettings::get_global(cx);
-    let sort_prefix = if settings.group_by != GitPanelGroupBy::Status {
-        TRACKED_SORT_PREFIX
-    } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
-        CONFLICT_SORT_PREFIX
-    } else if status.is_created() {
-        NEW_SORT_PREFIX
-    } else {
-        TRACKED_SORT_PREFIX
-    };
-    let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by);
-    PathKey::with_sort_prefix(sort_prefix, path)
-}
-
-fn project_diff_sort_path(
-    repo_path: &RelPath,
-    tree_view: bool,
-    sort_by: GitPanelSortBy,
-) -> Arc {
-    if tree_view {
-        tree_sort_path(repo_path)
-    } else {
-        match sort_by {
-            GitPanelSortBy::Path => repo_path.into_arc(),
-            GitPanelSortBy::Name => name_sort_path(repo_path),
-        }
-    }
-}
-
-/// Builds a synthetic path that sorts by file name first, falling back to the
-/// full path to keep the key unique per file.
-fn name_sort_path(repo_path: &RelPath) -> Arc {
-    let Some(file_name) = repo_path.file_name() else {
-        return repo_path.into_arc();
-    };
-    let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str());
-    RelPath::unix(&synthetic)
-        .map(|path| path.into_arc())
-        .unwrap_or_else(|_| repo_path.into_arc())
-}
-
-/// Builds a synthetic path whose natural component-wise ordering reproduces a
-/// folder-first tree order. Each directory component is prefixed with a NUL
-/// byte, which can never appear in a real path component and sorts before every
-/// printable character, so at each level directories sort before files.
-fn tree_sort_path(repo_path: &RelPath) -> Arc {
-    let components: Vec<&str> = repo_path.components().collect();
-    if components.len() <= 1 {
-        return repo_path.into_arc();
-    }
-    let last = components.len() - 1;
-    let mut synthetic = String::new();
-    for (index, component) in components.into_iter().enumerate() {
-        if index > 0 {
-            synthetic.push('/');
-        }
-        if index < last {
-            synthetic.push('\0');
-        }
-        synthetic.push_str(component);
-    }
-    RelPath::unix(&synthetic)
-        .map(|path| path.into_arc())
-        .unwrap_or_else(|_| repo_path.into_arc())
+struct ButtonStates {
+    stage: bool,
+    unstage: bool,
+    prev_next: bool,
+    selection: bool,
+    stage_all: bool,
+    unstage_all: bool,
 }
 
 impl EventEmitter for ProjectDiff {}
 
 impl Focusable for ProjectDiff {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
-        if self.multibuffer.read(cx).is_empty() {
-            self.focus_handle.clone()
-        } else {
-            self.editor.focus_handle(cx)
-        }
+        self.diff.read(cx).focus_handle(cx)
     }
 }
 
@@ -1231,11 +391,8 @@ impl Item for ProjectDiff {
     }
 
     fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |primary_editor, cx| {
-                primary_editor.deactivated(window, cx);
-            })
-        });
+        self.diff
+            .update(cx, |diff, cx| diff.deactivated(window, cx));
     }
 
     fn navigate(
@@ -1244,18 +401,12 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) -> bool {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |primary_editor, cx| {
-                primary_editor.navigate(data, window, cx)
-            })
-        })
+        self.diff
+            .update(cx, |diff, cx| diff.navigate(data, window, cx))
     }
 
     fn tab_tooltip_text(&self, cx: &App) -> Option {
-        match self.diff_base(cx) {
-            DiffBase::Head => Some("Project Diff".into()),
-            DiffBase::Merge { .. } => Some("Branch Diff".into()),
-        }
+        Some(self.tab_content_text(0, cx))
     }
 
     fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
@@ -1268,19 +419,16 @@ impl Item for ProjectDiff {
             .into_any_element()
     }
 
-    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
-        match self.branch_diff.read(cx).diff_base() {
-            DiffBase::Head => "Uncommitted Changes".into(),
-            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
-        }
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        "Uncommitted Changes".into()
     }
 
     fn telemetry_event_text(&self) -> Option<&'static str> {
         Some("Project Diff Opened")
     }
 
-    fn as_searchable(&self, _: &Entity, _cx: &App) -> Option> {
-        Some(Box::new(self.editor.clone()))
+    fn as_searchable(&self, _: &Entity, cx: &App) -> Option> {
+        Some(Box::new(self.diff.read(cx).editor().clone()))
     }
 
     fn for_each_project_item(
@@ -1288,26 +436,11 @@ impl Item for ProjectDiff {
         cx: &App,
         f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
     ) {
-        self.editor
-            .read(cx)
-            .rhs_editor()
-            .read(cx)
-            .for_each_project_item(cx, f)
+        self.diff.read(cx).for_each_project_item(cx, f)
     }
 
     fn active_project_path(&self, cx: &App) -> Option {
-        let editor = self.editor.read(cx).focused_editor().read(cx);
-        let multibuffer = editor.buffer().read(cx);
-        let position = editor.selections.newest_anchor().head();
-        let snapshot = multibuffer.snapshot(cx);
-        let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
-        let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
-
-        let file = buffer.read(cx).file()?;
-        Some(ProjectPath {
-            worktree_id: file.worktree_id(cx),
-            path: file.path().clone(),
-        })
+        self.diff.read(cx).active_project_path(cx)
     }
 
     fn set_nav_history(
@@ -1316,11 +449,8 @@ impl Item for ProjectDiff {
         _: &mut Window,
         cx: &mut Context,
     ) {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |primary_editor, _| {
-                primary_editor.set_nav_history(Some(nav_history));
-            })
-        });
+        self.diff
+            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
     }
 
     fn can_split(&self) -> bool {
@@ -1345,11 +475,11 @@ impl Item for ProjectDiff {
     }
 
     fn is_dirty(&self, cx: &App) -> bool {
-        self.multibuffer.read(cx).is_dirty(cx)
+        self.diff.read(cx).is_dirty(cx)
     }
 
     fn has_conflict(&self, cx: &App) -> bool {
-        self.multibuffer.read(cx).has_conflict(cx)
+        self.diff.read(cx).has_conflict(cx)
     }
 
     fn can_save(&self, _: &App) -> bool {
@@ -1363,11 +493,8 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) -> Task> {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |primary_editor, cx| {
-                primary_editor.save(options, project, window, cx)
-            })
-        })
+        self.diff
+            .update(cx, |diff, cx| diff.save(options, project, window, cx))
     }
 
     fn save_as(
@@ -1386,11 +513,8 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) -> Task> {
-        self.editor.update(cx, |editor, cx| {
-            editor.rhs_editor().update(cx, |primary_editor, cx| {
-                primary_editor.reload(project, window, cx)
-            })
-        })
+        self.diff
+            .update(cx, |diff, cx| diff.reload(project, window, cx))
     }
 
     fn act_as_type<'a>(
@@ -1402,9 +526,19 @@ impl Item for ProjectDiff {
         if type_id == TypeId::of::() {
             Some(self_handle.clone().into())
         } else if type_id == TypeId::of::() {
-            Some(self.editor.read(cx).rhs_editor().clone().into())
+            Some(
+                self.diff
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .rhs_editor()
+                    .clone()
+                    .into(),
+            )
         } else if type_id == TypeId::of::() {
-            Some(self.editor.clone().into())
+            Some(self.diff.read(cx).editor().clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).branch_diff().clone().into())
         } else {
             None
         }
@@ -1416,88 +550,15 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        self.editor.update(cx, |editor, cx| {
-            editor.added_to_workspace(workspace, window, cx)
+        self.diff.update(cx, |diff, cx| {
+            diff.added_to_workspace(workspace, window, cx)
         });
     }
 }
 
 impl Render for ProjectDiff {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let is_empty = self.multibuffer.read(cx).is_empty();
-        let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready();
-
-        let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. });
-
-        div()
-            .track_focus(&self.focus_handle)
-            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
-            .when(is_branch_diff_view, |this| {
-                this.on_action(cx.listener(Self::review_diff))
-            })
-            .bg(cx.theme().colors().editor_background)
-            .flex()
-            .items_center()
-            .justify_center()
-            .size_full()
-            .when(is_empty && is_loading, |el| {
-                let rems = TextSize::Large.rems(cx);
-                el.child(
-                    Icon::new(IconName::LoadCircle)
-                        .size(IconSize::Custom(rems))
-                        .color(Color::Accent)
-                        .with_rotate_animation(3)
-                        .into_any_element(),
-                )
-            })
-            .when(is_empty && !is_loading, |el| {
-                let remote_button = if let Some(panel) = self
-                    .workspace
-                    .upgrade()
-                    .and_then(|workspace| workspace.read(cx).panel::(cx))
-                {
-                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
-                } else {
-                    None
-                };
-                let keybinding_focus_handle = self.focus_handle(cx);
-                el.child(
-                    v_flex()
-                        .gap_1()
-                        .child(
-                            h_flex()
-                                .justify_around()
-                                .child(Label::new("No uncommitted changes")),
-                        )
-                        .map(|el| match remote_button {
-                            Some(button) => el.child(h_flex().justify_around().child(button)),
-                            None => el.child(
-                                h_flex()
-                                    .justify_around()
-                                    .child(Label::new("Remote up to date")),
-                            ),
-                        })
-                        .child(
-                            h_flex().justify_around().mt_1().child(
-                                Button::new("project-diff-close-button", "Close")
-                                    // .style(ButtonStyle::Transparent)
-                                    .key_binding(KeyBinding::for_action_in(
-                                        &CloseActiveItem::default(),
-                                        &keybinding_focus_handle,
-                                        cx,
-                                    ))
-                                    .on_click(move |_, window, cx| {
-                                        window.focus(&keybinding_focus_handle, cx);
-                                        window.dispatch_action(
-                                            Box::new(CloseActiveItem::default()),
-                                            cx,
-                                        );
-                                    }),
-                            ),
-                        ),
-                )
-            })
-            .when(!is_empty, |el| el.child(self.editor.clone()))
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+        div().size_full().child(self.diff.clone())
     }
 }
 
@@ -1518,46 +579,38 @@ impl SerializableItem for ProjectDiff {
     fn deserialize(
         project: Entity,
         workspace: WeakEntity,
-        workspace_id: workspace::WorkspaceId,
-        item_id: workspace::ItemId,
+        _workspace_id: workspace::WorkspaceId,
+        _item_id: workspace::ItemId,
         window: &mut Window,
         cx: &mut App,
     ) -> Task>> {
-        let db = persistence::ProjectDiffDb::global(cx);
         window.spawn(cx, async move |cx| {
-            let diff_base = db.get_diff_base(item_id, workspace_id)?;
-
-            let diff = cx.update(|window, cx| {
-                let branch_diff = cx
-                    .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
+            cx.update(|window, cx| {
+                let branch_diff = cx.new(|cx| {
+                    diff_buffer_list::DiffBufferList::new(
+                        DiffBase::Head,
+                        project.clone(),
+                        window,
+                        cx,
+                    )
+                });
                 let workspace = workspace.upgrade().context("workspace gone")?;
                 anyhow::Ok(
                     cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
                 )
-            })??;
-
-            Ok(diff)
+            })?
         })
     }
 
     fn serialize(
         &mut self,
-        workspace: &mut Workspace,
-        item_id: workspace::ItemId,
-        _closing: bool,
-        _window: &mut Window,
-        cx: &mut Context,
+        _: &mut Workspace,
+        _: workspace::ItemId,
+        _: bool,
+        _: &mut Window,
+        _: &mut Context,
     ) -> Option>> {
-        let workspace_id = workspace.database_id()?;
-        let diff_base = self.diff_base(cx).clone();
-
-        let db = persistence::ProjectDiffDb::global(cx);
-        Some(cx.background_spawn({
-            async move {
-                db.save_diff_base(item_id, workspace_id, diff_base.clone())
-                    .await
-            }
-        }))
+        Some(Task::ready(Ok(())))
     }
 
     fn should_serialize(&self, _: &Self::Event) -> bool {
@@ -1565,14 +618,14 @@ impl SerializableItem for ProjectDiff {
     }
 }
 
-mod persistence {
+pub(crate) mod persistence {
 
     use anyhow::Context as _;
     use db::{
         sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
         sqlez_macros::sql,
     };
-    use project::git_store::branch_diff::DiffBase;
+    use project::git_store::diff_buffer_list::DiffBase;
     use workspace::{ItemId, WorkspaceDb, WorkspaceId};
 
     pub struct ProjectDiffDb(ThreadSafeConnection);
@@ -1580,7 +633,11 @@ mod persistence {
     impl Domain for ProjectDiffDb {
         const NAME: &str = stringify!(ProjectDiffDb);
 
-        const MIGRATIONS: &[&str] = &[sql!(
+        // Legacy databases stored branch diffs under the "ProjectDiff" item
+        // kind, disambiguated by the `diff_base` column. Step 1 rewrites those
+        // item kinds so that each diff view owns its serialized kind.
+        const MIGRATIONS: &[&str] = &[
+            sql!(
                 CREATE TABLE project_diffs(
                     workspace_id INTEGER,
                     item_id INTEGER UNIQUE,
@@ -1591,13 +648,23 @@ mod persistence {
                     FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
                     ON DELETE CASCADE
                 ) STRICT;
-        )];
+            ),
+            r#"
+                UPDATE items SET kind = 'BranchDiff'
+                WHERE kind = 'ProjectDiff' AND EXISTS (
+                    SELECT 1 FROM project_diffs
+                    WHERE project_diffs.item_id = items.item_id
+                    AND project_diffs.workspace_id = items.workspace_id
+                    AND project_diffs.diff_base LIKE '{"Merge"%'
+                );
+            "#,
+        ];
     }
 
     db::static_connection!(ProjectDiffDb, [WorkspaceDb]);
 
     impl ProjectDiffDb {
-        pub async fn save_diff_base(
+        pub async fn save_project_diff_base(
             &self,
             item_id: ItemId,
             workspace_id: WorkspaceId,
@@ -1617,7 +684,7 @@ mod persistence {
             .await
         }
 
-        pub fn get_diff_base(
+        pub fn get_project_diff_base(
             &self,
             item_id: ItemId,
             workspace_id: WorkspaceId,
@@ -1703,7 +770,6 @@ impl ToolbarItemView for ProjectDiffToolbar {
     ) -> ToolbarItemLocation {
         self.project_diff = active_pane_item
             .and_then(|item| item.act_as::(cx))
-            .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
             .map(|entity| entity.downgrade());
         if self.project_diff.is_some() {
             ToolbarItemLocation::PrimaryRight
@@ -1721,15 +787,6 @@ impl ToolbarItemView for ProjectDiffToolbar {
     }
 }
 
-struct ButtonStates {
-    stage: bool,
-    unstage: bool,
-    prev_next: bool,
-    selection: bool,
-    stage_all: bool,
-    unstage_all: bool,
-}
-
 impl Render for ProjectDiffToolbar {
     fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
         let Some(project_diff) = self.project_diff(cx) else {
@@ -1737,18 +794,63 @@ impl Render for ProjectDiffToolbar {
         };
         let focus_handle = project_diff.focus_handle(cx);
         let button_states = project_diff.read(cx).button_states(cx);
-        let review_count = project_diff.read(cx).total_review_comment_count();
+        let review_count = project_diff.read(cx).total_review_comment_count(cx);
 
-        h_group_xl()
+        let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
+        let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty();
+
+        let stage_all_button_width = rems(5.);
+
+        h_flex()
             .my_neg_1()
             .py_1()
-            .items_center()
+            .gap_1p5()
             .flex_wrap()
             .justify_between()
+            .when(!is_multibuffer_empty, |this| {
+                this.child(DiffStat::new(
+                    "project-diff-stat",
+                    additions as usize,
+                    deletions as usize,
+                ))
+                .child(Divider::vertical().ml_1())
+            })
+            // n.b. the only reason these arrows are here is because we don't
+            // support "undo" for staging so we need a way to go back.
             .child(
                 h_group_sm()
-                    .when(button_states.selection, |el| {
-                        el.child(
+                    .child(
+                        IconButton::new("up", IconName::ArrowUp)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Previous Hunk",
+                                &GoToPreviousHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToPreviousHunk, window, cx)
+                            })),
+                    )
+                    .child(
+                        IconButton::new("down", IconName::ArrowDown)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Next Hunk",
+                                &GoToHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToHunk, window, cx)
+                            })),
+                    ),
+            )
+            .child(Divider::vertical())
+            .child(
+                h_group_sm()
+                    .when(button_states.selection, |this| {
+                        this.child(
                             Button::new("stage", "Toggle Staged")
                                 .tooltip(Tooltip::for_action_title_in(
                                     "Toggle Staged",
@@ -1761,127 +863,83 @@ impl Render for ProjectDiffToolbar {
                                 })),
                         )
                     })
-                    .when(!button_states.selection, |el| {
-                        el.child(
+                    .when(!button_states.selection, |this| {
+                        this.child(
                             Button::new("stage", "Stage")
+                                .disabled(!button_states.stage)
                                 .tooltip(Tooltip::for_action_title_in(
-                                    "Stage and go to next hunk",
+                                    "Stage and Go to Next Hunk",
                                     &StageAndNext,
                                     &focus_handle,
                                 ))
-                                .disabled(
-                                    !button_states.prev_next
-                                        && !button_states.stage_all
-                                        && !button_states.unstage_all,
-                                )
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.dispatch_action(&StageAndNext, window, cx)
                                 })),
                         )
                         .child(
                             Button::new("unstage", "Unstage")
+                                .disabled(!button_states.unstage)
                                 .tooltip(Tooltip::for_action_title_in(
-                                    "Unstage and go to next hunk",
+                                    "Unstage and Go to Next Hunk",
                                     &UnstageAndNext,
                                     &focus_handle,
                                 ))
-                                .disabled(
-                                    !button_states.prev_next
-                                        && !button_states.stage_all
-                                        && !button_states.unstage_all,
-                                )
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.dispatch_action(&UnstageAndNext, window, cx)
                                 })),
                         )
                     }),
             )
-            // n.b. the only reason these arrows are here is because we don't
-            // support "undo" for staging so we need a way to go back.
-            .child(
-                h_group_sm()
-                    .child(
-                        IconButton::new("up", IconName::ArrowUp)
-                            .shape(ui::IconButtonShape::Square)
+            .child(Divider::vertical())
+            .when(
+                button_states.unstage_all && !button_states.stage_all,
+                |this| {
+                    this.child(
+                        Button::new("unstage-all", "Unstage All")
+                            .width(stage_all_button_width)
                             .tooltip(Tooltip::for_action_title_in(
-                                "Go to previous hunk",
-                                &GoToPreviousHunk,
+                                "Unstage All Changes",
+                                &UnstageAll,
                                 &focus_handle,
                             ))
-                            .disabled(!button_states.prev_next)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&GoToPreviousHunk, window, cx)
-                            })),
+                            .on_click(
+                                cx.listener(|this, _, window, cx| this.unstage_all(window, cx)),
+                            ),
                     )
-                    .child(
-                        IconButton::new("down", IconName::ArrowDown)
-                            .shape(ui::IconButtonShape::Square)
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Go to next hunk",
-                                &GoToHunk,
-                                &focus_handle,
-                            ))
-                            .disabled(!button_states.prev_next)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&GoToHunk, window, cx)
-                            })),
-                    ),
+                },
             )
-            .child(vertical_divider())
-            .child(
-                h_group_sm()
-                    .when(
-                        button_states.unstage_all && !button_states.stage_all,
-                        |el| {
-                            el.child(
-                                Button::new("unstage-all", "Unstage All")
-                                    .tooltip(Tooltip::for_action_title_in(
-                                        "Unstage all changes",
-                                        &UnstageAll,
-                                        &focus_handle,
-                                    ))
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.unstage_all(window, cx)
-                                    })),
-                            )
-                        },
-                    )
-                    .when(
-                        !button_states.unstage_all || button_states.stage_all,
-                        |el| {
-                            el.child(
-                                // todo make it so that changing to say "Unstaged"
-                                // doesn't change the position.
-                                div().child(
-                                    Button::new("stage-all", "Stage All")
-                                        .disabled(!button_states.stage_all)
-                                        .tooltip(Tooltip::for_action_title_in(
-                                            "Stage all changes",
-                                            &StageAll,
-                                            &focus_handle,
-                                        ))
-                                        .on_click(cx.listener(|this, _, window, cx| {
-                                            this.stage_all(window, cx)
-                                        })),
-                                ),
-                            )
-                        },
-                    )
-                    .child(
-                        Button::new("commit", "Commit")
+            .when(
+                !button_states.unstage_all || button_states.stage_all,
+                |this| {
+                    this.child(
+                        Button::new("stage-all", "Stage All")
+                            .width(stage_all_button_width)
+                            .disabled(!button_states.stage_all)
                             .tooltip(Tooltip::for_action_title_in(
-                                "Commit",
-                                &Commit,
+                                "Stage All Changes",
+                                &StageAll,
                                 &focus_handle,
                             ))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&Commit, window, cx);
-                            })),
-                    ),
+                            .on_click(
+                                cx.listener(|this, _, window, cx| this.stage_all(window, cx)),
+                            ),
+                    )
+                },
+            )
+            .child(Divider::vertical())
+            .child(
+                Button::new("commit", "Commit")
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Commit",
+                        &Commit,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.dispatch_action(&Commit, window, cx);
+                    })),
             )
-            // "Send Review to Agent" button (only shown when there are review comments)
             .when(review_count > 0, |el| {
-                el.child(vertical_divider()).child(
+                el.child(Divider::vertical()).child(
                     render_send_review_to_agent_button(review_count, &focus_handle).on_click(
                         cx.listener(|this, _, window, cx| {
                             this.dispatch_action(&SendReviewToAgent, window, cx)
@@ -1892,7 +950,10 @@ impl Render for ProjectDiffToolbar {
     }
 }
 
-fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button {
+pub(crate) fn render_send_review_to_agent_button(
+    review_count: usize,
+    focus_handle: &FocusHandle,
+) -> Button {
     Button::new(
         "send-review",
         format!("Send Review to Agent ({})", review_count),
@@ -1909,209 +970,19 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH
     ))
 }
 
-pub struct BranchDiffToolbar {
-    project_diff: Option>,
-}
-
-impl BranchDiffToolbar {
-    pub fn new(_cx: &mut Context) -> Self {
-        Self { project_diff: None }
-    }
-
-    fn project_diff(&self, _: &App) -> Option> {
-        self.project_diff.as_ref()?.upgrade()
-    }
-
-    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) {
-        if let Some(project_diff) = self.project_diff(cx) {
-            project_diff.focus_handle(cx).focus(window, cx);
-        }
-        let action = action.boxed_clone();
-        cx.defer(move |cx| {
-            cx.dispatch_action(action.as_ref());
-        })
-    }
-}
-
-impl EventEmitter for BranchDiffToolbar {}
-
-impl ToolbarItemView for BranchDiffToolbar {
-    fn set_active_pane_item(
-        &mut self,
-        active_pane_item: Option<&dyn ItemHandle>,
-        _: &mut Window,
-        cx: &mut Context,
-    ) -> ToolbarItemLocation {
-        self.project_diff = active_pane_item
-            .and_then(|item| item.act_as::(cx))
-            .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
-            .map(|entity| entity.downgrade());
-        if self.project_diff.is_some() {
-            ToolbarItemLocation::PrimaryRight
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-
-    fn pane_focus_update(
-        &mut self,
-        _pane_focused: bool,
-        _window: &mut Window,
-        _cx: &mut Context,
-    ) {
-    }
-}
-
-impl Render for BranchDiffToolbar {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let Some(project_diff) = self.project_diff(cx) else {
-            return div();
-        };
-        let focus_handle = project_diff.focus_handle(cx);
-        let review_count = project_diff.read(cx).total_review_comment_count();
-        let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
-        let diff_base = project_diff.read(cx).diff_base(cx).clone();
-        let DiffBase::Merge { base_ref } = diff_base else {
-            return div();
-        };
-        let selected_base_ref = base_ref.clone();
-        let base_ref_label = format!("Base: {base_ref}");
-        let repository = project_diff.read(cx).branch_diff.read(cx).repo().cloned();
-        let workspace = project_diff.read(cx).workspace.clone();
-        let project_diff_for_picker = project_diff.downgrade();
-
-        let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty();
-        let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
-
-        let show_review_button = !is_multibuffer_empty && is_ai_enabled;
-
-        h_group_xl()
-            .my_neg_1()
-            .py_1()
-            .items_center()
-            .flex_wrap()
-            .justify_end()
-            .gap_2()
-            .child(
-                PopoverMenu::new("branch-diff-base-branch-picker")
-                    .menu(move |window, cx| {
-                        let project_diff = project_diff_for_picker.clone();
-                        let on_select = Arc::new(
-                            move |branch: git::repository::Branch,
-                                  _window: &mut Window,
-                                  cx: &mut App| {
-                                let base_ref: SharedString = branch.name().to_owned().into();
-                                project_diff
-                                    .update(cx, |project_diff, cx| {
-                                        let branch_diff = &mut project_diff.branch_diff;
-                                        branch_diff.update(cx, |branch_diff, cx| {
-                                            branch_diff
-                                                .set_diff_base(DiffBase::Merge { base_ref }, cx);
-                                        });
-                                        cx.notify();
-                                    })
-                                    .ok();
-                            },
-                        );
-                        Some(branch_picker::select_popover(
-                            workspace.clone(),
-                            repository.clone(),
-                            Some(selected_base_ref.clone()),
-                            on_select,
-                            window,
-                            cx,
-                        ))
-                    })
-                    .trigger_with_tooltip(
-                        Button::new("branch-diff-base-branch", base_ref_label)
-                            .color(Color::Muted)
-                            .end_icon(
-                                Icon::new(IconName::ChevronDown)
-                                    .size(IconSize::XSmall)
-                                    .color(Color::Muted),
-                            ),
-                        Tooltip::text("Select base branch"),
-                    ),
-            )
-            .when(!is_multibuffer_empty, |this| {
-                this.child(DiffStat::new(
-                    "branch-diff-stat",
-                    additions as usize,
-                    deletions as usize,
-                ))
-            })
-            .when(show_review_button, |this| {
-                let focus_handle = focus_handle.clone();
-                this.child(Divider::vertical()).child(
-                    Button::new("review-diff", "Review Diff")
-                        .start_icon(
-                            Icon::new(IconName::ZedAssistant)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        )
-                        .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
-                        .tooltip(move |_, cx| {
-                            Tooltip::with_meta_in(
-                                "Review Diff",
-                                Some(&ReviewDiff),
-                                "Send this diff for your last agent to review.",
-                                &focus_handle,
-                                cx,
-                            )
-                        })
-                        .on_click(cx.listener(|this, _, window, cx| {
-                            this.dispatch_action(&ReviewDiff, window, cx);
-                        })),
-                )
-            })
-            .when(review_count > 0, |this| {
-                this.child(vertical_divider()).child(
-                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
-                        cx.listener(|this, _, window, cx| {
-                            this.dispatch_action(&SendReviewToAgent, window, cx)
-                        }),
-                    ),
-                )
-            })
-    }
-}
-
-struct BranchDiffAddon {
-    branch_diff: Entity,
-}
-
-impl Addon for BranchDiffAddon {
-    fn to_any(&self) -> &dyn std::any::Any {
-        self
-    }
-
-    fn override_status_for_buffer_id(
-        &self,
-        buffer_id: language::BufferId,
-        cx: &App,
-    ) -> Option {
-        self.branch_diff
-            .read(cx)
-            .status_for_buffer_id(buffer_id, cx)
-    }
-}
-
 #[cfg(test)]
 mod tests {
-    use collections::HashMap;
+    use buffer_diff::DiffHunkSecondaryStatus;
     use db::indoc;
     use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
-    use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
     use gpui::TestAppContext;
+    use multi_buffer::PathKey;
     use project::FakeFs;
     use serde_json::json;
     use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore};
     use std::path::Path;
     use unindent::Unindent as _;
-    use util::{
-        path,
-        rel_path::{RelPath, rel_path},
-    };
+    use util::{path, rel_path::rel_path};
 
     use workspace::MultiWorkspace;
 
@@ -2137,6 +1008,482 @@ mod tests {
         });
     }
 
+    use zed_actions::git as git_actions;
+
+    use crate::project_diff::{self, ProjectDiff};
+
+    #[test]
+    fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() {
+        use db::sqlez::{
+            connection::Connection,
+            domain::{Domain as _, Migrator as _},
+        };
+
+        let connection = Connection::open_memory(Some(
+            "test_legacy_branch_diff_rows_migrate_to_their_own_kind",
+        ));
+        connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap();
+        workspace::WorkspaceDb::migrate(&connection).unwrap();
+        connection
+            .migrate(
+                persistence::ProjectDiffDb::NAME,
+                &persistence::ProjectDiffDb::MIGRATIONS[..1],
+                &mut |_, _, _| false,
+            )
+            .unwrap();
+
+        connection
+            .exec(
+                "INSERT INTO workspaces(workspace_id) VALUES (1);
+                INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1);
+                INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES
+                    (1, 1, 1, 'ProjectDiff', 0, 1),
+                    (2, 1, 1, 'ProjectDiff', 1, 0)",
+            )
+            .unwrap()()
+        .unwrap();
+        let head = serde_json::to_string(&DiffBase::Head).unwrap();
+        let merge = serde_json::to_string(&DiffBase::Merge {
+            base_ref: "main".into(),
+        })
+        .unwrap();
+        connection
+            .exec_bound::<(String, String)>(
+                "INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)",
+            )
+            .unwrap()((head, merge))
+        .unwrap();
+
+        persistence::ProjectDiffDb::migrate(&connection).unwrap();
+
+        let kinds = connection
+            .select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id")
+            .unwrap()()
+        .unwrap();
+        assert_eq!(
+            kinds,
+            [
+                (1, "ProjectDiff".to_string()),
+                (2, "BranchDiff".to_string())
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_update_on_uncommit(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "README.md": "# My cool project\n".to_owned()
+            }),
+        )
+        .await;
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("README.md", "# My cool project\n".to_owned())],
+        );
+        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
+        let worktree_id = project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+        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.run_until_parked();
+
+        let _editor = workspace
+            .update_in(cx, |workspace, window, cx| {
+                workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
+            })
+            .await
+            .unwrap()
+            .downcast::()
+            .unwrap();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+        let item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        cx.focus(&item);
+        let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone());
+
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[(
+                "README.md",
+                "# My cool project\nDetails to come.\n".to_owned(),
+            )],
+        );
+        cx.run_until_parked();
+
+        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
+
+        cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
+    }
+
+    #[gpui::test]
+    async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project_a"),
+            json!({
+                ".git": {},
+                "a.txt": "CHANGED_A\n",
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            path!("/project_b"),
+            json!({
+                ".git": {},
+                "b.txt": "CHANGED_B\n",
+            }),
+        )
+        .await;
+
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project_a/.git")),
+            &[("a.txt", "original_a\n".to_string())],
+        );
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project_b/.git")),
+            &[("b.txt", "original_b\n".to_string())],
+        );
+
+        let project = Project::test(
+            fs.clone(),
+            [
+                Path::new(path!("/project_a")),
+                Path::new(path!("/project_b")),
+            ],
+            cx,
+        )
+        .await;
+
+        let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
+            let mut worktrees: Vec<_> = project.worktrees(cx).collect();
+            worktrees.sort_by_key(|w| w.read(cx).abs_path());
+            (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
+        });
+
+        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.run_until_parked();
+
+        // Select project A explicitly and open the diff.
+        workspace.update(cx, |workspace, cx| {
+            let git_store = workspace.project().read(cx).git_store().clone();
+            git_store.update(cx, |git_store, cx| {
+                git_store.set_active_repo_for_worktree(worktree_a_id, cx);
+            });
+        });
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let diff_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
+        assert_eq!(paths_a.len(), 1);
+        assert_eq!(*paths_a[0], *"a.txt");
+
+        // Switch the explicit active repository to project B and re-run the diff action.
+        workspace.update(cx, |workspace, cx| {
+            let git_store = workspace.project().read(cx).git_store().clone();
+            git_store.update(cx, |git_store, cx| {
+                git_store.set_active_repo_for_worktree(worktree_b_id, cx);
+            });
+        });
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let same_diff_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
+
+        let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
+        assert_eq!(paths_b.len(), 1);
+        assert_eq!(*paths_b[0], *"b.txt");
+    }
+
+    #[gpui::test]
+    async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let committed_contents = r#"
+            fn main() {
+                println!("hello world");
+            }
+        "#
+        .unindent();
+        let staged_contents = r#"
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+        let file_contents = r#"
+            // print goodbye
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "src": {
+                    "main.rs": file_contents,
+                }
+            }),
+        )
+        .await;
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", committed_contents)],
+            "deadbeef",
+        );
+        fs.set_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", staged_contents)],
+        );
+
+        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, window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        cx.run_until_parked();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let diff_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        let diff_editor =
+            diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
+        assert_eq!(
+            diff_editor.read_with(cx, |editor, cx| {
+                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .map(|hunk| hunk.status.secondary)
+                    .collect::>()
+            }),
+            vec![
+                DiffHunkSecondaryStatus::HasSecondaryHunk,
+                DiffHunkSecondaryStatus::NoSecondaryHunk,
+            ]
+        );
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let unstaged_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        assert_ne!(diff_item.entity_id(), unstaged_item.entity_id());
+        let unstaged_editor = workspace.update(cx, |workspace, cx| {
+            let active_item = workspace.active_item(cx).unwrap();
+            assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes");
+            active_item
+                .act_as::(cx)
+                .unwrap()
+                .read(cx)
+                .editor()
+                .read(cx)
+                .rhs_editor()
+                .clone()
+        });
+        assert_eq!(
+            unstaged_editor.read_with(cx, |editor, cx| {
+                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .map(|hunk| hunk.status.secondary)
+                    .collect::>()
+            }),
+            vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
+        );
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let uncommitted_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id());
+        assert_eq!(
+            uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)),
+            "Uncommitted Changes"
+        );
+        let uncommitted_editor = uncommitted_item
+            .read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
+        assert_eq!(
+            uncommitted_editor.read_with(cx, |editor, cx| {
+                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .map(|hunk| hunk.status.secondary)
+                    .collect::>()
+            }),
+            vec![
+                DiffHunkSecondaryStatus::HasSecondaryHunk,
+                DiffHunkSecondaryStatus::NoSecondaryHunk,
+            ]
+        );
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let staged_editor = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap();
+            let active_item = workspace.active_item(cx).unwrap();
+            assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes");
+            active_item
+                .act_as::(cx)
+                .unwrap()
+                .read(cx)
+                .editor()
+                .read(cx)
+                .rhs_editor()
+                .clone()
+        });
+        assert_eq!(
+            staged_editor.read_with(cx, |editor, cx| {
+                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .map(|hunk| hunk.status.secondary)
+                    .collect::>()
+            }),
+            vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/a"),
+            json!({
+                ".git": {},
+                "a.txt": "created\n",
+                "b.txt": "really changed\n",
+                "c.txt": "unchanged\n"
+            }),
+        )
+        .await;
+
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/a/.git")),
+            &[
+                ("b.txt", "before\n".to_string()),
+                ("c.txt", "unchanged\n".to_string()),
+                ("d.txt", "deleted\n".to_string()),
+            ],
+        );
+
+        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        cx.run_until_parked();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+
+        cx.run_until_parked();
+
+        let item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+        cx.focus(&item);
+        let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone());
+
+        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
+
+        cx.set_selections_state(indoc!(
+            "
+            before
+            really changed
+
+            deleted
+
+            ˇcreated
+        "
+        ));
+
+        cx.dispatch_action(editor::actions::GoToPreviousHunk);
+
+        cx.assert_excerpts_with_selections(indoc!(
+            "
+            [EXCERPT]
+            before
+            really changed
+            [EXCERPT]
+            ˇ[FOLDED]
+            [EXCERPT]
+            created
+        "
+        ));
+
+        cx.dispatch_action(editor::actions::GoToPreviousHunk);
+
+        cx.assert_excerpts_with_selections(indoc!(
+            "
+            [EXCERPT]
+            ˇbefore
+            really changed
+            [EXCERPT]
+            [FOLDED]
+            [EXCERPT]
+            created
+        "
+        ));
+    }
+
     #[gpui::test]
     async fn test_save_after_restore(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2170,7 +1517,7 @@ mod tests {
         });
         cx.run_until_parked();
 
-        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
+        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
         assert_state_with_diff(
             &editor,
             cx,
@@ -2226,12 +1573,14 @@ mod tests {
         cx.run_until_parked();
 
         let editor = cx.update_window_entity(&diff, |diff, window, cx| {
-            diff.move_to_path(
-                PathKey::with_sort_prefix(2, rel_path("foo").into_arc()),
-                window,
-                cx,
-            );
-            diff.editor.read(cx).rhs_editor().clone()
+            diff.diff.update(cx, |diff, cx| {
+                diff.move_to_path(
+                    PathKey::with_sort_prefix(2, rel_path("foo").into_arc()),
+                    window,
+                    cx,
+                )
+            });
+            diff.editor(cx).read(cx).rhs_editor().clone()
         });
         assert_state_with_diff(
             &editor,
@@ -2247,12 +1596,14 @@ mod tests {
         );
 
         let editor = cx.update_window_entity(&diff, |diff, window, cx| {
-            diff.move_to_path(
-                PathKey::with_sort_prefix(2, rel_path("bar").into_arc()),
-                window,
-                cx,
-            );
-            diff.editor.read(cx).rhs_editor().clone()
+            diff.diff.update(cx, |diff, cx| {
+                diff.move_to_path(
+                    PathKey::with_sort_prefix(2, rel_path("bar").into_arc()),
+                    window,
+                    cx,
+                )
+            });
+            diff.editor(cx).read(cx).rhs_editor().clone()
         });
         assert_state_with_diff(
             &editor,
@@ -2305,7 +1656,8 @@ mod tests {
         });
         cx.run_until_parked();
 
-        let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
+        let diff_editor =
+            diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
 
         assert_state_with_diff(
             &diff_editor,
@@ -2383,334 +1735,6 @@ mod tests {
         );
     }
 
-    use crate::project_diff::{self, ProjectDiff};
-
-    #[gpui::test]
-    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/a"),
-            json!({
-                ".git": {},
-                "a.txt": "created\n",
-                "b.txt": "really changed\n",
-                "c.txt": "unchanged\n"
-            }),
-        )
-        .await;
-
-        fs.set_head_and_index_for_repo(
-            Path::new(path!("/a/.git")),
-            &[
-                ("b.txt", "before\n".to_string()),
-                ("c.txt", "unchanged\n".to_string()),
-                ("d.txt", "deleted\n".to_string()),
-            ],
-        );
-
-        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-
-        cx.run_until_parked();
-
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
-        });
-
-        cx.run_until_parked();
-
-        let item = workspace.update(cx, |workspace, cx| {
-            workspace.active_item_as::(cx).unwrap()
-        });
-        cx.focus(&item);
-        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
-
-        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
-
-        cx.set_selections_state(indoc!(
-            "
-            before
-            really changed
-
-            deleted
-
-            ˇcreated
-        "
-        ));
-
-        cx.dispatch_action(editor::actions::GoToPreviousHunk);
-
-        cx.assert_excerpts_with_selections(indoc!(
-            "
-            [EXCERPT]
-            before
-            really changed
-            [EXCERPT]
-            ˇ[FOLDED]
-            [EXCERPT]
-            created
-        "
-        ));
-
-        cx.dispatch_action(editor::actions::GoToPreviousHunk);
-
-        cx.assert_excerpts_with_selections(indoc!(
-            "
-            [EXCERPT]
-            ˇbefore
-            really changed
-            [EXCERPT]
-            [FOLDED]
-            [EXCERPT]
-            created
-        "
-        ));
-    }
-
-    #[gpui::test]
-    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let git_contents = indoc! {r#"
-            #[rustfmt::skip]
-            fn main() {
-                let x = 0.0; // this line will be removed
-                // 1
-                // 2
-                // 3
-                let y = 0.0; // this line will be removed
-                // 1
-                // 2
-                // 3
-                let arr = [
-                    0.0, // this line will be removed
-                    0.0, // this line will be removed
-                    0.0, // this line will be removed
-                    0.0, // this line will be removed
-                ];
-            }
-        "#};
-        let buffer_contents = indoc! {"
-            #[rustfmt::skip]
-            fn main() {
-                // 1
-                // 2
-                // 3
-                // 1
-                // 2
-                // 3
-                let arr = [
-                ];
-            }
-        "};
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/a"),
-            json!({
-                ".git": {},
-                "main.rs": buffer_contents,
-            }),
-        )
-        .await;
-
-        fs.set_head_and_index_for_repo(
-            Path::new(path!("/a/.git")),
-            &[("main.rs", git_contents.to_owned())],
-        );
-
-        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-
-        cx.run_until_parked();
-
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
-        });
-
-        cx.run_until_parked();
-
-        let item = workspace.update(cx, |workspace, cx| {
-            workspace.active_item_as::(cx).unwrap()
-        });
-        cx.focus(&item);
-        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
-
-        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
-
-        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
-
-        cx.dispatch_action(editor::actions::GoToHunk);
-        cx.dispatch_action(editor::actions::GoToHunk);
-        cx.dispatch_action(git::Restore);
-        cx.dispatch_action(editor::actions::MoveToBeginning);
-
-        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
-    }
-
-    #[gpui::test(iterations = 50)]
-    async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
-        cx: &mut TestAppContext,
-    ) {
-        init_test(cx);
-
-        cx.update(|cx| {
-            cx.update_global::(|store, cx| {
-                store.update_user_settings(cx, |settings| {
-                    settings.editor.diff_view_style = Some(DiffViewStyle::Split);
-                });
-            });
-        });
-
-        let build_conflict_text: fn(usize) -> String = |tag: usize| {
-            let mut lines = (0..80)
-                .map(|line_index| format!("line {line_index}"))
-                .collect::>();
-            for offset in [5usize, 20, 37, 61] {
-                lines[offset] = format!("base-{tag}-line-{offset}");
-            }
-            format!("{}\n", lines.join("\n"))
-        };
-        let initial_conflict_text = build_conflict_text(0);
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/project"),
-            json!({
-                ".git": {},
-                "helper.txt": "same\n",
-                "conflict.txt": initial_conflict_text,
-            }),
-        )
-        .await;
-        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
-            state
-                .refs
-                .insert("MERGE_HEAD".into(), "conflict-head".into());
-        })
-        .unwrap();
-        fs.set_status_for_repo(
-            path!("/project/.git").as_ref(),
-            &[(
-                "conflict.txt",
-                FileStatus::Unmerged(UnmergedStatus {
-                    first_head: UnmergedStatusCode::Updated,
-                    second_head: UnmergedStatusCode::Updated,
-                }),
-            )],
-        );
-        fs.set_merge_base_content_for_repo(
-            path!("/project/.git").as_ref(),
-            &[
-                ("conflict.txt", build_conflict_text(1)),
-                ("helper.txt", "same\n".to_string()),
-            ],
-        );
-
-        let project = Project::test(fs.clone(), [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 _project_diff = cx
-            .update(|window, cx| {
-                ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
-            })
-            .await
-            .unwrap();
-        cx.run_until_parked();
-
-        let buffer = project
-            .update(cx, |project, cx| {
-                project.open_local_buffer(path!("/project/conflict.txt"), cx)
-            })
-            .await
-            .unwrap();
-        buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
-        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
-        cx.run_until_parked();
-
-        cx.update(|window, cx| {
-            let fs = fs.clone();
-            window
-                .spawn(cx, async move |cx| {
-                    cx.background_executor().simulate_random_delay().await;
-                    fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
-                        state.refs.insert("HEAD".into(), "head-1".into());
-                        state.refs.remove("MERGE_HEAD");
-                    })
-                    .unwrap();
-                    fs.set_status_for_repo(
-                        path!("/project/.git").as_ref(),
-                        &[
-                            (
-                                "conflict.txt",
-                                FileStatus::Tracked(TrackedStatus {
-                                    index_status: git::status::StatusCode::Modified,
-                                    worktree_status: git::status::StatusCode::Modified,
-                                }),
-                            ),
-                            (
-                                "helper.txt",
-                                FileStatus::Tracked(TrackedStatus {
-                                    index_status: git::status::StatusCode::Modified,
-                                    worktree_status: git::status::StatusCode::Modified,
-                                }),
-                            ),
-                        ],
-                    );
-                    // FakeFs assigns deterministic OIDs by entry position; flipping order churns
-                    // conflict diff identity without reaching into ProjectDiff internals.
-                    fs.set_merge_base_content_for_repo(
-                        path!("/project/.git").as_ref(),
-                        &[
-                            ("helper.txt", "helper-base\n".to_string()),
-                            ("conflict.txt", build_conflict_text(2)),
-                        ],
-                    );
-                })
-                .detach();
-        });
-
-        cx.update(|window, cx| {
-            let buffer = buffer.clone();
-            window
-                .spawn(cx, async move |cx| {
-                    cx.background_executor().simulate_random_delay().await;
-                    for edit_index in 0..10 {
-                        if edit_index > 0 {
-                            cx.background_executor().simulate_random_delay().await;
-                        }
-                        buffer.update(cx, |buffer, cx| {
-                            let len = buffer.len();
-                            if edit_index % 2 == 0 {
-                                buffer.edit(
-                                    [(0..0, format!("status-burst-head-{edit_index}\n"))],
-                                    None,
-                                    cx,
-                                );
-                            } else {
-                                buffer.edit(
-                                    [(len..len, format!("status-burst-tail-{edit_index}\n"))],
-                                    None,
-                                    cx,
-                                );
-                            }
-                        });
-                    }
-                })
-                .detach();
-        });
-
-        cx.run_until_parked();
-    }
-
     #[gpui::test]
     async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2769,7 +1793,7 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
+        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
 
         assert_state_with_diff(
             &editor,
@@ -2944,323 +1968,79 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_branch_diff(cx: &mut TestAppContext) {
+    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
         init_test(cx);
 
+        let git_contents = indoc! {r#"
+            #[rustfmt::skip]
+            fn main() {
+                let x = 0.0; // this line will be removed
+                // 1
+                // 2
+                // 3
+                let y = 0.0; // this line will be removed
+                // 1
+                // 2
+                // 3
+                let arr = [
+                    0.0, // this line will be removed
+                    0.0, // this line will be removed
+                    0.0, // this line will be removed
+                    0.0, // this line will be removed
+                ];
+            }
+        "#};
+        let buffer_contents = indoc! {"
+            #[rustfmt::skip]
+            fn main() {
+                // 1
+                // 2
+                // 3
+                // 1
+                // 2
+                // 3
+                let arr = [
+                ];
+            }
+        "};
+
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
-            path!("/project"),
+            path!("/a"),
             json!({
                 ".git": {},
-                "a.txt": "C",
-                "b.txt": "new",
-                "c.txt": "in-merge-base-and-work-tree",
-                "d.txt": "created-in-head",
+                "main.rs": buffer_contents,
             }),
         )
         .await;
-        let project = Project::test(fs.clone(), [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 diff = cx
-            .update(|window, cx| {
-                ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
-            })
-            .await
-            .unwrap();
-        cx.run_until_parked();
-
-        fs.set_head_for_repo(
-            Path::new(path!("/project/.git")),
-            &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
-            "sha",
-        );
-        // fs.set_index_for_repo(dot_git, index_state);
-        fs.set_merge_base_content_for_repo(
-            Path::new(path!("/project/.git")),
-            &[
-                ("a.txt", "A".into()),
-                ("c.txt", "in-merge-base-and-work-tree".into()),
-            ],
-        );
-        cx.run_until_parked();
-
-        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
-
-        assert_state_with_diff(
-            &editor,
-            cx,
-            &"
-                - A
-                + ˇC
-                + new
-                + created-in-head"
-                .unindent(),
-        );
-
-        let statuses: HashMap, Option> =
-            editor.update(cx, |editor, cx| {
-                editor
-                    .buffer()
-                    .read(cx)
-                    .all_buffers()
-                    .iter()
-                    .map(|buffer| {
-                        (
-                            buffer.read(cx).file().unwrap().path().clone(),
-                            editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
-                        )
-                    })
-                    .collect()
-            });
-
-        assert_eq!(
-            statuses,
-            HashMap::from_iter([
-                (
-                    rel_path("a.txt").into_arc(),
-                    Some(FileStatus::Tracked(TrackedStatus {
-                        index_status: git::status::StatusCode::Modified,
-                        worktree_status: git::status::StatusCode::Modified
-                    }))
-                ),
-                (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
-                (
-                    rel_path("d.txt").into_arc(),
-                    Some(FileStatus::Tracked(TrackedStatus {
-                        index_status: git::status::StatusCode::Added,
-                        worktree_status: git::status::StatusCode::Added
-                    }))
-                )
-            ])
-        );
-    }
-
-    #[gpui::test]
-    async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/project"),
-            json!({
-                ".git": {},
-                "a.txt": "changed",
-            }),
-        )
-        .await;
-        let project = Project::test(fs.clone(), [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 target_branch_diff = cx
-            .update(|window, cx| {
-                let Some(repository) = project.read(cx).active_repository(cx) else {
-                    return Task::ready(Err(anyhow!("No active repository")));
-                };
-                ProjectDiff::new_with_branch_base(
-                    project.clone(),
-                    workspace.clone(),
-                    "topic".into(),
-                    repository,
-                    window,
-                    cx,
-                )
-            })
-            .await
-            .unwrap();
-        workspace.update_in(cx, |workspace, window, cx| {
-            workspace.add_item_to_active_pane(
-                Box::new(target_branch_diff.clone()),
-                None,
-                true,
-                window,
-                cx,
-            );
-        });
-        cx.run_until_parked();
-
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(BranchDiff.boxed_clone(), cx);
-        });
-        cx.run_until_parked();
-
-        let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
-            let active_item = workspace.active_item_as::(cx).unwrap();
-            let active_base_ref = match active_item.read(cx).diff_base(cx) {
-                DiffBase::Merge { base_ref } => base_ref.to_string(),
-                DiffBase::Head => panic!("expected active item to be a branch diff"),
-            };
-            let base_refs = workspace
-                .items_of_type::(cx)
-                .filter_map(|item| match item.read(cx).diff_base(cx) {
-                    DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
-                    DiffBase::Head => None,
-                })
-                .collect::>();
-            (active_base_ref, base_refs)
-        });
-        base_refs.sort();
-
-        assert_eq!(active_base_ref, "origin/main");
-        assert_eq!(base_refs, vec!["origin/main", "topic"]);
-    }
-
-    #[gpui::test]
-    async fn test_update_on_uncommit(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/project"),
-            json!({
-                ".git": {},
-                "README.md": "# My cool project\n".to_owned()
-            }),
-        )
-        .await;
-        fs.set_head_and_index_for_repo(
-            Path::new(path!("/project/.git")),
-            &[("README.md", "# My cool project\n".to_owned())],
-        );
-        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
-        let worktree_id = project.read_with(cx, |project, cx| {
-            project.worktrees(cx).next().unwrap().read(cx).id()
-        });
-        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.run_until_parked();
-
-        let _editor = workspace
-            .update_in(cx, |workspace, window, cx| {
-                workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
-            })
-            .await
-            .unwrap()
-            .downcast::()
-            .unwrap();
-
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
-        });
-        cx.run_until_parked();
-        let item = workspace.update(cx, |workspace, cx| {
-            workspace.active_item_as::(cx).unwrap()
-        });
-        cx.focus(&item);
-        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
 
         fs.set_head_and_index_for_repo(
-            Path::new(path!("/project/.git")),
-            &[(
-                "README.md",
-                "# My cool project\nDetails to come.\n".to_owned(),
-            )],
+            Path::new(path!("/a/.git")),
+            &[("main.rs", git_contents.to_owned())],
         );
+
+        let project = Project::test(fs, [Path::new(path!("/a"))], 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.run_until_parked();
 
+        let diff = cx.new_window_entity(|window, cx| {
+            ProjectDiff::new(project.clone(), workspace, window, cx)
+        });
+        cx.run_until_parked();
+        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
+
         let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
 
-        cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
-    }
+        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
 
-    #[gpui::test]
-    async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) {
-        init_test(cx);
+        cx.dispatch_action(editor::actions::GoToHunk);
+        cx.dispatch_action(editor::actions::GoToHunk);
+        cx.dispatch_action(git::Restore);
+        cx.dispatch_action(editor::actions::MoveToBeginning);
 
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            path!("/project_a"),
-            json!({
-                ".git": {},
-                "a.txt": "CHANGED_A\n",
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            path!("/project_b"),
-            json!({
-                ".git": {},
-                "b.txt": "CHANGED_B\n",
-            }),
-        )
-        .await;
-
-        fs.set_head_and_index_for_repo(
-            Path::new(path!("/project_a/.git")),
-            &[("a.txt", "original_a\n".to_string())],
-        );
-        fs.set_head_and_index_for_repo(
-            Path::new(path!("/project_b/.git")),
-            &[("b.txt", "original_b\n".to_string())],
-        );
-
-        let project = Project::test(
-            fs.clone(),
-            [
-                Path::new(path!("/project_a")),
-                Path::new(path!("/project_b")),
-            ],
-            cx,
-        )
-        .await;
-
-        let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
-            let mut worktrees: Vec<_> = project.worktrees(cx).collect();
-            worktrees.sort_by_key(|w| w.read(cx).abs_path());
-            (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
-        });
-
-        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.run_until_parked();
-
-        // Select project A explicitly and open the diff.
-        workspace.update(cx, |workspace, cx| {
-            let git_store = workspace.project().read(cx).git_store().clone();
-            git_store.update(cx, |git_store, cx| {
-                git_store.set_active_repo_for_worktree(worktree_a_id, cx);
-            });
-        });
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
-        });
-        cx.run_until_parked();
-
-        let diff_item = workspace.update(cx, |workspace, cx| {
-            workspace.active_item_as::(cx).unwrap()
-        });
-        let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
-        assert_eq!(paths_a.len(), 1);
-        assert_eq!(*paths_a[0], *"a.txt");
-
-        // Switch the explicit active repository to project B and re-run the diff action.
-        workspace.update(cx, |workspace, cx| {
-            let git_store = workspace.project().read(cx).git_store().clone();
-            git_store.update(cx, |git_store, cx| {
-                git_store.set_active_repo_for_worktree(worktree_b_id, cx);
-            });
-        });
-        cx.focus(&workspace);
-        cx.update(|window, cx| {
-            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
-        });
-        cx.run_until_parked();
-
-        let same_diff_item = workspace.update(cx, |workspace, cx| {
-            workspace.active_item_as::(cx).unwrap()
-        });
-        assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
-
-        let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
-        assert_eq!(paths_b.len(), 1);
-        assert_eq!(*paths_b[0], *"b.txt");
+        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
     }
 }
diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs
index 157ce831677..3c9921c0f96 100644
--- a/crates/git_ui/src/remote_output.rs
+++ b/crates/git_ui/src/remote_output.rs
@@ -4,6 +4,17 @@ use git::repository::{Remote, RemoteCommandOutput};
 use ui::SharedString;
 use util::ResultExt as _;
 
+const PULL_REQUEST_HINTS: &[(&str, &str)] = &[
+    // GitHub: "Create a pull request for 'branch' on GitHub by visiting:"
+    ("Create a pull request", "Create Pull Request"),
+    // Bitbucket: "Create pull request for branch:"
+    ("Create pull request", "Create Pull Request"),
+    // GitLab: "To create a merge request for branch, visit:"
+    ("create a merge request", "Create Merge Request"),
+    // GitLab: "View merge request for branch:"
+    ("View merge request", "View Merge Request"),
+];
+
 #[derive(Clone)]
 pub enum RemoteAction {
     Fetch(Option),
@@ -24,6 +35,7 @@ impl RemoteAction {
 pub enum SuccessStyle {
     Toast,
     ToastWithLog { output: RemoteCommandOutput },
+    PushPrLink { label: &'static str, url: String },
 }
 
 pub struct SuccessMessage {
@@ -31,6 +43,42 @@ pub struct SuccessMessage {
     pub style: SuccessStyle,
 }
 
+fn extract_pull_request_link(output: &RemoteCommandOutput) -> Option<(&'static str, String)> {
+    let mut pending_label: Option<&'static str> = None;
+
+    for line in output.stderr.lines() {
+        let Some(remote_line) = line.trim_start().strip_prefix("remote:") else {
+            pending_label = None;
+            continue;
+        };
+
+        if let Some((_, label)) = PULL_REQUEST_HINTS
+            .iter()
+            .find(|(hint, _)| remote_line.contains(hint))
+        {
+            pending_label = Some(label);
+        }
+
+        if let Some(url) = extract_url(remote_line)
+            && let Some(label) = pending_label
+        {
+            return Some((label, url));
+        }
+    }
+
+    None
+}
+
+fn extract_url(line: &str) -> Option {
+    let http_index = line.find("https://").or_else(|| line.find("http://"))?;
+    let url = line[http_index..]
+        .split_whitespace()
+        .next()?
+        .trim_end_matches(|character| matches!(character, ',' | '.' | ')' | ']' | '>'));
+
+    Some(url.to_string())
+}
+
 pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
     match action {
         RemoteAction::Fetch(remote) => {
@@ -122,6 +170,11 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ
                     message: "Push: Everything is up-to-date".to_string(),
                     style: SuccessStyle::Toast,
                 }
+            } else if let Some((label, url)) = extract_pull_request_link(&output) {
+                SuccessMessage {
+                    message: format!("Pushed {} to {}", branch_name, remote_ref.name),
+                    style: SuccessStyle::PushPrLink { label, url },
+                }
             } else {
                 SuccessMessage {
                     message: format!("Pushed {} to {}", branch_name, remote_ref.name),
@@ -161,9 +214,13 @@ mod tests {
         };
 
         let msg = format_output(&action, output);
-
-        assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. }));
-        assert_eq!(msg.message, "Pushed test_branch to test_remote");
+        if let SuccessStyle::PushPrLink { label, url } = msg.style {
+            assert_eq!(msg.message, "Pushed test_branch to test_remote");
+            assert_eq!(label, "Create Pull Request");
+            assert_eq!(url, "https://example.com/test/test/pull/new/test");
+        } else {
+            panic!("Expected PushPrLink variant");
+        }
     }
 
     #[test]
@@ -191,8 +248,37 @@ mod tests {
 
         let msg = format_output(&action, output);
 
-        assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. }));
-        assert_eq!(msg.message, "Pushed test_branch to test_remote");
+        if let SuccessStyle::PushPrLink { label, url } = msg.style {
+            assert_eq!(msg.message, "Pushed test_branch to test_remote");
+            assert_eq!(label, "Create Merge Request");
+            assert_eq!(
+                url,
+                "https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test"
+            )
+        } else {
+            panic!("Expected PushPrLink variant")
+        }
+    }
+
+    #[test]
+    fn test_push_new_branch_bitbucket_pull_request() {
+        let output = RemoteCommandOutput {
+            stdout: String::new(),
+            stderr: indoc! {"
+                remote:
+                remote: Create pull request for test:
+                remote:   https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test
+                "}
+            .to_string(),
+        };
+
+        assert_eq!(
+            extract_pull_request_link(&output),
+            Some((
+                "Create Pull Request",
+                "https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test".to_string()
+            ))
+        );
     }
 
     #[test]
@@ -206,7 +292,9 @@ mod tests {
 
         let output = RemoteCommandOutput {
             stdout: String::new(),
-            // Simulate an extraneous link that should not be found in top 3 lines
+            // Include an unrelated URL outside of the `remote:` lines, in this
+            // case, an OpenSSH warning, to ensure that it is not mistaken for
+            // the merge request link.
             stderr: indoc! {"
                 ** WARNING: connection is not using a post-quantum key exchange algorithm.
                 ** This session may be vulnerable to \"store now, decrypt later\" attacks.
@@ -224,8 +312,13 @@ mod tests {
 
         let msg = format_output(&action, output);
 
-        assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. }));
-        assert_eq!(msg.message, "Pushed test_branch to test_remote");
+        if let SuccessStyle::PushPrLink { label, url } = msg.style {
+            assert_eq!(msg.message, "Pushed test_branch to test_remote");
+            assert_eq!(label, "View Merge Request");
+            assert_eq!(url, "https://example.com/test/test/-/merge_requests/99999");
+        } else {
+            panic!("Expected PushPrLink variant")
+        }
     }
 
     #[test]
@@ -254,6 +347,7 @@ mod tests {
                 output.stderr,
                 "To http://example.com/test/test.git\n * [new branch]      test -> test\n"
             );
+            assert_eq!(extract_pull_request_link(output), None);
         } else {
             panic!("Expected ToastWithLog variant");
         }
diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs
index 90bde497fa1..9ada614cfcf 100644
--- a/crates/git_ui/src/solo_diff_view.rs
+++ b/crates/git_ui/src/solo_diff_view.rs
@@ -1,18 +1,19 @@
-use crate::{git_panel::GitStatusEntry, git_status_icon};
+use crate::{git_panel::GitStatusEntry, git_panel_settings::GitPanelSettings, git_status_icon};
 use anyhow::{Context as _, Result};
 use buffer_diff::DiffHunkSecondaryStatus;
 use editor::{
-    Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff,
+    DiffStyleControls, Direction, Editor, EditorEvent, EditorSettings, SplittableEditor,
+    ToggleSplitDiff,
     actions::{GoToHunk, GoToPreviousHunk},
+    file_status_label_color,
 };
-use fs::Fs;
 use git::{
     Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
     repository::RepoPath, status::StageStatus,
 };
 use gpui::{
-    Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window,
+    Action, AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle,
+    Focusable, HighlightStyle, IntoElement, Render, Subscription, Task, WeakEntity, Window,
 };
 use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point};
 use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines};
@@ -20,16 +21,13 @@ use project::{
     Project,
     git_store::{Repository, RepositoryId},
 };
-use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file};
+use settings::{Settings, SettingsStore, StatusStyle};
 use std::{
     any::{Any, TypeId},
     ops::Range,
     sync::Arc,
 };
-use ui::{
-    Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _,
-    SharedString, Tooltip, prelude::*, vertical_divider,
-};
+use ui::{DiffStat, Divider, Tooltip, prelude::*};
 use util::paths::{PathExt as _, PathStyle};
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
@@ -479,16 +477,31 @@ impl Item for SoloDiffView {
     }
 
     fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> {
+        let text: SharedString = self
+            .repo_path
+            .as_ref()
+            .display(PathStyle::local())
+            .into_owned()
+            .into();
+
+        // When the git panel is set to convey status via label color rather
+        // than an icon, tint the whole path like multibuffer headers do.
+        let mut highlights = Vec::new();
+        if GitPanelSettings::get_global(cx).status_style == StatusStyle::LabelColor
+            && let Some(status) = self
+                .repository
+                .read(cx)
+                .status_for_path(&self.repo_path)
+                .map(|entry| entry.status)
+        {
+            highlights.push((
+                0..text.len(),
+                HighlightStyle::color(file_status_label_color(Some(status)).color(cx)),
+            ));
+        }
+
         Some((
-            vec![HighlightedText {
-                text: self
-                    .repo_path
-                    .as_ref()
-                    .display(PathStyle::local())
-                    .into_owned()
-                    .into(),
-                highlights: Vec::new(),
-            }],
+            vec![HighlightedText { text, highlights }],
             Some(
                 theme_settings::ThemeSettings::get_global(cx)
                     .buffer_font
@@ -548,43 +561,6 @@ impl SoloDiffStyleToolbar {
         self.solo_diff.as_ref()?.upgrade()
     }
 
-    fn set_diff_view_style(
-        &mut self,
-        diff_view_style: DiffViewStyle,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let Some(solo_diff) = self.solo_diff() else {
-            return;
-        };
-        let workspace = solo_diff.read(cx).workspace.clone();
-
-        update_settings_file(::global(cx), cx, move |settings, _| {
-            settings.editor.diff_view_style = Some(diff_view_style);
-        });
-
-        if let Some(workspace) = workspace.upgrade() {
-            let splittable_editors = {
-                workspace
-                    .read(cx)
-                    .items(cx)
-                    .filter_map(|item| item.act_as_type(TypeId::of::(), cx))
-                    .filter_map(|item| item.downcast::().ok())
-                    .collect::>()
-            };
-
-            for editor in splittable_editors {
-                editor.update(cx, |editor, cx| {
-                    if editor.diff_view_style() != diff_view_style {
-                        editor.toggle_split(&ToggleSplitDiff, window, cx);
-                    }
-                });
-            }
-        }
-
-        cx.notify();
-    }
-
     fn toggle_showing_full_file(&mut self, cx: &mut Context) {
         if let Some(solo_diff) = self.solo_diff() {
             solo_diff.update(cx, |solo_diff, cx| {
@@ -617,64 +593,49 @@ impl ToolbarItemView for SoloDiffStyleToolbar {
 impl Render for SoloDiffStyleToolbar {
     fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
         let Some(solo_diff) = self.solo_diff() else {
-            return div();
+            return Empty.into_any_element();
         };
-        let (editor_entity, showing_full_file) = {
+
+        let (editor_entity, showing_full_file, status) = {
             let solo_diff = solo_diff.read(cx);
-            (solo_diff.editor.clone(), solo_diff.showing_full_file)
+            (
+                solo_diff.editor.clone(),
+                solo_diff.showing_full_file,
+                solo_diff
+                    .repository
+                    .read(cx)
+                    .status_for_path(&solo_diff.repo_path)
+                    .map(|entry| entry.status),
+            )
         };
-        let editor = editor_entity.read(cx);
-        let diff_view_style = editor.diff_view_style();
-        let is_split_set = diff_view_style == DiffViewStyle::Split;
-        let split_icon = if is_split_set && !editor.is_split() {
-            IconName::DiffSplitAuto
+
+        let show_status_icon =
+            GitPanelSettings::get_global(cx).status_style != StatusStyle::LabelColor;
+
+        let (expand_icon, expand_tooltip) = if showing_full_file {
+            (IconName::ChevronDownUp, "Show Changes Only")
         } else {
-            IconName::DiffSplit
+            (IconName::ChevronUpDown, "Show Full File")
         };
 
         h_flex()
-            .h_8()
-            .items_center()
+            .pl_0p5()
             .gap_1()
             .child(
-                IconButton::new(
-                    "solo-diff-toggle-excerpts",
-                    if showing_full_file {
-                        IconName::ChevronDownUp
-                    } else {
-                        IconName::ChevronUpDown
-                    },
-                )
-                .icon_size(IconSize::Small)
-                .tooltip(Tooltip::text(if showing_full_file {
-                    "Show Changes Only"
-                } else {
-                    "Show Full File"
-                }))
-                .on_click(cx.listener(|this, _, _, cx| {
-                    this.toggle_showing_full_file(cx);
-                })),
-            )
-            .child(
-                IconButton::new("solo-diff-unified", IconName::DiffUnified)
+                IconButton::new("solo-diff-toggle-excerpts", expand_icon)
                     .icon_size(IconSize::Small)
-                    .toggle_state(diff_view_style == DiffViewStyle::Unified)
-                    .tooltip(Tooltip::text("Unified"))
-                    .on_click(cx.listener(|this, _, window, cx| {
-                        this.set_diff_view_style(DiffViewStyle::Unified, window, cx);
+                    .tooltip(Tooltip::text(expand_tooltip))
+                    .on_click(cx.listener(|this, _, _, cx| {
+                        this.toggle_showing_full_file(cx);
                     })),
             )
-            .child(
-                IconButton::new("solo-diff-split", split_icon)
-                    .icon_size(IconSize::Small)
-                    .toggle_state(diff_view_style == DiffViewStyle::Split)
-                    .tooltip(Tooltip::text("Split"))
-                    .on_click(cx.listener(|this, _, window, cx| {
-                        this.set_diff_view_style(DiffViewStyle::Split, window, cx);
-                    })),
+            .child(DiffStyleControls::new(editor_entity))
+            .child(Divider::vertical().mr_1())
+            .when_some(
+                show_status_icon.then_some(status).flatten(),
+                |this, status| this.child(git_status_icon(status)),
             )
-            .child(vertical_divider())
-            .child(div().w_1())
+            .into_any_element()
     }
 }
 
@@ -745,8 +706,9 @@ struct SoloDiffButtonStates {
 impl Render for SoloDiffGitToolbar {
     fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
         let Some(solo_diff) = self.solo_diff() else {
-            return div();
+            return gpui::Empty.into_any_element();
         };
+
         let focus_handle = solo_diff.focus_handle(cx);
         let solo_diff = solo_diff.read(cx);
         let button_states = solo_diff.button_states(cx);
@@ -754,32 +716,59 @@ impl Render for SoloDiffGitToolbar {
             .repository
             .read(cx)
             .status_for_path(&solo_diff.repo_path);
-        let status = status_entry.as_ref().map(|entry| entry.status);
         let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
 
-        h_group_xl()
+        h_flex()
             .my_neg_1()
             .py_1()
-            .items_center()
+            .gap_1p5()
             .flex_wrap()
             .justify_between()
-            .children(status.map(|status| git_status_icon(status).into_any_element()))
             .children(diff_stat.map(|stat| {
                 DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
-                    .into_any_element()
             }))
+            .child(Divider::vertical().ml_1())
+            .child(
+                h_group_sm()
+                    .child(
+                        IconButton::new("up", IconName::ArrowUp)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Previous Hunk",
+                                &GoToPreviousHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToPreviousHunk, window, cx)
+                            })),
+                    )
+                    .child(
+                        IconButton::new("down", IconName::ArrowDown)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Next Hunk",
+                                &GoToHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToHunk, window, cx)
+                            })),
+                    ),
+            )
             .child(Divider::vertical())
             .child(
                 h_group_sm()
                     .when(button_states.selection, |el| {
                         el.child(
                             Button::new("stage", "Toggle Staged")
+                                .disabled(!button_states.stage && !button_states.unstage)
                                 .tooltip(Tooltip::for_action_title_in(
                                     "Toggle Staged",
                                     &ToggleStaged,
                                     &focus_handle,
                                 ))
-                                .disabled(!button_states.stage && !button_states.unstage)
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.dispatch_action(&ToggleStaged, window, cx)
                                 })),
@@ -788,24 +777,24 @@ impl Render for SoloDiffGitToolbar {
                     .when(!button_states.selection, |el| {
                         el.child(
                             Button::new("stage", "Stage")
+                                .disabled(!button_states.stage)
                                 .tooltip(Tooltip::for_action_title_in(
-                                    "Stage and go to next hunk",
+                                    "Stage and Go to Next Hunk",
                                     &StageAndNext,
                                     &focus_handle,
                                 ))
-                                .disabled(!button_states.stage)
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.dispatch_action(&StageAndNext, window, cx)
                                 })),
                         )
                         .child(
                             Button::new("unstage", "Unstage")
+                                .disabled(!button_states.unstage)
                                 .tooltip(Tooltip::for_action_title_in(
-                                    "Unstage and go to next hunk",
+                                    "Unstage and Go to Next Hunk",
                                     &UnstageAndNext,
                                     &focus_handle,
                                 ))
-                                .disabled(!button_states.unstage)
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.dispatch_action(&UnstageAndNext, window, cx)
                                 })),
@@ -824,72 +813,40 @@ impl Render for SoloDiffGitToolbar {
                             })),
                     ),
             )
+            .child(Divider::vertical())
+            .child(h_group_sm().child(if button_states.stage_file {
+                Button::new("stage-file", "Stage All")
+                    .width(rems_from_px(80.))
+                    .disabled(!button_states.stage_file)
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Stage All",
+                        &StageFile,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| this.stage_file(window, cx)))
+            } else {
+                Button::new("unstage-file", "Unstage All")
+                    .width(rems_from_px(80.))
+                    .disabled(!button_states.unstage_file)
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Unstage All",
+                        &UnstageFile,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| this.unstage_file(window, cx)))
+            }))
+            .child(Divider::vertical())
             .child(
-                h_group_sm()
-                    .child(
-                        IconButton::new("up", IconName::ArrowUp)
-                            .shape(IconButtonShape::Square)
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Go to previous hunk",
-                                &GoToPreviousHunk,
-                                &focus_handle,
-                            ))
-                            .disabled(!button_states.prev_next)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&GoToPreviousHunk, window, cx)
-                            })),
-                    )
-                    .child(
-                        IconButton::new("down", IconName::ArrowDown)
-                            .shape(IconButtonShape::Square)
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Go to next hunk",
-                                &GoToHunk,
-                                &focus_handle,
-                            ))
-                            .disabled(!button_states.prev_next)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&GoToHunk, window, cx)
-                            })),
-                    ),
-            )
-            .child(vertical_divider())
-            .child(
-                h_group_sm()
-                    .child(if button_states.stage_file {
-                        Button::new("stage-file", "Stage File")
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Stage file",
-                                &StageFile,
-                                &focus_handle,
-                            ))
-                            .disabled(!button_states.stage_file)
-                            .on_click(
-                                cx.listener(|this, _, window, cx| this.stage_file(window, cx)),
-                            )
-                    } else {
-                        Button::new("unstage-file", "Unstage File")
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Unstage file",
-                                &UnstageFile,
-                                &focus_handle,
-                            ))
-                            .disabled(!button_states.unstage_file)
-                            .on_click(
-                                cx.listener(|this, _, window, cx| this.unstage_file(window, cx)),
-                            )
-                    })
-                    .child(
-                        Button::new("commit", "Commit")
-                            .tooltip(Tooltip::for_action_title_in(
-                                "Commit",
-                                &Commit,
-                                &focus_handle,
-                            ))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&Commit, window, cx);
-                            })),
-                    ),
+                Button::new("commit", "Commit")
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Commit",
+                        &Commit,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.dispatch_action(&Commit, window, cx);
+                    })),
             )
+            .into_any_element()
     }
 }
diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs
new file mode 100644
index 00000000000..9b607c72cbc
--- /dev/null
+++ b/crates/git_ui/src/staged_diff.rs
@@ -0,0 +1,1090 @@
+use crate::{
+    diff_multibuffer::DiffMultibuffer,
+    git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
+};
+use anyhow::{Context as _, Result};
+use buffer_diff::DiffHunkStatus;
+use editor::{
+    DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor,
+    actions::{GoToHunk, GoToPreviousHunk},
+};
+use git::{Commit, UnstageAll, UnstageAndNext};
+use gpui::{
+    Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    SharedString, Subscription, Task, WeakEntity,
+};
+use language::Capability;
+use project::{
+    Project, ProjectPath,
+    git_store::diff_buffer_list::{DiffBase, DiffBufferList},
+    project_settings::ProjectSettings,
+};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    ops::Range,
+    sync::Arc,
+};
+use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*};
+use util::ResultExt as _;
+use workspace::{
+    ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    Workspace,
+    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
+    searchable::SearchableItemHandle,
+};
+
+pub(crate) struct StagedDiffDelegate;
+
+impl DiffHunkDelegate for StagedDiffDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.stage_or_unstage(false, hunks, editor, window, cx);
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        editor: &mut Editor,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if stage {
+            return;
+        }
+        let Some(project) = editor.project().cloned() else {
+            return;
+        };
+        for hunks in hunks {
+            let index_ranges = hunks
+                .hunks
+                .into_iter()
+                .map(|hunk| hunk.buffer_range)
+                .collect::>();
+            if index_ranges.is_empty() {
+                continue;
+            }
+            project
+                .update(cx, |project, cx| {
+                    project.unstage_staged_hunks(hunks.diff, index_ranges, cx)
+                })
+                .log_err();
+        }
+    }
+
+    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 {
+        if !ProjectSettings::get_global(cx)
+            .git
+            .show_stage_restore_buttons
+        {
+            return gpui::Empty.into_any_element();
+        }
+        let hunk_range = hunk_range.start..hunk_range.start;
+        h_flex()
+            .h(line_height)
+            .mr_1()
+            .gap_1()
+            .px_0p5()
+            .pb_1()
+            .border_x_1()
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .rounded_b_lg()
+            .bg(cx.theme().colors().editor_background)
+            .block_mouse_except_scroll()
+            .shadow_md()
+            .child(
+                Button::new(("unstage", row as u64), "Unstage")
+                    .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+                    .tooltip(Tooltip::text("Unstage Hunk"))
+                    .on_click({
+                        let editor = editor.clone();
+                        move |_event, window, cx| {
+                            editor.update(cx, |editor, cx| {
+                                editor.stage_or_unstage_diff_hunks(
+                                    false,
+                                    vec![hunk_range.clone()],
+                                    window,
+                                    cx,
+                                );
+                            });
+                        }
+                    }),
+            )
+            .into_any_element()
+    }
+}
+
+/// The workspace item for the staged diff. It wraps a single read-only
+/// [`DiffMultibuffer`] over [`DiffBase::Staged`] and delegates the [`Item`]
+/// surface to it.
+pub struct StagedDiff {
+    diff: Entity,
+    project: Entity,
+    workspace: WeakEntity,
+    _diff_event_subscription: Subscription,
+}
+
+impl StagedDiff {
+    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) {
+        let _ = workspace;
+        workspace::register_serializable_item::(cx);
+    }
+
+    pub fn deploy_at(
+        workspace: &mut Workspace,
+        entry: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        telemetry::event!(
+            "Git Staged Diff Opened",
+            source = if entry.is_some() {
+                "Git Panel"
+            } else {
+                "Action"
+            }
+        );
+        let intended_repo = workspace.project().read(cx).active_repository(cx);
+        let existing = workspace.items_of_type::(cx).next();
+        let staged_diff = if let Some(existing) = existing {
+            workspace.activate_item(&existing, true, true, window, cx);
+            existing
+        } else {
+            let workspace_handle = cx.entity();
+            let staged_diff =
+                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
+            workspace.add_item_to_active_pane(
+                Box::new(staged_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+            staged_diff
+        };
+
+        if let Some(intended) = &intended_repo {
+            let needs_switch = staged_diff
+                .read(cx)
+                .diff
+                .read(cx)
+                .repo(cx)
+                .map_or(true, |current| current.entity_id() != intended.entity_id());
+            if needs_switch {
+                staged_diff.update(cx, |staged_diff, cx| {
+                    staged_diff.diff.update(cx, |diff, cx| {
+                        diff.set_repo(Some(intended.clone()), cx);
+                    });
+                });
+            }
+        }
+
+        if let Some(entry) = entry {
+            staged_diff.update(cx, |staged_diff, cx| {
+                staged_diff
+                    .diff
+                    .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
+            });
+        }
+    }
+
+    pub(crate) fn new(
+        project: Entity,
+        workspace: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        let branch_diff =
+            cx.new(|cx| DiffBufferList::new(DiffBase::Staged, project.clone(), window, cx));
+        let workspace_handle = workspace.downgrade();
+        let diff = cx.new(|cx| {
+            DiffMultibuffer::new(
+                branch_diff,
+                Capability::ReadOnly,
+                "No staged changes",
+                move |editor, cx| {
+                    editor.set_diff_hunk_delegate(Some(Arc::new(StagedDiffDelegate)), cx);
+                    editor.rhs_editor().update(cx, |rhs_editor, _cx| {
+                        rhs_editor.set_read_only(true);
+                        rhs_editor.register_addon(GitPanelAddon {
+                            workspace: workspace_handle,
+                        });
+                    });
+                },
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            )
+        });
+        Self::from_diff(diff, project, workspace, cx)
+    }
+
+    pub(crate) fn from_diff(
+        diff: Entity,
+        project: Entity,
+        workspace: Entity,
+        cx: &mut Context,
+    ) -> Self {
+        let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
+            cx.emit(event.clone())
+        });
+
+        Self {
+            diff,
+            project,
+            workspace: workspace.downgrade(),
+            _diff_event_subscription: diff_event_subscription,
+        }
+    }
+
+    fn button_states(&self, cx: &App) -> ButtonStates {
+        let diff = self.diff.read(cx);
+        let editor = diff.editor().read(cx).rhs_editor().clone();
+        let editor = editor.read(cx);
+        let snapshot = diff.multibuffer().read(cx).snapshot(cx);
+        let prev_next = snapshot.diff_hunks().nth(1).is_some();
+        let (selection, ranges) = diff.selected_ranges(cx);
+        let unstage = editor
+            .diff_hunks_in_ranges(&ranges, &snapshot)
+            .next()
+            .is_some();
+        let mut unstage_all = false;
+        self.workspace
+            .read_with(cx, |workspace, cx| {
+                if let Some(git_panel) = workspace.panel::(cx) {
+                    unstage_all = git_panel.read(cx).can_unstage_all();
+                }
+            })
+            .ok();
+
+        ButtonStates {
+            unstage,
+            prev_next,
+            selection,
+            unstage_all,
+        }
+    }
+
+    fn unstage_selected_staged_hunks(
+        &mut self,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.stage_or_unstage_selected_hunks(false, move_to_next, window, cx)
+        });
+    }
+}
+
+struct ButtonStates {
+    unstage: bool,
+    prev_next: bool,
+    selection: bool,
+    unstage_all: bool,
+}
+
+impl EventEmitter for StagedDiff {}
+
+impl Focusable for StagedDiff {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.diff.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for StagedDiff {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
+        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
+    }
+
+    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Arc,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> bool {
+        self.diff
+            .update(cx, |diff, cx| diff.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option {
+        Some("Staged Changes".into())
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(0, _cx))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        "Staged Changes".into()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Git Staged Diff Opened")
+    }
+
+    fn as_searchable(&self, _: &Entity, cx: &App) -> Option> {
+        Some(Box::new(self.diff.read(cx).editor().clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.diff.read(cx).for_each_project_item(cx, f);
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff
+            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
+    }
+
+    fn can_split(&self) -> bool {
+        true
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task>>
+    where
+        Self: Sized,
+    {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(None);
+        };
+        let project = self.project.clone();
+        Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx))))
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.diff.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.diff.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _: &App) -> bool {
+        false
+    }
+
+    fn save(
+        &mut self,
+        _: SaveOptions,
+        _: Entity,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Task> {
+        Task::ready(Ok(()))
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity,
+        _: ProjectPath,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Task> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.reload(project, window, cx))
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity,
+        cx: &'a App,
+    ) -> Option {
+        if type_id == TypeId::of::() {
+            Some(self_handle.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(
+                self.diff
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .rhs_editor()
+                    .clone()
+                    .into(),
+            )
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).editor().clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).branch_diff().clone().into())
+        } else {
+            None
+        }
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl SerializableItem for StagedDiff {
+    fn serialized_item_kind() -> &'static str {
+        "StagedDiff"
+    }
+
+    fn cleanup(
+        _: workspace::WorkspaceId,
+        _: Vec,
+        _: &mut Window,
+        _: &mut App,
+    ) -> Task> {
+        Task::ready(Ok(()))
+    }
+
+    fn deserialize(
+        project: Entity,
+        workspace: WeakEntity,
+        _: workspace::WorkspaceId,
+        _: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        window.spawn(cx, async move |cx| {
+            let workspace = workspace.upgrade().context("workspace gone")?;
+            cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))?
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        _: &mut Workspace,
+        _: workspace::ItemId,
+        _: bool,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Option>> {
+        Some(Task::ready(Ok(())))
+    }
+
+    fn should_serialize(&self, _: &Self::Event) -> bool {
+        false
+    }
+}
+
+impl Render for StagedDiff {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+        self.diff.clone()
+    }
+}
+
+pub struct StagedDiffToolbar {
+    staged_diff: Option>,
+    workspace: WeakEntity,
+}
+
+impl StagedDiffToolbar {
+    pub fn new(workspace: &Workspace, _: &mut Context) -> Self {
+        Self {
+            staged_diff: None,
+            workspace: workspace.weak_handle(),
+        }
+    }
+
+    fn staged_diff(&self, _: &App) -> Option> {
+        self.staged_diff.as_ref()?.upgrade()
+    }
+
+    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) {
+        if let Some(staged_diff) = self.staged_diff(cx) {
+            staged_diff.focus_handle(cx).focus(window, cx);
+        }
+        let action = action.boxed_clone();
+        cx.defer(move |cx| {
+            cx.dispatch_action(action.as_ref());
+        })
+    }
+
+    fn unstage_selected_staged_hunks(
+        &mut self,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(staged_diff) = self.staged_diff(cx) else {
+            return;
+        };
+        staged_diff.update(cx, |staged_diff, cx| {
+            staged_diff.unstage_selected_staged_hunks(move_to_next, window, cx);
+        });
+    }
+
+    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context) {
+        self.workspace
+            .update(cx, |workspace, cx| {
+                let Some(panel) = workspace.panel::(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.unstage_all(&Default::default(), window, cx);
+                });
+            })
+            .ok();
+    }
+}
+
+impl EventEmitter for StagedDiffToolbar {}
+
+impl ToolbarItemView for StagedDiffToolbar {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _: &mut Window,
+        cx: &mut Context,
+    ) -> ToolbarItemLocation {
+        self.staged_diff = active_pane_item
+            .and_then(|item| item.act_as::(cx))
+            .map(|entity| entity.downgrade());
+        if self.staged_diff.is_some() {
+            ToolbarItemLocation::PrimaryRight
+        } else {
+            ToolbarItemLocation::Hidden
+        }
+    }
+
+    fn pane_focus_update(
+        &mut self,
+        _pane_focused: bool,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+}
+
+impl Render for StagedDiffToolbar {
+    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let Some(staged_diff) = self.staged_diff(cx) else {
+            return div();
+        };
+        let focus_handle = staged_diff.focus_handle(cx);
+        let button_states = staged_diff.read(cx).button_states(cx);
+
+        let diff = staged_diff.read(cx).diff.read(cx);
+        let (additions, deletions) = diff.calculate_changed_lines(cx);
+        let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty();
+
+        h_flex()
+            .my_neg_1()
+            .py_1()
+            .gap_1p5()
+            .flex_wrap()
+            .justify_between()
+            .when(!is_multibuffer_empty, |this| {
+                this.child(DiffStat::new(
+                    "staged-diff-stat",
+                    additions as usize,
+                    deletions as usize,
+                ))
+                .child(Divider::vertical().ml_1())
+            })
+            // n.b. the only reason these arrows are here is because we don't
+            // support "undo" for staging so we need a way to go back.
+            .child(
+                h_group_sm()
+                    .child(
+                        IconButton::new("up", IconName::ArrowUp)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Previous Hunk",
+                                &GoToPreviousHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToPreviousHunk, window, cx)
+                            })),
+                    )
+                    .child(
+                        IconButton::new("down", IconName::ArrowDown)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Next Hunk",
+                                &GoToHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToHunk, window, cx)
+                            })),
+                    ),
+            )
+            .child(Divider::vertical())
+            .child(
+                h_group_sm()
+                    .when(button_states.selection, |this| {
+                        this.child(
+                            Button::new("unstage", "Unstage")
+                                .disabled(!button_states.unstage)
+                                .tooltip(Tooltip::text("Unstage Selected Hunks"))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.unstage_selected_staged_hunks(false, window, cx)
+                                })),
+                        )
+                    })
+                    .when(!button_states.selection, |this| {
+                        this.child(
+                            Button::new("unstage", "Unstage")
+                                .disabled(!button_states.unstage)
+                                .tooltip(Tooltip::for_action_title_in(
+                                    "Unstage and Go to Next Hunk",
+                                    &UnstageAndNext,
+                                    &focus_handle,
+                                ))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.unstage_selected_staged_hunks(true, window, cx)
+                                })),
+                        )
+                    }),
+            )
+            .child(Divider::vertical())
+            .child(
+                Button::new("unstage-all", "Unstage All")
+                    .width(rems_from_px(80.))
+                    .disabled(!button_states.unstage_all)
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Unstage All Changes",
+                        &UnstageAll,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| this.unstage_all(window, cx))),
+            )
+            .child(Divider::vertical())
+            .child(
+                Button::new("commit", "Commit")
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Commit",
+                        &Commit,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.dispatch_action(&Commit, window, cx);
+                    })),
+            )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::project_diff::{self, ProjectDiff};
+    use git::repository::RepoPath;
+    use gpui::{Action as _, TestAppContext};
+    use language::Point;
+    use project::{FakeFs, Fs as _};
+    use serde_json::json;
+    use settings::{DiffViewStyle, SettingsStore};
+    use std::path::Path;
+    use unindent::Unindent as _;
+    use util::{path, rel_path::rel_path};
+    use workspace::MultiWorkspace;
+
+    use super::*;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            cx.update_global::(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
+                });
+            });
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+            editor::init(cx);
+            crate::init(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_staged_changes_deploy_as_a_separate_staged_diff_item(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let committed_contents = r#"
+            fn main() {
+                println!("hello world");
+            }
+        "#
+        .unindent();
+        let staged_contents = r#"
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+        let file_contents = r#"
+            // print goodbye
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "src": {
+                    "main.rs": file_contents.clone(),
+                }
+            }),
+        )
+        .await;
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", committed_contents)],
+            "deadbeef",
+        );
+        fs.set_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", staged_contents.clone())],
+        );
+
+        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, window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        cx.run_until_parked();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        let uncommitted_item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::(cx).unwrap()
+        });
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            StagedDiff::deploy_at(workspace, None, window, cx);
+        });
+        cx.run_until_parked();
+
+        workspace.update(cx, |workspace, cx| {
+            let staged_diff = workspace.active_item_as::(cx).unwrap();
+            assert_ne!(staged_diff.entity_id(), uncommitted_item.entity_id());
+            let staged_item = workspace
+                .active_item(cx)
+                .unwrap()
+                .act_as::(cx)
+                .unwrap();
+            assert_ne!(staged_item.entity_id(), uncommitted_item.entity_id());
+            assert_eq!(
+                staged_item.read_with(cx, |diff, cx| diff.diff_base(cx).clone()),
+                DiffBase::Staged
+            );
+            assert!(staged_item.read_with(cx, |diff, cx| diff.multibuffer().read(cx).read_only()));
+            assert_eq!(workspace.items_of_type::(cx).count(), 1);
+            assert_eq!(workspace.items_of_type::(cx).count(), 1);
+
+            let active_item = workspace.active_item(cx).unwrap();
+            assert!(active_item.act_as::(cx).is_some());
+            assert!(active_item.act_as::(cx).is_some());
+            assert_eq!(
+                active_item
+                    .to_serializable_item_handle(cx)
+                    .unwrap()
+                    .serialized_item_kind(),
+                "StagedDiff"
+            );
+            assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes");
+            assert!(!active_item.can_save(cx));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_toggle_staged_unstages_from_staged_view(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let committed_contents = r#"
+            fn main() {
+                println!("hello world");
+            }
+        "#
+        .unindent();
+        let staged_contents = r#"
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+        let file_contents = r#"
+            // print goodbye
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "src": {
+                    "main.rs": file_contents,
+                }
+            }),
+        )
+        .await;
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", committed_contents.clone())],
+            "deadbeef",
+        );
+        fs.set_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", staged_contents)],
+        );
+        let repo = fs
+            .open_repo(path!("/project/.git").as_ref(), Some("git".as_ref()))
+            .unwrap();
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        cx.run_until_parked();
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            StagedDiff::deploy_at(workspace, None, window, cx);
+        });
+        cx.run_until_parked();
+
+        let editor = workspace.update(cx, |workspace, cx| {
+            let staged_diff = workspace.active_item_as::(cx).unwrap();
+            let staged_diff = staged_diff.read(cx);
+            staged_diff
+                .diff
+                .read(cx)
+                .editor()
+                .read(cx)
+                .rhs_editor()
+                .clone()
+        });
+        editor.read_with(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            assert_eq!(
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .count(),
+                1
+            );
+        });
+
+        // Hold back FS events so the first assertions observe the optimistic
+        // state rather than a reloaded diff.
+        fs.pause_events();
+
+        editor.update_in(cx, |editor, window, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]);
+            });
+        });
+        cx.focus(&editor);
+        cx.update(|window, cx| {
+            window.dispatch_action(git::ToggleStaged.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+
+        // The hunk is optimistically suppressed from the staged view, and the
+        // index write has landed.
+        editor.read_with(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            assert_eq!(
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .count(),
+                0
+            );
+        });
+        assert_eq!(
+            repo.load_index_text(RepoPath::from_rel_path(rel_path("src/main.rs")))
+                .await
+                .unwrap(),
+            committed_contents
+        );
+
+        fs.unpause_events_and_flush();
+        cx.run_until_parked();
+
+        // Once the write is reconciled, the staged view remains empty.
+        editor.read_with(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            assert_eq!(
+                editor
+                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
+                    .count(),
+                0
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_staged_diff_restores_as_staged_diff(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let committed_contents = r#"
+            fn main() {
+                println!("hello world");
+            }
+        "#
+        .unindent();
+        let staged_contents = r#"
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+        let file_contents = r#"
+            // print goodbye
+            fn main() {
+                println!("goodbye world");
+            }
+        "#
+        .unindent();
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "src": {
+                    "main.rs": file_contents,
+                }
+            }),
+        )
+        .await;
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", committed_contents)],
+            "deadbeef",
+        );
+        fs.set_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("src/main.rs", staged_contents)],
+        );
+
+        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, window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        cx.run_until_parked();
+
+        let project = workspace.update(cx, |workspace, _| workspace.project().clone());
+        let workspace_id = workspace::WorkspaceId::from_i64(1);
+        let item_id = 42;
+
+        let restore_task = workspace.update_in(cx, |_workspace, window, cx| {
+            ::deserialize(
+                project.clone(),
+                cx.entity().downgrade(),
+                workspace_id,
+                item_id,
+                window,
+                cx,
+            )
+        });
+        let restored_staged_diff = restore_task.await.unwrap();
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(restored_staged_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        workspace.update(cx, |workspace, cx| {
+            let active_item = workspace.active_item(cx).unwrap();
+            assert!(active_item.act_as::(cx).is_some());
+            assert!(active_item.act_as::(cx).is_some());
+            assert_eq!(
+                active_item
+                    .to_serializable_item_handle(cx)
+                    .unwrap()
+                    .serialized_item_kind(),
+                "StagedDiff"
+            );
+            assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes");
+            assert!(!active_item.can_save(cx));
+            assert_eq!(workspace.items_of_type::(cx).count(), 0);
+            assert_eq!(workspace.items_of_type::(cx).count(), 1);
+            let diff = active_item.act_as::(cx).unwrap();
+            assert_eq!(
+                diff.read_with(cx, |diff, cx| diff.diff_base(cx).clone()),
+                DiffBase::Staged
+            );
+        });
+    }
+}
diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs
index 5312ae6d088..c9cf6b5f537 100644
--- a/crates/git_ui/src/text_diff_view.rs
+++ b/crates/git_ui/src/text_diff_view.rs
@@ -3,8 +3,8 @@
 use anyhow::Result;
 use buffer_diff::BufferDiff;
 use editor::{
-    Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint,
-    actions::DiffClipboardWithSelectionData,
+    Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
+    SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData,
 };
 use futures::{FutureExt, select_biased};
 use gpui::{
@@ -184,11 +184,8 @@ impl TextDiffView {
                 window,
                 cx,
             );
-            splittable.disable_diff_hunk_controls(cx);
-            splittable.set_render_diff_hunks_as_unstaged(cx);
-            splittable.rhs_editor().update(cx, |editor, _cx| {
-                editor.start_temporary_diff_override();
-            });
+            splittable
+                .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
             splittable
         });
 
@@ -221,7 +218,7 @@ impl TextDiffView {
                     .file()
                     .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
             })
-            .unwrap_or("untitled".into());
+            .unwrap_or(MultiBuffer::DEFAULT_TITLE.into());
 
         let selection_location_path = selection_location_text
             .map(|text| format!("{} @ {}", path, text))
diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs
new file mode 100644
index 00000000000..44f700c722a
--- /dev/null
+++ b/crates/git_ui/src/unstaged_diff.rs
@@ -0,0 +1,722 @@
+use crate::{
+    diff_multibuffer::DiffMultibuffer,
+    git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
+};
+use anyhow::{Context as _, Result};
+use buffer_diff::DiffHunkStatus;
+use editor::{
+    DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor,
+    actions::{GoToHunk, GoToPreviousHunk},
+};
+use git::{StageAll, StageAndNext};
+use gpui::{
+    Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    SharedString, Subscription, Task, WeakEntity,
+};
+use language::Capability;
+use project::{
+    Project, ProjectPath,
+    git_store::diff_buffer_list::{DiffBase, DiffBufferList},
+    project_settings::ProjectSettings,
+};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    ops::Range,
+    sync::Arc,
+};
+use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*};
+use util::ResultExt as _;
+use workspace::{
+    ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    Workspace,
+    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
+    searchable::SearchableItemHandle,
+};
+
+pub(crate) struct UnstagedDiffDelegate;
+
+impl DiffHunkDelegate for UnstagedDiffDelegate {
+    fn toggle(
+        &self,
+        hunks: Vec,
+        editor: &mut Editor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.stage_or_unstage(true, hunks, editor, window, cx);
+    }
+
+    fn stage_or_unstage(
+        &self,
+        stage: bool,
+        hunks: Vec,
+        editor: &mut Editor,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !stage {
+            return;
+        }
+        let Some(project) = editor.project().cloned() else {
+            return;
+        };
+        for hunks in hunks {
+            let Some(buffer) = hunks.buffer else {
+                continue;
+            };
+            let worktree_ranges = hunks
+                .hunks
+                .into_iter()
+                .map(|hunk| hunk.buffer_range)
+                .collect::>();
+            if worktree_ranges.is_empty() {
+                continue;
+            }
+            project
+                .update(cx, |project, cx| {
+                    project.stage_hunks(buffer, hunks.diff, worktree_ranges, cx)
+                })
+                .log_err();
+        }
+    }
+
+    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 {
+        if !ProjectSettings::get_global(cx)
+            .git
+            .show_stage_restore_buttons
+        {
+            return gpui::Empty.into_any_element();
+        }
+        let hunk_range = hunk_range.start..hunk_range.start;
+        h_flex()
+            .h(line_height)
+            .mr_1()
+            .gap_1()
+            .px_0p5()
+            .pb_1()
+            .border_x_1()
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .rounded_b_lg()
+            .bg(cx.theme().colors().editor_background)
+            .block_mouse_except_scroll()
+            .shadow_md()
+            .child(
+                Button::new(("stage", row as u64), "Stage")
+                    .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+                    .tooltip(Tooltip::text("Stage Hunk"))
+                    .on_click({
+                        let editor = editor.clone();
+                        move |_event, window, cx| {
+                            editor.update(cx, |editor, cx| {
+                                editor.stage_or_unstage_diff_hunks(
+                                    true,
+                                    vec![hunk_range.clone()],
+                                    window,
+                                    cx,
+                                );
+                            });
+                        }
+                    }),
+            )
+            .into_any_element()
+    }
+
+    fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool {
+        false
+    }
+}
+
+pub struct UnstagedDiff {
+    diff: Entity,
+    project: Entity,
+    workspace: WeakEntity,
+    _diff_event_subscription: Subscription,
+}
+
+impl UnstagedDiff {
+    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) {
+        let _ = workspace;
+        workspace::register_serializable_item::(cx);
+    }
+
+    pub fn deploy_at(
+        workspace: &mut Workspace,
+        entry: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        telemetry::event!(
+            "Git Unstaged Diff Opened",
+            source = if entry.is_some() {
+                "Git Panel"
+            } else {
+                "Action"
+            }
+        );
+        let intended_repo = workspace.project().read(cx).active_repository(cx);
+        let existing = workspace.items_of_type::(cx).next();
+        let unstaged_diff = if let Some(existing) = existing {
+            workspace.activate_item(&existing, true, true, window, cx);
+            existing
+        } else {
+            let workspace_handle = cx.entity();
+            let unstaged_diff =
+                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
+            workspace.add_item_to_active_pane(
+                Box::new(unstaged_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+            unstaged_diff
+        };
+
+        if let Some(intended) = &intended_repo {
+            let needs_switch = unstaged_diff
+                .read(cx)
+                .diff
+                .read(cx)
+                .repo(cx)
+                .map_or(true, |current| current.entity_id() != intended.entity_id());
+            if needs_switch {
+                unstaged_diff.update(cx, |unstaged_diff, cx| {
+                    unstaged_diff.diff.update(cx, |diff, cx| {
+                        diff.set_repo(Some(intended.clone()), cx);
+                    });
+                });
+            }
+        }
+
+        if let Some(entry) = entry {
+            unstaged_diff.update(cx, |unstaged_diff, cx| {
+                unstaged_diff
+                    .diff
+                    .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
+            });
+        }
+    }
+
+    pub(crate) fn new(
+        project: Entity,
+        workspace: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        let branch_diff =
+            cx.new(|cx| DiffBufferList::new(DiffBase::Index, project.clone(), window, cx));
+        let workspace_handle = workspace.downgrade();
+        let diff = cx.new(|cx| {
+            DiffMultibuffer::new(
+                branch_diff,
+                Capability::ReadWrite,
+                "No unstaged changes",
+                move |editor, cx| {
+                    editor.set_diff_hunk_delegate(Some(Arc::new(UnstagedDiffDelegate)), cx);
+                    editor.rhs_editor().update(cx, |rhs_editor, _cx| {
+                        rhs_editor.set_read_only(false);
+                        rhs_editor.register_addon(GitPanelAddon {
+                            workspace: workspace_handle,
+                        });
+                    });
+                },
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            )
+        });
+        Self::from_diff(diff, project, workspace, cx)
+    }
+
+    pub(crate) fn from_diff(
+        diff: Entity,
+        project: Entity,
+        workspace: Entity,
+        cx: &mut Context,
+    ) -> Self {
+        let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
+            cx.emit(event.clone())
+        });
+
+        Self {
+            diff,
+            project,
+            workspace: workspace.downgrade(),
+            _diff_event_subscription: diff_event_subscription,
+        }
+    }
+
+    fn button_states(&self, cx: &App) -> ButtonStates {
+        let diff = self.diff.read(cx);
+        let editor = diff.editor().read(cx).rhs_editor().clone();
+        let editor = editor.read(cx);
+        let snapshot = diff.multibuffer().read(cx).snapshot(cx);
+        let prev_next = snapshot.diff_hunks().nth(1).is_some();
+        let (selection, ranges) = diff.selected_ranges(cx);
+        let stage = editor
+            .diff_hunks_in_ranges(&ranges, &snapshot)
+            .next()
+            .is_some();
+        let mut stage_all = false;
+        self.workspace
+            .read_with(cx, |workspace, cx| {
+                if let Some(git_panel) = workspace.panel::(cx) {
+                    stage_all = git_panel.read(cx).can_stage_all();
+                }
+            })
+            .ok();
+
+        ButtonStates {
+            stage,
+            prev_next,
+            selection,
+            stage_all,
+        }
+    }
+
+    fn stage_selected_unstaged_hunks(
+        &mut self,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx)
+        });
+    }
+}
+
+struct ButtonStates {
+    stage: bool,
+    prev_next: bool,
+    selection: bool,
+    stage_all: bool,
+}
+
+impl EventEmitter for UnstagedDiff {}
+
+impl Focusable for UnstagedDiff {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.diff.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for UnstagedDiff {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
+        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
+    }
+
+    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context) {
+        self.diff
+            .update(cx, |diff, cx| diff.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Arc,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> bool {
+        self.diff
+            .update(cx, |diff, cx| diff.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option {
+        Some("Unstaged Changes".into())
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(0, _cx))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        "Unstaged Changes".into()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Git Unstaged Diff Opened")
+    }
+
+    fn as_searchable(&self, _: &Entity, cx: &App) -> Option> {
+        Some(Box::new(self.diff.read(cx).editor().clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.diff.read(cx).for_each_project_item(cx, f);
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff
+            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
+    }
+
+    fn can_split(&self) -> bool {
+        true
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task>>
+    where
+        Self: Sized,
+    {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(None);
+        };
+        let project = self.project.clone();
+        Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx))))
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.diff.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.diff.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _cx: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.save(options, project, window, cx))
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity,
+        _: ProjectPath,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Task> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Task> {
+        self.diff
+            .update(cx, |diff, cx| diff.reload(project, window, cx))
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity,
+        cx: &'a App,
+    ) -> Option {
+        if type_id == TypeId::of::() {
+            Some(self_handle.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(
+                self.diff
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .rhs_editor()
+                    .clone()
+                    .into(),
+            )
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).editor().clone().into())
+        } else if type_id == TypeId::of::() {
+            Some(self.diff.read(cx).branch_diff().clone().into())
+        } else {
+            None
+        }
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.diff.update(cx, |diff, cx| {
+            diff.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl SerializableItem for UnstagedDiff {
+    fn serialized_item_kind() -> &'static str {
+        "UnstagedDiff"
+    }
+
+    fn cleanup(
+        _: workspace::WorkspaceId,
+        _: Vec,
+        _: &mut Window,
+        _: &mut App,
+    ) -> Task> {
+        Task::ready(Ok(()))
+    }
+
+    fn deserialize(
+        project: Entity,
+        workspace: WeakEntity,
+        _: workspace::WorkspaceId,
+        _: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task>> {
+        window.spawn(cx, async move |cx| {
+            let workspace = workspace.upgrade().context("workspace gone")?;
+            cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))?
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        _: &mut Workspace,
+        _: workspace::ItemId,
+        _: bool,
+        _: &mut Window,
+        _: &mut Context,
+    ) -> Option>> {
+        Some(Task::ready(Ok(())))
+    }
+
+    fn should_serialize(&self, _: &Self::Event) -> bool {
+        false
+    }
+}
+
+impl Render for UnstagedDiff {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+        self.diff.clone()
+    }
+}
+
+pub struct UnstagedDiffToolbar {
+    unstaged_diff: Option>,
+    workspace: WeakEntity,
+}
+
+impl UnstagedDiffToolbar {
+    pub fn new(workspace: &Workspace, _: &mut Context) -> Self {
+        Self {
+            unstaged_diff: None,
+            workspace: workspace.weak_handle(),
+        }
+    }
+
+    fn unstaged_diff(&self, _: &App) -> Option> {
+        self.unstaged_diff.as_ref()?.upgrade()
+    }
+
+    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) {
+        if let Some(unstaged_diff) = self.unstaged_diff(cx) {
+            unstaged_diff.focus_handle(cx).focus(window, cx);
+        }
+        let action = action.boxed_clone();
+        cx.defer(move |cx| {
+            cx.dispatch_action(action.as_ref());
+        })
+    }
+
+    fn stage_selected_unstaged_hunks(
+        &mut self,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(unstaged_diff) = self.unstaged_diff(cx) else {
+            return;
+        };
+        unstaged_diff.update(cx, |unstaged_diff, cx| {
+            unstaged_diff.stage_selected_unstaged_hunks(move_to_next, window, cx);
+        });
+    }
+
+    fn stage_all(&mut self, window: &mut Window, cx: &mut Context) {
+        self.workspace
+            .update(cx, |workspace, cx| {
+                let Some(panel) = workspace.panel::(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.stage_all(&Default::default(), window, cx);
+                });
+            })
+            .ok();
+    }
+}
+
+impl EventEmitter for UnstagedDiffToolbar {}
+
+impl ToolbarItemView for UnstagedDiffToolbar {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _: &mut Window,
+        cx: &mut Context,
+    ) -> ToolbarItemLocation {
+        self.unstaged_diff = active_pane_item
+            .and_then(|item| item.act_as::(cx))
+            .map(|entity| entity.downgrade());
+        if self.unstaged_diff.is_some() {
+            ToolbarItemLocation::PrimaryRight
+        } else {
+            ToolbarItemLocation::Hidden
+        }
+    }
+
+    fn pane_focus_update(
+        &mut self,
+        _pane_focused: bool,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) {
+    }
+}
+
+impl Render for UnstagedDiffToolbar {
+    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+        let Some(unstaged_diff) = self.unstaged_diff(cx) else {
+            return div();
+        };
+        let focus_handle = unstaged_diff.focus_handle(cx);
+        let button_states = unstaged_diff.read(cx).button_states(cx);
+
+        let diff = unstaged_diff.read(cx).diff.read(cx);
+        let (additions, deletions) = diff.calculate_changed_lines(cx);
+        let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty();
+
+        h_flex()
+            .my_neg_1()
+            .py_1()
+            .gap_1p5()
+            .flex_wrap()
+            .justify_between()
+            .when(!is_multibuffer_empty, |this| {
+                this.child(DiffStat::new(
+                    "unstaged-diff-stat",
+                    additions as usize,
+                    deletions as usize,
+                ))
+                .child(Divider::vertical().ml_1())
+            })
+            // n.b. the only reason these arrows are here is because we don't
+            // support "undo" for staging so we need a way to go back.
+            .child(
+                h_group_sm()
+                    .child(
+                        IconButton::new("up", IconName::ArrowUp)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Previous Hunk",
+                                &GoToPreviousHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToPreviousHunk, window, cx)
+                            })),
+                    )
+                    .child(
+                        IconButton::new("down", IconName::ArrowDown)
+                            .icon_size(IconSize::Small)
+                            .disabled(!button_states.prev_next)
+                            .tooltip(Tooltip::for_action_title_in(
+                                "Go to Next Hunk",
+                                &GoToHunk,
+                                &focus_handle,
+                            ))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.dispatch_action(&GoToHunk, window, cx)
+                            })),
+                    ),
+            )
+            .child(Divider::vertical())
+            .child(
+                h_group_sm()
+                    .when(button_states.selection, |this| {
+                        this.child(
+                            Button::new("stage", "Stage")
+                                .disabled(!button_states.stage)
+                                .tooltip(Tooltip::text("Stage Selected Hunks"))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.stage_selected_unstaged_hunks(false, window, cx)
+                                })),
+                        )
+                    })
+                    .when(!button_states.selection, |this| {
+                        this.child(
+                            Button::new("stage", "Stage")
+                                .disabled(!button_states.stage)
+                                .tooltip(Tooltip::for_action_title_in(
+                                    "Stage and Go to Next Hunk",
+                                    &StageAndNext,
+                                    &focus_handle,
+                                ))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.stage_selected_unstaged_hunks(true, window, cx)
+                                })),
+                        )
+                    }),
+            )
+            .child(Divider::vertical())
+            .child(
+                Button::new("stage-all", "Stage All")
+                    .width(rems_from_px(80.))
+                    .disabled(!button_states.stage_all)
+                    .tooltip(Tooltip::for_action_title_in(
+                        "Stage All Changes",
+                        &StageAll,
+                        &focus_handle,
+                    ))
+                    .on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))),
+            )
+    }
+}
diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs
index ab071156483..16c4f86eb41 100644
--- a/crates/google_ai/src/completion.rs
+++ b/crates/google_ai/src/completion.rs
@@ -2,8 +2,9 @@ use anyhow::Result;
 use futures::{Stream, StreamExt};
 use language_model_core::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role,
-    StopReason, TokenUsage,
+    LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse,
+    LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
+    TokenUsage,
 };
 use std::pin::Pin;
 use std::sync::Arc;
@@ -20,20 +21,18 @@ pub fn into_google(
     mut request: LanguageModelRequest,
     model_id: String,
     mode: GoogleModelMode,
-) -> crate::GenerateContentRequest {
-    fn map_content(content: Vec) -> Vec {
-        content
-            .into_iter()
-            .flat_map(|content| match content {
+) -> Result {
+    fn map_content(content: Vec) -> Result> {
+        let mut mapped_parts = Vec::new();
+        for content in content {
+            match content {
                 MessageContent::Text(text) => {
                     if !text.is_empty() {
-                        vec![Part::TextPart(TextPart {
+                        mapped_parts.push(Part::TextPart(TextPart {
                             text,
                             thought: false,
                             thought_signature: None,
-                        })]
-                    } else {
-                        vec![]
+                        }));
                     }
                 }
                 MessageContent::Thinking {
@@ -41,39 +40,37 @@ pub fn into_google(
                     signature: Some(signature),
                 } => {
                     if !signature.is_empty() {
-                        vec![Part::TextPart(TextPart {
+                        mapped_parts.push(Part::TextPart(TextPart {
                             text,
                             thought: true,
                             thought_signature: Some(signature),
-                        })]
-                    } else {
-                        vec![]
+                        }));
                     }
                 }
-                MessageContent::Thinking { .. } => {
-                    vec![]
-                }
-                MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![],
+                MessageContent::Thinking { .. } => {}
+                MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {}
                 MessageContent::Image(image) => {
-                    vec![Part::InlineDataPart(InlineDataPart {
+                    mapped_parts.push(Part::InlineDataPart(InlineDataPart {
                         inline_data: GenerativeContentBlob {
                             mime_type: "image/png".to_string(),
                             data: image.source.to_string(),
                         },
-                    })]
+                    }));
                 }
                 MessageContent::ToolUse(tool_use) => {
-                    // Normalize empty string signatures to None
                     let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty());
+                    let LanguageModelToolUseInput::Json(input) = tool_use.input else {
+                        anyhow::bail!("Google AI does not support custom tool calls");
+                    };
 
-                    vec![Part::FunctionCallPart(crate::FunctionCallPart {
+                    mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart {
                         function_call: crate::FunctionCall {
                             name: tool_use.name.to_string(),
-                            args: tool_use.input,
+                            args: input,
                             id: Some(tool_use.id.to_string()),
                         },
                         thought_signature,
-                    })]
+                    }));
                 }
                 MessageContent::ToolResult(tool_result) => {
                     let mut text_output = String::new();
@@ -98,7 +95,7 @@ pub fn into_google(
                     } else {
                         text_output
                     };
-                    let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart {
+                    mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart {
                         function_response: crate::FunctionResponse {
                             name: tool_result.tool_name.to_string(),
                             // The API expects a valid JSON object
@@ -107,12 +104,12 @@ pub fn into_google(
                             }),
                             id: Some(tool_result.tool_use_id.to_string()),
                         },
-                    })];
-                    parts.extend(images.into_iter().map(Part::InlineDataPart));
-                    parts
+                    }));
+                    mapped_parts.extend(images.into_iter().map(Part::InlineDataPart));
                 }
-            })
-            .collect()
+            }
+        }
+        Ok(mapped_parts)
     }
 
     let thinking_config = thinking_config_for_request(&request, &model_id, mode);
@@ -124,33 +121,59 @@ pub fn into_google(
     {
         let message = request.messages.remove(0);
         Some(SystemInstruction {
-            parts: map_content(message.content),
+            parts: map_content(message.content)?,
         })
     } else {
         None
     };
 
-    crate::GenerateContentRequest {
+    let tools = if request.tools.is_empty() {
+        None
+    } else {
+        Some(vec![crate::Tool {
+            function_declarations: request
+                .tools
+                .into_iter()
+                .map(|tool| match tool.input {
+                    LanguageModelRequestToolInput::Function { input_schema, .. } => {
+                        Ok(FunctionDeclaration {
+                            name: tool.name,
+                            description: tool.description,
+                            parameters: input_schema,
+                        })
+                    }
+                    LanguageModelRequestToolInput::Custom { .. } => {
+                        Err(anyhow::anyhow!("Google AI does not support custom tools"))
+                    }
+                })
+                .collect::>()?,
+        }])
+    };
+
+    Ok(crate::GenerateContentRequest {
         model: ModelName { model_id },
         system_instruction: system_instructions,
         contents: request
             .messages
             .into_iter()
-            .filter_map(|message| {
-                let parts = map_content(message.content);
+            .map(|message| {
+                let parts = map_content(message.content)?;
                 if parts.is_empty() {
-                    None
+                    Ok(None)
                 } else {
-                    Some(Content {
+                    Ok(Some(Content {
                         parts,
                         role: match message.role {
                             Role::User => crate::Role::User,
                             Role::Assistant => crate::Role::Model,
                             Role::System => crate::Role::User, // Google AI doesn't have a system role
                         },
-                    })
+                    }))
                 }
             })
+            .collect::>>()?
+            .into_iter()
+            .flatten()
             .collect(),
         generation_config: Some(GenerationConfig {
             candidate_count: Some(1),
@@ -162,19 +185,7 @@ pub fn into_google(
             top_k: None,
         }),
         safety_settings: None,
-        tools: (!request.tools.is_empty()).then(|| {
-            vec![crate::Tool {
-                function_declarations: request
-                    .tools
-                    .into_iter()
-                    .map(|tool| FunctionDeclaration {
-                        name: tool.name,
-                        description: tool.description,
-                        parameters: tool.input_schema,
-                    })
-                    .collect(),
-            }]
-        }),
+        tools,
         tool_config: request.tool_choice.map(|choice| ToolConfig {
             function_calling_config: FunctionCallingConfig {
                 mode: match choice {
@@ -185,7 +196,7 @@ pub fn into_google(
                 allowed_function_names: None,
             },
         }),
-    }
+    })
 }
 
 fn thinking_config_for_request(
@@ -404,7 +415,9 @@ impl GoogleEventMapper {
                                     name,
                                     is_input_complete: true,
                                     raw_input: function_call_part.function_call.args.to_string(),
-                                    input: function_call_part.function_call.args,
+                                    input: LanguageModelToolUseInput::Json(
+                                        function_call_part.function_call.args,
+                                    ),
                                     thought_signature,
                                 },
                             )));
@@ -493,7 +506,8 @@ mod tests {
             GoogleModelMode::Thinking {
                 budget_tokens: None,
             },
-        );
+        )
+        .unwrap();
 
         let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
         assert_eq!(thinking_config.include_thoughts, Some(true));
@@ -515,7 +529,8 @@ mod tests {
             GoogleModelMode::Thinking {
                 budget_tokens: None,
             },
-        );
+        )
+        .unwrap();
 
         let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
         assert_eq!(thinking_config.thinking_budget, Some(0));
@@ -533,7 +548,8 @@ mod tests {
             GoogleModelMode::Thinking {
                 budget_tokens: None,
             },
-        );
+        )
+        .unwrap();
 
         let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
         assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal));
@@ -561,7 +577,8 @@ mod tests {
             GoogleModelMode::Thinking {
                 budget_tokens: None,
             },
-        );
+        )
+        .unwrap();
 
         let Part::TextPart(text_part) = &request.contents[0].parts[0] else {
             panic!("expected text part");
diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml
index fa6fcace40f..eab072e470b 100644
--- a/crates/gpui/Cargo.toml
+++ b/crates/gpui/Cargo.toml
@@ -29,9 +29,7 @@ test-support = [
 bench = ["test-support", "dep:criterion", "dep:hdrhistogram"]
 inspector = ["gpui_macros/inspector"]
 leak-detection = ["backtrace"]
-wayland = [
-    "bitflags",
-]
+wayland = []
 x11 = [
     "scap?/x11",
 ]
@@ -51,7 +49,7 @@ accesskit.workspace = true
 anyhow.workspace = true
 async-task = "4.7"
 backtrace = { workspace = true, optional = true }
-bitflags = { workspace = true, optional = true }
+bitflags.workspace = true
 
 collections.workspace = true
 criterion = { workspace = true, optional = true }
@@ -256,3 +254,7 @@ path = "examples/mouse_pressure.rs"
 [[example]]
 name = "a11y"
 path = "examples/a11y.rs"
+
+[[example]]
+name = "view_example"
+path = "examples/view_example/view_example_main.rs"
diff --git a/crates/gpui/examples/view_example/example_editor.rs b/crates/gpui/examples/view_example/example_editor.rs
new file mode 100644
index 00000000000..2064d7cacef
--- /dev/null
+++ b/crates/gpui/examples/view_example/example_editor.rs
@@ -0,0 +1,549 @@
+//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard
+//! handling, and the specialized text-shaping renderer. The *text itself* lives
+//! in a shared `Entity` it's handed at construction, so the value is
+//! readable/writable from outside while the editing machinery stays in here.
+//!
+//! This is the piece that proves the point: a text input is genuinely
+//! complicated, and `View` lets all of that complexity live in one entity that
+//! anything can embed.
+
+use std::ops::Range;
+use std::time::Duration;
+
+use gpui::{
+    App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable,
+    InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task,
+    TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size,
+};
+use unicode_segmentation::*;
+
+use crate::{Backspace, Delete, End, Home, Left, Right};
+
+pub struct Editor {
+    pub value: Entity,
+    pub focus_handle: FocusHandle,
+    pub cursor: usize,
+    pub cursor_visible: bool,
+    _blink_task: Task<()>,
+    _subscriptions: Vec,
+}
+
+impl Editor {
+    /// An editor that owns its own string internally, seeded with `text`.
+    /// Nothing to allocate or wire up at the call site.
+    pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self {
+        let value = cx.new(|_| text.into());
+        Self::over(value, window, cx)
+    }
+
+    /// An editor over a string *you* own, so the value is shared in and out.
+    pub fn over(value: Entity, window: &mut Window, cx: &mut Context) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| {
+            this.start_blink(cx);
+        });
+        let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| {
+            this.stop_blink(cx);
+        });
+
+        // The value is shared: anything can write it while we hold a cursor into
+        // it. Observe it so external writes (a) clamp the cursor back onto a char
+        // boundary before the next IME round-trip can slice out of bounds, and
+        // (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache
+        // is keyed on *our* notify, not the value's.
+        let value_sub = cx.observe(&value, |this, value, cx| {
+            let content = value.read(cx);
+            let mut cursor = this.cursor.min(content.len());
+            while cursor > 0 && !content.is_char_boundary(cursor) {
+                cursor -= 1;
+            }
+            this.cursor = cursor;
+            cx.notify();
+        });
+
+        Self {
+            value,
+            focus_handle,
+            cursor: 0,
+            cursor_visible: false,
+            _blink_task: Task::ready(()),
+            _subscriptions: vec![focus_sub, blur_sub, value_sub],
+        }
+    }
+
+    /// The current text. Read this from anywhere to get the value out.
+    pub fn text(&self, cx: &App) -> String {
+        self.value.read(cx).clone()
+    }
+
+    fn start_blink(&mut self, cx: &mut Context) {
+        self.cursor_visible = true;
+        self._blink_task = Self::spawn_blink_task(cx);
+    }
+
+    fn stop_blink(&mut self, cx: &mut Context) {
+        self.cursor_visible = false;
+        self._blink_task = Task::ready(());
+        cx.notify();
+    }
+
+    fn spawn_blink_task(cx: &mut Context) -> Task<()> {
+        cx.spawn(async move |this, cx| {
+            loop {
+                cx.background_executor()
+                    .timer(Duration::from_millis(500))
+                    .await;
+                let result = this.update(cx, |editor, cx| {
+                    editor.cursor_visible = !editor.cursor_visible;
+                    cx.notify();
+                });
+                if result.is_err() {
+                    break;
+                }
+            }
+        })
+    }
+
+    fn reset_blink(&mut self, cx: &mut Context) {
+        self.cursor_visible = true;
+        self._blink_task = Self::spawn_blink_task(cx);
+    }
+
+    pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) {
+        let content = self.text(cx);
+        if self.cursor > 0 {
+            self.cursor = previous_boundary(&content, self.cursor);
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) {
+        let content = self.text(cx);
+        if self.cursor < content.len() {
+            self.cursor = next_boundary(&content, self.cursor);
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) {
+        self.cursor = 0;
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) {
+        self.cursor = self.text(cx).len();
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) {
+        let content = self.text(cx);
+        if self.cursor > 0 {
+            let prev = previous_boundary(&content, self.cursor);
+            let cursor = self.cursor;
+            self.value.update(cx, |s, cx| {
+                s.drain(prev..cursor);
+                cx.notify();
+            });
+            self.cursor = prev;
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) {
+        let content = self.text(cx);
+        if self.cursor < content.len() {
+            let next = next_boundary(&content, self.cursor);
+            let cursor = self.cursor;
+            self.value.update(cx, |s, cx| {
+                s.drain(cursor..next);
+                cx.notify();
+            });
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn insert_newline(&mut self, cx: &mut Context) {
+        let cursor = self.cursor;
+        self.value.update(cx, |s, cx| {
+            s.insert(cursor, '\n');
+            cx.notify();
+        });
+        self.cursor += 1;
+        self.reset_blink(cx);
+        cx.notify();
+    }
+}
+
+fn previous_boundary(content: &str, offset: usize) -> usize {
+    content
+        .grapheme_indices(true)
+        .rev()
+        .find_map(|(idx, _)| (idx < offset).then_some(idx))
+        .unwrap_or(0)
+}
+
+fn next_boundary(content: &str, offset: usize) -> usize {
+    content
+        .grapheme_indices(true)
+        .find_map(|(idx, _)| (idx > offset).then_some(idx))
+        .unwrap_or(content.len())
+}
+
+fn offset_from_utf16(content: &str, offset: usize) -> usize {
+    let mut utf8_offset = 0;
+    let mut utf16_count = 0;
+    for ch in content.chars() {
+        if utf16_count >= offset {
+            break;
+        }
+        utf16_count += ch.len_utf16();
+        utf8_offset += ch.len_utf8();
+    }
+    utf8_offset
+}
+
+fn offset_to_utf16(content: &str, offset: usize) -> usize {
+    let mut utf16_offset = 0;
+    let mut utf8_count = 0;
+    for ch in content.chars() {
+        if utf8_count >= offset {
+            break;
+        }
+        utf8_count += ch.len_utf8();
+        utf16_offset += ch.len_utf16();
+    }
+    utf16_offset
+}
+
+fn range_to_utf16(content: &str, range: &Range) -> Range {
+    offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end)
+}
+
+fn range_from_utf16(content: &str, range_utf16: &Range) -> Range {
+    offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end)
+}
+
+impl Focusable for Editor {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EntityInputHandler for Editor {
+    fn text_for_range(
+        &mut self,
+        range_utf16: Range,
+        actual_range: &mut Option>,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) -> Option {
+        let content = self.text(cx);
+        let range = range_from_utf16(&content, &range_utf16);
+        actual_range.replace(range_to_utf16(&content, &range));
+        Some(content[range].to_string())
+    }
+
+    fn selected_text_range(
+        &mut self,
+        _ignore_disabled_input: bool,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) -> Option {
+        let content = self.text(cx);
+        let utf16_cursor = offset_to_utf16(&content, self.cursor);
+        Some(UTF16Selection {
+            range: utf16_cursor..utf16_cursor,
+            reversed: false,
+        })
+    }
+
+    fn marked_text_range(
+        &self,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) -> Option> {
+        None
+    }
+
+    fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {}
+
+    fn replace_text_in_range(
+        &mut self,
+        range_utf16: Option>,
+        new_text: &str,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let content = self.text(cx);
+        let range = range_utf16
+            .as_ref()
+            .map(|r| range_from_utf16(&content, r))
+            .unwrap_or(self.cursor..self.cursor);
+
+        let new_content = content[..range.start].to_owned() + new_text + &content[range.end..];
+        self.cursor = range.start + new_text.len();
+        self.value.update(cx, |s, cx| {
+            *s = new_content;
+            cx.notify();
+        });
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    fn replace_and_mark_text_in_range(
+        &mut self,
+        range_utf16: Option>,
+        new_text: &str,
+        _new_selected_range_utf16: Option>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.replace_text_in_range(range_utf16, new_text, window, cx);
+    }
+
+    fn bounds_for_range(
+        &mut self,
+        _range_utf16: Range,
+        _bounds: Bounds,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) -> Option> {
+        None
+    }
+
+    fn character_index_for_point(
+        &mut self,
+        _point: gpui::Point,
+        _window: &mut Window,
+        _cx: &mut Context,
+    ) -> Option {
+        None
+    }
+}
+
+impl gpui::Render for Editor {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        EditorText {
+            editor: cx.entity(),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// EditorText — the specialized renderer: shapes the text and paints the cursor.
+// ---------------------------------------------------------------------------
+
+struct EditorText {
+    editor: Entity,
+}
+
+struct EditorTextPrepaint {
+    lines: Vec,
+    cursor: Option,
+}
+
+impl IntoElement for EditorText {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for EditorText {
+    type RequestLayoutState = ();
+    type PrepaintState = EditorTextPrepaint;
+
+    fn id(&self) -> Option {
+        None
+    }
+
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        let editor = self.editor.read(cx);
+        let content = editor.value.read(cx);
+        let line_count = content.split('\n').count().max(1);
+        let line_height = window.line_height();
+        let mut style = gpui::Style::default();
+        style.size.width = relative(1.).into();
+        style.size.height = (line_height * line_count as f32).into();
+        (window.request_layout(style, [], cx), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        bounds: Bounds,
+        _request_layout: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::PrepaintState {
+        let editor = self.editor.read(cx);
+        let content = editor.value.read(cx).clone();
+        let cursor_offset = editor.cursor;
+        let cursor_visible = editor.cursor_visible;
+        let is_focused = editor.focus_handle.is_focused(window);
+
+        let style = window.text_style();
+        let text_color = style.color;
+        let font_size = style.font_size.to_pixels(window.rem_size());
+        let line_height = window.line_height();
+
+        let is_placeholder = content.is_empty();
+
+        let lines: Vec = if is_placeholder {
+            let placeholder: SharedString = "Type here...".into();
+            let run = TextRun {
+                len: placeholder.len(),
+                font: style.font(),
+                color: hsla(0., 0., 0.5, 0.5),
+                background_color: None,
+                underline: None,
+                strikethrough: None,
+            };
+            vec![
+                window
+                    .text_system()
+                    .shape_line(placeholder, font_size, &[run], None),
+            ]
+        } else {
+            content
+                .split('\n')
+                .map(|line_str| {
+                    let text: SharedString = SharedString::from(line_str.to_string());
+                    let run = TextRun {
+                        len: text.len(),
+                        font: style.font(),
+                        color: text_color,
+                        background_color: None,
+                        underline: None,
+                        strikethrough: None,
+                    };
+                    window
+                        .text_system()
+                        .shape_line(text, font_size, &[run], None)
+                })
+                .collect()
+        };
+
+        let cursor = if is_focused && cursor_visible && !is_placeholder {
+            let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset);
+            let cursor_line = cursor_line.min(lines.len().saturating_sub(1));
+            let cursor_x = lines[cursor_line].x_for_index(offset_in_line);
+            Some(fill(
+                Bounds::new(
+                    point(
+                        bounds.left() + cursor_x,
+                        bounds.top() + line_height * cursor_line as f32,
+                    ),
+                    size(px(1.5), line_height),
+                ),
+                text_color,
+            ))
+        } else if is_focused && cursor_visible && is_placeholder {
+            Some(fill(
+                Bounds::new(
+                    point(bounds.left(), bounds.top()),
+                    size(px(1.5), line_height),
+                ),
+                text_color,
+            ))
+        } else {
+            None
+        };
+
+        EditorTextPrepaint { lines, cursor }
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        bounds: Bounds,
+        _request_layout: &mut Self::RequestLayoutState,
+        prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let focus_handle = self.editor.read(cx).focus_handle.clone();
+        window.handle_input(
+            &focus_handle,
+            ElementInputHandler::new(bounds, self.editor.clone()),
+            cx,
+        );
+
+        let line_height = window.line_height();
+        for (i, line) in prepaint.lines.iter().enumerate() {
+            let origin = point(bounds.left(), bounds.top() + line_height * i as f32);
+            line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx)
+                .unwrap();
+        }
+
+        if let Some(cursor) = prepaint.cursor.take() {
+            window.paint_quad(cursor);
+        }
+    }
+}
+
+fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) {
+    let mut line_index = 0;
+    let mut line_start = 0;
+    for (i, ch) in content.char_indices() {
+        if i >= cursor {
+            break;
+        }
+        if ch == '\n' {
+            line_index += 1;
+            line_start = i + 1;
+        }
+    }
+    (line_index, cursor - line_start)
+}
+
+pub fn standard_actions(editor: Entity) -> impl FnOnce(E) -> E {
+    move |element| {
+        element
+            .on_action({
+                let editor = editor.clone();
+                move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx))
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx))
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx))
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx))
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |a: &Backspace, window, cx| {
+                    editor.update(cx, |e, cx| e.backspace(a, window, cx))
+                }
+            })
+            .on_action(move |a: &Delete, window, cx| {
+                editor.update(cx, |e, cx| e.delete(a, window, cx))
+            })
+    }
+}
diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs
new file mode 100644
index 00000000000..25d74013deb
--- /dev/null
+++ b/crates/gpui/examples/view_example/example_input.rs
@@ -0,0 +1,121 @@
+//! `Input` — a single-line text input. The shaping layer over `Editor`.
+//!
+//! Construct it two ways, depending on how much state you want to own:
+//!   * `Input::new(value: Entity)`  — you hold just the string; the input
+//!     allocates the `Editor` internally via `use_state`. Value readable, cursor hidden.
+//!   * `Input::editor(editor: Entity)` — you hold the editor; cursor/selection
+//!     are now yours to read and drive too.
+//!
+//! Either way the chrome is identical. Because the string (or editor) is the
+//! input's *identity*, the internal `use_state(Editor)` is collision-safe across
+//! any number of inputs.
+
+use gpui::{
+    App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement,
+    Window, div, hsla, point, prelude::*, px, white,
+};
+
+use crate::example_editor::{Editor, standard_actions};
+
+enum Source {
+    Value(Entity),
+    Editor(Entity),
+}
+
+#[derive(IntoElement)]
+pub struct Input {
+    source: Source,
+    width: Option,
+    color: Option,
+}
+
+impl Input {
+    /// Backed by a bare string; the editor is allocated internally.
+    pub fn new(value: Entity) -> Self {
+        Self {
+            source: Source::Value(value),
+            width: None,
+            color: None,
+        }
+    }
+
+    /// Backed by an editor you own (so you can read/drive its cursor).
+    pub fn editor(editor: Entity) -> Self {
+        Self {
+            source: Source::Editor(editor),
+            width: None,
+            color: None,
+        }
+    }
+
+    pub fn width(mut self, width: Pixels) -> Self {
+        self.width = Some(width);
+        self
+    }
+
+    pub fn color(mut self, color: Hsla) -> Self {
+        self.color = Some(color);
+        self
+    }
+}
+
+impl gpui::View for Input {
+    fn entity_id(&self) -> Option {
+        Some(match &self.source {
+            Source::Value(value) => value.entity_id(),
+            Source::Editor(editor) => editor.entity_id(),
+        })
+    }
+
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        // Get the editor: use the one we were handed, or allocate it under our
+        // own (string-derived) identity so it persists and never collides.
+        let editor = match self.source {
+            Source::Value(value) => {
+                window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
+            }
+            Source::Editor(editor) => editor,
+        };
+
+        let focus_handle = editor.read(cx).focus_handle.clone();
+        let is_focused = focus_handle.is_focused(window);
+        let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
+        let box_width = self.width.unwrap_or(px(300.));
+
+        let border = if is_focused {
+            hsla(220. / 360., 0.8, 0.5, 1.)
+        } else {
+            hsla(0., 0., 0.75, 1.)
+        };
+
+        div()
+            .id("input")
+            .key_context("TextInput")
+            .track_focus(&focus_handle)
+            .cursor(CursorStyle::IBeam)
+            .map(standard_actions(editor.clone()))
+            .w(box_width)
+            .h(px(36.))
+            .px(px(8.))
+            .bg(white())
+            .border_1()
+            .border_color(border)
+            .when(is_focused, |this| {
+                this.shadow(vec![BoxShadow {
+                    color: hsla(220. / 360., 0.8, 0.5, 0.3),
+                    offset: point(px(0.), px(0.)),
+                    blur_radius: px(4.),
+                    spread_radius: px(1.),
+                    inset: false,
+                }])
+            })
+            .rounded(px(4.))
+            .overflow_hidden()
+            .flex()
+            .items_center()
+            .line_height(px(20.))
+            .text_size(px(14.))
+            .text_color(text_color)
+            .child(editor.cached(StyleRefinement::default().size_full()))
+    }
+}
diff --git a/crates/gpui/examples/view_example/example_tests.rs b/crates/gpui/examples/view_example/example_tests.rs
new file mode 100644
index 00000000000..a3edae8cda1
--- /dev/null
+++ b/crates/gpui/examples/view_example/example_tests.rs
@@ -0,0 +1,131 @@
+//! Tests for the input composition. Require the `test-support` feature:
+//!
+//! ```sh
+//! cargo test -p gpui --example view_example --features test-support
+//! ```
+
+#[cfg(test)]
+mod tests {
+    use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*};
+
+    use crate::example_editor::Editor;
+    use crate::example_input::Input;
+    use crate::{Backspace, Delete, End, Home, Left, Right};
+
+    /// Two inputs, each backed by an editor we own (so the test can focus and
+    /// read them). Proves data flows through the shared `String` and that
+    /// sibling inputs stay isolated.
+    struct Harness {
+        a: Entity,
+        b: Entity,
+    }
+
+    impl Render for Harness {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+            gpui::div()
+                .child(Input::editor(self.a.clone()))
+                .child(Input::editor(self.b.clone()))
+        }
+    }
+
+    fn bind_keys(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            cx.bind_keys([
+                KeyBinding::new("backspace", Backspace, None),
+                KeyBinding::new("delete", Delete, None),
+                KeyBinding::new("left", Left, None),
+                KeyBinding::new("right", Right, None),
+                KeyBinding::new("home", Home, None),
+                KeyBinding::new("end", End, None),
+            ]);
+        });
+    }
+
+    fn setup(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity,
+        Entity,
+        Entity,
+        &mut gpui::VisualTestContext,
+    ) {
+        bind_keys(cx);
+
+        let (harness, cx) = cx.add_window_view(|window, cx| {
+            let a_value = cx.new(|_| String::new());
+            let b_value = cx.new(|_| String::new());
+            let a = cx.new(|cx| Editor::over(a_value, window, cx));
+            let b = cx.new(|cx| Editor::over(b_value, window, cx));
+            Harness { a, b }
+        });
+
+        let a = cx.read_entity(&harness, |h, _| h.a.clone());
+        let b = cx.read_entity(&harness, |h, _| h.b.clone());
+        let a_value = cx.read_entity(&a, |e, _| e.value.clone());
+        let b_value = cx.read_entity(&b, |e, _| e.value.clone());
+
+        // Focus the first input's editor.
+        cx.update(|window, cx| {
+            let focus_handle = a.read(cx).focus_handle.clone();
+            window.focus(&focus_handle, cx);
+        });
+
+        (a, a_value, b_value, cx)
+    }
+
+    #[gpui::test]
+    fn typing_updates_the_shared_string(cx: &mut TestAppContext) {
+        let (editor, a_value, _b_value, cx) = setup(cx);
+
+        cx.simulate_input("hello");
+
+        cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello"));
+        cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
+    }
+
+    #[gpui::test]
+    fn sibling_inputs_are_isolated(cx: &mut TestAppContext) {
+        let (_editor, a_value, b_value, cx) = setup(cx);
+
+        cx.simulate_input("x");
+
+        cx.read_entity(&a_value, |value, _| assert_eq!(value, "x"));
+        cx.read_entity(&b_value, |value, _| {
+            assert_eq!(value, "", "typing in input A must not touch input B")
+        });
+    }
+
+    #[gpui::test]
+    fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) {
+        let (editor, a_value, _b_value, cx) = setup(cx);
+
+        cx.simulate_input("hello");
+        cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
+
+        // Write the shared value from outside the editor. The old cursor (5)
+        // now points into the middle of a multi-byte character; the editor's
+        // observation must clamp it back onto a boundary.
+        cx.update(|_, cx| {
+            a_value.update(cx, |value, cx| {
+                *value = "日本".to_string();
+                cx.notify();
+            })
+        });
+
+        cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本"));
+        cx.read_entity(&editor, |editor, _| {
+            assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary");
+        });
+    }
+
+    #[gpui::test]
+    fn arrows_move_the_cursor(cx: &mut TestAppContext) {
+        let (editor, _a_value, _b_value, cx) = setup(cx);
+
+        cx.simulate_input("abc");
+        cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3));
+
+        cx.simulate_keystrokes("left left");
+        cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1));
+    }
+}
diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs
new file mode 100644
index 00000000000..07640b93294
--- /dev/null
+++ b/crates/gpui/examples/view_example/example_text_area.rs
@@ -0,0 +1,118 @@
+//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome,
+//! and `Enter` inserts a newline instead of being ignored. Constructible from a
+//! string or an editor, exactly like [`Input`](crate::example_input::Input).
+
+use gpui::{
+    App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div,
+    hsla, point, prelude::*, px, white,
+};
+
+use crate::Enter;
+use crate::example_editor::{Editor, standard_actions};
+
+enum Source {
+    Value(Entity),
+    Editor(Entity),
+}
+
+#[derive(IntoElement)]
+pub struct TextArea {
+    source: Source,
+    rows: usize,
+    color: Option,
+}
+
+impl TextArea {
+    pub fn new(value: Entity, rows: usize) -> Self {
+        Self {
+            source: Source::Value(value),
+            rows,
+            color: None,
+        }
+    }
+
+    pub fn editor(editor: Entity, rows: usize) -> Self {
+        Self {
+            source: Source::Editor(editor),
+            rows,
+            color: None,
+        }
+    }
+
+    pub fn color(mut self, color: Hsla) -> Self {
+        self.color = Some(color);
+        self
+    }
+}
+
+impl gpui::View for TextArea {
+    fn entity_id(&self) -> Option {
+        Some(match &self.source {
+            Source::Value(value) => value.entity_id(),
+            Source::Editor(editor) => editor.entity_id(),
+        })
+    }
+
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let editor = match self.source {
+            Source::Value(value) => {
+                window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
+            }
+            Source::Editor(editor) => editor,
+        };
+
+        let focus_handle = editor.read(cx).focus_handle.clone();
+        let is_focused = focus_handle.is_focused(window);
+        let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
+        let row_height = px(20.);
+        let box_height = row_height * self.rows as f32 + px(16.);
+
+        let border = if is_focused {
+            hsla(220. / 360., 0.8, 0.5, 1.)
+        } else {
+            hsla(0., 0., 0.75, 1.)
+        };
+
+        div()
+            .id("text-area")
+            .key_context("TextInput")
+            .track_focus(&focus_handle)
+            .cursor(CursorStyle::IBeam)
+            .map(standard_actions(editor.clone()))
+            // Enter is the one binding that differs from a single-line input.
+            .on_action({
+                let editor = editor.clone();
+                move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx))
+            })
+            .w(px(400.))
+            .h(box_height)
+            .p(px(8.))
+            .bg(white())
+            .border_1()
+            .border_color(border)
+            .when(is_focused, |this| {
+                this.shadow(vec![BoxShadow {
+                    color: hsla(220. / 360., 0.8, 0.5, 0.3),
+                    offset: point(px(0.), px(0.)),
+                    blur_radius: px(4.),
+                    spread_radius: px(1.),
+                    inset: false,
+                }])
+            })
+            .rounded(px(4.))
+            .overflow_hidden()
+            .line_height(row_height)
+            .text_size(px(14.))
+            .text_color(text_color)
+            // The cache style is computed from the `rows` prop: change `rows` and
+            // the editor's cached bounds change, busting its cache and re-laying
+            // out the text. (`Input` just uses `size_full()` — nothing to vary.)
+            .child(
+                editor.cached(
+                    StyleRefinement::default()
+                        .w_full()
+                        .h(row_height * self.rows as f32),
+                ),
+            )
+    }
+}
diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs
new file mode 100644
index 00000000000..0eac8494ffc
--- /dev/null
+++ b/crates/gpui/examples/view_example/view_example_main.rs
@@ -0,0 +1,173 @@
+#![cfg_attr(target_family = "wasm", no_main)]
+
+//! View example — composing a text input from the `View` primitives.
+//!
+//! The whole point: a text input is deceptively complicated, and `View` makes it
+//! easy to compose one. Three pieces, each shown in its own section:
+//!
+//!   * `Editor`  — the workhorse entity: cursor, blink, focus, keyboard, and a
+//!                 specialized text renderer. All the hard parts live here.
+//!   * `String`  — the data plane. `editor.text(cx)` / `value.read(cx)` get it out.
+//!   * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows
+//!                 the editor internally) OR an `Editor` (so you can read the cursor).
+//!
+//! Run: `cargo run -p gpui --example view_example`
+
+mod example_editor;
+mod example_input;
+mod example_text_area;
+
+#[cfg(test)]
+mod example_tests;
+
+use example_editor::Editor;
+use example_input::Input;
+use example_text_area::TextArea;
+
+use gpui::{
+    App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window,
+    WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size,
+};
+use gpui_platform::application;
+
+actions!(
+    view_example,
+    [Backspace, Delete, Left, Right, Home, End, Enter, Quit]
+);
+
+/// A tiny stateless view that reads an editor's cursor and is composed *beside*
+/// the thing editing it — two views over one entity, zero wiring.
+#[derive(IntoElement)]
+struct CursorReadout {
+    editor: Entity,
+}
+
+impl CursorReadout {
+    fn new(editor: Entity) -> Self {
+        Self { editor }
+    }
+}
+
+impl gpui::RenderOnce for CursorReadout {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let cursor = self.editor.read(cx).cursor;
+        div()
+            .text_sm()
+            .text_color(hsla(0., 0., 0.45, 1.))
+            .child(SharedString::from(format!("cursor @ {cursor}")))
+    }
+}
+
+struct ViewExample;
+
+impl ViewExample {
+    fn new() -> Self {
+        Self
+    }
+}
+
+impl Render for ViewExample {
+    fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+        // The data plane: plain strings, allocated at the top by the hook.
+        let name = window.use_state(cx, |_, _| String::new());
+        let email = window.use_state(cx, |_, _| String::from("me@example.com"));
+        let bio = window.use_state(cx, |_, _| String::new());
+        // Editors that own their own string internally — no extra wiring up top.
+        let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx));
+        let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx));
+
+        div()
+            .flex()
+            .flex_col()
+            .size_full()
+            .bg(rgb(0xf0f0f0))
+            .p(px(24.))
+            .gap(px(24.))
+            .child(
+                section("Inputs — from a String (cursor stays internal)")
+                    .child(Input::new(name).width(px(320.)))
+                    .child(
+                        Input::new(email)
+                            .width(px(320.))
+                            .color(hsla(0., 0., 0.3, 1.)),
+                    ),
+            )
+            .child(
+                section("Input — from an Editor (read its cursor beside it)").child(
+                    div()
+                        .flex()
+                        .items_center()
+                        .gap(px(12.))
+                        .child(Input::editor(owned.clone()).width(px(320.)))
+                        .child(CursorReadout::new(owned)),
+                ),
+            )
+            .child(
+                section("Text areas — from a String, or from an Editor")
+                    .child(TextArea::new(bio, 3))
+                    .child(
+                        div()
+                            .flex()
+                            .items_start()
+                            .gap(px(12.))
+                            .child(TextArea::editor(notes.clone(), 3).color(hsla(
+                                250. / 360.,
+                                0.7,
+                                0.4,
+                                1.,
+                            )))
+                            .child(CursorReadout::new(notes)),
+                    ),
+            )
+    }
+}
+
+/// A labeled vertical section.
+fn section(title: &str) -> Div {
+    div().flex().flex_col().gap(px(8.)).child(
+        div()
+            .text_sm()
+            .text_color(hsla(0., 0., 0.3, 1.))
+            .child(SharedString::from(title.to_string())),
+    )
+}
+
+fn run_example() {
+    application().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx);
+        cx.bind_keys([
+            KeyBinding::new("backspace", Backspace, None),
+            KeyBinding::new("delete", Delete, None),
+            KeyBinding::new("left", Left, None),
+            KeyBinding::new("right", Right, None),
+            KeyBinding::new("home", Home, None),
+            KeyBinding::new("end", End, None),
+            KeyBinding::new("enter", Enter, None),
+            KeyBinding::new("cmd-q", Quit, None),
+        ]);
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |_, cx| cx.new(|_| ViewExample::new()),
+        )
+        .unwrap();
+
+        cx.on_action(|_: &Quit, cx| cx.quit());
+        cx.activate(true);
+    });
+}
+
+#[cfg(not(target_family = "wasm"))]
+fn main() {
+    run_example();
+}
+
+#[cfg(target_family = "wasm")]
+#[wasm_bindgen::prelude::wasm_bindgen(start)]
+pub fn start() {
+    gpui_platform::web_init();
+    run_example();
+}
diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs
index e6ca25ecae0..0467c3e4fd8 100644
--- a/crates/gpui/src/app.rs
+++ b/crates/gpui/src/app.rs
@@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> {
 /// You won't interact with this type much outside of initial configuration and startup.
 pub struct Application(Rc);
 
+/// A strong handle to an [`Application`] started with [`Application::run_embedded`].
+///
+/// Dropping this handle releases the app, so an embedder must hold it for as long as the
+/// app should run. While held, it is the embedder's entry point back into GPUI each time
+/// the external run loop gives it control.
+pub struct ApplicationHandle {
+    app: Rc,
+}
+
+impl ApplicationHandle {
+    /// Invoke `f` with the app context. Must not be called re-entrantly from code that
+    /// is already inside an update; the app state is a `RefCell` and will panic on a
+    /// double borrow.
+    pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R {
+        let cx = &mut *self.app.borrow_mut();
+        f(cx)
+    }
+
+    /// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the
+    /// app alive remains this handle's job.
+    pub fn to_async(&self) -> AsyncApp {
+        self.update(|cx| cx.to_async())
+    }
+}
+
 /// Represents an application before it is fully launched. Once your app is
 /// configured, you'll start the app with `App::run`.
 impl Application {
@@ -209,6 +234,28 @@ impl Application {
         }));
     }
 
+    /// Start the application for an embedder that drives the run loop itself.
+    ///
+    /// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the
+    /// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms —
+    /// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest,
+    /// or a GPUI view hosted inside a foreign native application — implement
+    /// `Platform::run` to invoke the launch callback and return immediately. This method
+    /// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive
+    /// and lets the embedder re-enter it whenever the external run loop yields control.
+    pub fn run_embedded(self, on_finish_launching: F) -> ApplicationHandle
+    where
+        F: 'static + FnOnce(&mut App),
+    {
+        let this = self.0.clone();
+        let platform = self.0.borrow().platform.clone();
+        platform.run(Box::new(move || {
+            let cx = &mut *this.borrow_mut();
+            on_finish_launching(cx);
+        }));
+        ApplicationHandle { app: self.0 }
+    }
+
     /// Register a handler to be invoked when the platform instructs the application
     /// to open one or more URLs.
     pub fn on_open_urls(&self, mut callback: F) -> &Self
diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs
index f212f246caa..dbd82b1c2ef 100644
--- a/crates/gpui/src/element.rs
+++ b/crates/gpui/src/element.rs
@@ -33,12 +33,12 @@
 
 use crate::{
     A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId,
-    FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window,
+    FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window,
     util::FluentBuilder, window::with_element_arena,
 };
 use derive_more::{Deref, DerefMut};
 use std::{
-    any::{Any, type_name},
+    any::Any,
     fmt::{self, Debug, Display},
     mem, panic,
     sync::Arc,
@@ -208,116 +208,6 @@ pub trait ParentElement {
     }
 }
 
-/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro
-/// for [`RenderOnce`]
-#[doc(hidden)]
-pub struct Component {
-    component: Option,
-    #[cfg(debug_assertions)]
-    source: &'static core::panic::Location<'static>,
-}
-
-impl Component {
-    /// Create a new component from the given RenderOnce type.
-    #[track_caller]
-    pub fn new(component: C) -> Self {
-        Component {
-            component: Some(component),
-            #[cfg(debug_assertions)]
-            source: core::panic::Location::caller(),
-        }
-    }
-}
-
-fn prepaint_component(
-    (element, name): &mut (AnyElement, &'static str),
-    window: &mut Window,
-    cx: &mut App,
-) {
-    window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
-        element.prepaint(window, cx);
-    })
-}
-
-fn paint_component(
-    (element, name): &mut (AnyElement, &'static str),
-    window: &mut Window,
-    cx: &mut App,
-) {
-    window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
-        element.paint(window, cx);
-    })
-}
-impl Element for Component {
-    type RequestLayoutState = (AnyElement, &'static str);
-    type PrepaintState = ();
-
-    fn id(&self) -> Option {
-        None
-    }
-
-    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
-        #[cfg(debug_assertions)]
-        return Some(self.source);
-
-        #[cfg(not(debug_assertions))]
-        return None;
-    }
-
-    fn request_layout(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> (LayoutId, Self::RequestLayoutState) {
-        window.with_id(ElementId::Name(type_name::().into()), |window| {
-            let mut element = self
-                .component
-                .take()
-                .unwrap()
-                .render(window, cx)
-                .into_any_element();
-
-            let layout_id = element.request_layout(window, cx);
-            (layout_id, (element, type_name::()))
-        })
-    }
-
-    fn prepaint(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        _: Bounds,
-        state: &mut Self::RequestLayoutState,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        prepaint_component(state, window, cx);
-    }
-
-    fn paint(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        _: Bounds,
-        state: &mut Self::RequestLayoutState,
-        _: &mut Self::PrepaintState,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        paint_component(state, window, cx);
-    }
-}
-
-impl IntoElement for Component {
-    type Element = Self;
-
-    fn into_element(self) -> Self::Element {
-        self
-    }
-}
-
 /// A globally unique identifier for an element, used to track state across frames.
 #[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)]
 pub struct GlobalElementId(pub(crate) Arc<[ElementId]>);
diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs
index 8a42c8bd492..b816f7dd935 100644
--- a/crates/gpui/src/elements/animation.rs
+++ b/crates/gpui/src/elements/animation.rs
@@ -2,7 +2,8 @@ use scheduler::Instant;
 use std::{rc::Rc, time::Duration};
 
 use crate::{
-    AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window,
+    AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement,
+    ParentElement, Window,
 };
 
 pub use easing::*;
@@ -95,6 +96,16 @@ pub struct AnimationElement {
     animator: Box E + 'static>,
 }
 
+impl ParentElement for AnimationElement {
+    fn extend(&mut self, elements: impl IntoIterator) {
+        let Some(element) = &mut self.element else {
+            return;
+        };
+
+        element.extend(elements);
+    }
+}
+
 impl AnimationElement {
     /// Returns a new [`AnimationElement`] after applying the given function
     /// to the element being animated.
@@ -259,3 +270,33 @@ mod easing {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::InteractiveElement;
+    use crate::div;
+
+    use super::*;
+
+    // Before parent-animation-element, using .with_animation
+    // would not allow chaining .parent after. This is just a
+    // build check that we can call div().id().with_animation().child()
+    #[test]
+    fn test_animation_parent() {
+        div()
+            .id("id")
+            //
+            .with_animation(
+                "animation",
+                Animation::new(Duration::from_secs(1)),
+                |el, _t| {
+                    //
+                    el
+                },
+            )
+            .child(
+                //
+                div(),
+            );
+    }
+}
diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs
index 4a61069a0a9..04692c86a55 100644
--- a/crates/gpui/src/elements/div.rs
+++ b/crates/gpui/src/elements/div.rs
@@ -21,10 +21,10 @@ use crate::{
     Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox,
     HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent,
     KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
-    MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow,
-    ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
-    StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px,
-    size,
+    MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent,
+    MouseUpEvent, Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString,
+    Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea,
+    point, px, size,
 };
 use collections::HashMap;
 use gpui_util::ResultExt;
@@ -305,6 +305,22 @@ impl Interactivity {
             }));
     }
 
+    /// Bind the given callback to the mouse exit event, during the bubble phase.
+    /// The imperative API equivalent to [`InteractiveElement::on_mouse_exit`].
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    pub fn on_mouse_exit(
+        &mut self,
+        listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static,
+    ) {
+        self.mouse_exit_listeners
+            .push(Box::new(move |event, phase, hitbox, window, cx| {
+                if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
+                    (listener)(event, window, cx);
+                }
+            }));
+    }
+
     /// Bind the given callback to the mouse drag event of the given type. Note that this
     /// will be called for all move events, inside or outside of this element, as long as the
     /// drag was started with this element under the mouse. Useful for implementing draggable
@@ -919,6 +935,18 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Bind the given callback to the mouse exit event, during the bubble phase.
+    /// The fluent API equivalent to [`Interactivity::on_mouse_exit`].
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    fn on_mouse_exit(
+        mut self,
+        listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.interactivity().on_mouse_exit(listener);
+        self
+    }
+
     /// Bind the given callback to the mouse drag event of the given type. Note that this
     /// will be called for all move events, inside or outside of this element, as long as the
     /// drag was started with this element under the mouse. Useful for implementing draggable
@@ -1525,6 +1553,8 @@ pub(crate) type MousePressureListener =
     Box;
 pub(crate) type MouseMoveListener =
     Box;
+pub(crate) type MouseExitListener =
+    Box;
 
 pub(crate) type ScrollWheelListener =
     Box;
@@ -1907,6 +1937,7 @@ pub struct Interactivity {
     pub(crate) mouse_up_listeners: Vec,
     pub(crate) mouse_pressure_listeners: Vec,
     pub(crate) mouse_move_listeners: Vec,
+    pub(crate) mouse_exit_listeners: Vec,
     pub(crate) scroll_wheel_listeners: Vec,
     pub(crate) pinch_listeners: Vec,
     pub(crate) key_down_listeners: Vec,
@@ -2152,6 +2183,7 @@ impl Interactivity {
             || !self.mouse_pressure_listeners.is_empty()
             || !self.mouse_down_listeners.is_empty()
             || !self.mouse_move_listeners.is_empty()
+            || !self.mouse_exit_listeners.is_empty()
             || !self.click_listeners.is_empty()
             || !self.aux_click_listeners.is_empty()
             || !self.scroll_wheel_listeners.is_empty()
@@ -2531,6 +2563,13 @@ impl Interactivity {
             })
         }
 
+        for listener in self.mouse_exit_listeners.drain(..) {
+            let hitbox = hitbox.clone();
+            window.on_mouse_event(move |event: &MouseExitEvent, phase, window, cx| {
+                listener(event, phase, &hitbox, window, cx);
+            })
+        }
+
         for listener in self.scroll_wheel_listeners.drain(..) {
             let hitbox = hitbox.clone();
             window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
@@ -2821,7 +2860,6 @@ impl Interactivity {
             }
 
             if let Some(hover_listener) = self.hover_listener.take() {
-                let hitbox = hitbox.clone();
                 let was_hovered = element_state
                     .hover_listener_state
                     .get_or_insert_with(Default::default)
@@ -2830,22 +2868,35 @@ impl Interactivity {
                     .pending_mouse_down
                     .get_or_insert_with(Default::default)
                     .clone();
-
-                window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
-                    if phase != DispatchPhase::Bubble {
-                        return;
-                    }
-                    let is_hovered = has_mouse_down.borrow().is_none()
-                        && !cx.has_active_drag()
-                        && hitbox.is_hovered(window);
+                let hover_listener = Rc::new(hover_listener);
+                let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| {
                     let mut was_hovered = was_hovered.borrow_mut();
-
                     if is_hovered != *was_hovered {
                         *was_hovered = is_hovered;
                         drop(was_hovered);
-
                         hover_listener(&is_hovered, window, cx);
                     }
+                };
+
+                window.on_mouse_event({
+                    let update_hover = update_hover.clone();
+                    let hitbox = hitbox.clone();
+                    move |_: &MouseMoveEvent, phase, window, cx| {
+                        if phase == DispatchPhase::Bubble {
+                            let is_hovered = has_mouse_down.borrow().is_none()
+                                && !cx.has_active_drag()
+                                && hitbox.is_hovered(window);
+                            update_hover(is_hovered, window, cx);
+                        }
+                    }
+                });
+
+                // The pointer can leave the window without a final MouseMove, so also
+                // clear hover on MouseExited.
+                window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| {
+                    if phase == DispatchPhase::Bubble {
+                        update_hover(false, window, cx);
+                    }
                 });
             }
 
diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs
index 85b38d5234e..62b481bc530 100644
--- a/crates/gpui/src/elements/list.rs
+++ b/crates/gpui/src/elements/list.rs
@@ -338,6 +338,17 @@ impl ListState {
         self
     }
 
+    /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb
+    /// is correctly sized from the first frame, without measuring all items up front.
+    ///
+    /// As items are actually rendered their real heights replace the hint, so the scrollbar
+    /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`]
+    /// for lists where items have roughly uniform heights (e.g. table rows).
+    pub fn with_uniform_item_height(self, height: Pixels) -> Self {
+        self.apply_uniform_item_height(height);
+        self
+    }
+
     /// Reset this instantiation of the list state.
     ///
     /// Note that this will cause scroll events to be dropped until the next paint.
@@ -355,6 +366,33 @@ impl ListState {
         self.splice(0..old_count, element_count);
     }
 
+    /// Reset the list to `element_count` items, pre-populating every item with a
+    /// uniform height hint so the scrollbar thumb is correctly sized from the first
+    /// frame even for off-screen items.
+    pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) {
+        self.reset(element_count);
+        self.apply_uniform_item_height(height);
+    }
+
+    fn apply_uniform_item_height(&self, height: Pixels) {
+        let size_hint = Size {
+            width: px(0.),
+            height,
+        };
+        let mut state = self.0.borrow_mut();
+        let new_items = state
+            .items
+            .iter()
+            .map(|item| ListItem::Unmeasured {
+                size_hint: Some(item.size_hint().unwrap_or(size_hint)),
+                focus_handle: item.focus_handle(),
+            })
+            .collect::>();
+        let mut tree = SumTree::default();
+        tree.extend(new_items, ());
+        state.items = tree;
+    }
+
     /// Remeasure all items while preserving proportional scroll position.
     ///
     /// Use this when item heights may have changed (e.g., font size changes)
diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs
index 5765fd80aff..2a37d0b19b2 100644
--- a/crates/gpui/src/platform.rs
+++ b/crates/gpui/src/platform.rs
@@ -6,6 +6,9 @@ mod keystroke;
 #[expect(missing_docs)]
 pub mod layer_shell;
 
+/// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips.
+pub mod popup;
+
 #[cfg(any(test, feature = "bench"))]
 mod bench_dispatcher;
 
@@ -702,6 +705,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn show_window_menu(&self, _position: Point) {}
     fn start_window_move(&self) {}
     fn start_window_resize(&self, _edge: ResizeEdge) {}
+    fn set_input_region(&self, _region: Option<&[Bounds]>) {}
     fn window_decorations(&self) -> Decorations {
         Decorations::Server
     }
@@ -1676,6 +1680,14 @@ pub enum WindowKind {
     /// use sparingly!
     PopUp,
 
+    /// A parent-anchored, platform-native popup window for menus, comboboxes, context menus and
+    /// tooltips. Unlike [`WindowKind::PopUp`], it is positioned relative to a parent window.
+    ///
+    /// The popup's size comes from [`WindowOptions::window_bounds`], whose origin is ignored.
+    /// See [`popup::PopupOptions`] for the placement options. Platforms without a native
+    /// implementation reject it with [`popup::PopupNotSupportedError`].
+    AnchoredPopup(popup::PopupOptions),
+
     /// A floating window that appears on top of its parent window
     Floating,
 
diff --git a/crates/gpui/src/platform/popup.rs b/crates/gpui/src/platform/popup.rs
new file mode 100644
index 00000000000..a1f8d7ea0d5
--- /dev/null
+++ b/crates/gpui/src/platform/popup.rs
@@ -0,0 +1,134 @@
+use bitflags::bitflags;
+use thiserror::Error;
+
+use crate::{AnyWindowHandle, Bounds, Pixels, Point};
+
+/// Options for a parent-anchored popup window such as a menu, dropdown, context menu or tooltip.
+///
+/// A popup is placed relative to an anchor rectangle on its parent window rather than at an
+/// absolute screen position. The platform resolves the final position, so this works both on
+/// systems where the compositor owns window placement (Wayland) and on platforms with absolute
+/// coordinates.
+///
+/// The popup's size comes from [`WindowOptions::window_bounds`](crate::WindowOptions), whose
+/// origin is ignored. All coordinates are in logical pixels.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PopupOptions {
+    /// The window the popup is anchored to.
+    pub parent: AnyWindowHandle,
+
+    /// The rectangle the popup is positioned relative to, in the parent window's coordinate
+    /// space (the same space element bounds are in). For example, a dropdown menu uses the
+    /// bounds of the button that opened it.
+    pub anchor_rect: Bounds,
+
+    /// Which point of [`Self::anchor_rect`] the popup is anchored to.
+    pub anchor: PopupAnchor,
+
+    /// The direction in which the popup extends away from the anchor point. A dropdown that
+    /// drops below its button anchors to [`PopupAnchor::BottomLeft`] with a gravity of
+    /// [`PopupGravity::BottomRight`] so it grows down and to the right.
+    pub gravity: PopupGravity,
+
+    /// How the platform may adjust the popup if the requested placement would put it off-screen.
+    pub constraint_adjustment: PopupConstraintAdjustment,
+
+    /// An additional offset applied to the popup after anchoring.
+    pub offset: Point,
+
+    /// Whether the popup should take an explicit input grab.
+    ///
+    /// Grabbing popups behave like menus: they take keyboard focus and are dismissed when the
+    /// user clicks outside of them or presses a dismissing key. Use it for menus and comboboxes,
+    /// not for tooltips or other passive popups.
+    ///
+    /// A grab must be requested while the triggering input is still active, in practice the
+    /// press of the mouse button that opens the popup. Open grabbing popups from a mouse-down
+    /// handler rather than a click handler, otherwise the grab is refused.
+    ///
+    /// Automatic dismissal only covers input aimed at other applications. A click elsewhere in
+    /// your own application still reaches it as usual, so closing the popup in that case is up
+    /// to you. Nested grabbing popups must be closed in the reverse order they were opened.
+    pub grab: bool,
+}
+
+/// The point of the anchor rectangle that a popup is anchored to.
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum PopupAnchor {
+    /// Anchor to the center of the anchor rectangle.
+    #[default]
+    Center,
+    /// Anchor to the center of the top edge.
+    Top,
+    /// Anchor to the center of the bottom edge.
+    Bottom,
+    /// Anchor to the center of the left edge.
+    Left,
+    /// Anchor to the center of the right edge.
+    Right,
+    /// Anchor to the top-left corner.
+    TopLeft,
+    /// Anchor to the bottom-left corner.
+    BottomLeft,
+    /// Anchor to the top-right corner.
+    TopRight,
+    /// Anchor to the bottom-right corner.
+    BottomRight,
+}
+
+/// The direction in which a popup extends away from its anchor point.
+///
+/// For instance, a gravity of [`PopupGravity::BottomRight`] places the popup below and to the
+/// right of the anchor point.
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum PopupGravity {
+    /// The popup is centered over the anchor point.
+    #[default]
+    Center,
+    /// The popup extends upwards from the anchor point.
+    Top,
+    /// The popup extends downwards from the anchor point.
+    Bottom,
+    /// The popup extends to the left of the anchor point.
+    Left,
+    /// The popup extends to the right of the anchor point.
+    Right,
+    /// The popup extends up and to the left of the anchor point.
+    TopLeft,
+    /// The popup extends down and to the left of the anchor point.
+    BottomLeft,
+    /// The popup extends up and to the right of the anchor point.
+    TopRight,
+    /// The popup extends down and to the right of the anchor point.
+    BottomRight,
+}
+
+bitflags! {
+    /// How a popup may be adjusted by the platform if the requested placement would put it
+    /// off-screen. If no flags are set, the popup is placed exactly as requested and may be
+    /// clipped.
+    #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+    pub struct PopupConstraintAdjustment: u32 {
+        /// The popup may be slid horizontally to stay on-screen.
+        const SLIDE_X = 1;
+        /// The popup may be slid vertically to stay on-screen.
+        const SLIDE_Y = 2;
+        /// The popup's anchor and gravity may be flipped horizontally to stay on-screen.
+        const FLIP_X = 4;
+        /// The popup's anchor and gravity may be flipped vertically to stay on-screen.
+        const FLIP_Y = 8;
+        /// The popup may be shrunk horizontally to stay on-screen.
+        const RESIZE_X = 16;
+        /// The popup may be shrunk vertically to stay on-screen.
+        const RESIZE_Y = 32;
+    }
+}
+
+/// Returned when the current platform has no native popup implementation yet.
+///
+/// Native popups are separate from gpui's in-window popovers, which are drawn as elements inside
+/// an existing window. A caller that wants a popup on every platform should treat this error as
+/// a cue to fall back to that in-window rendering.
+#[derive(Debug, Error)]
+#[error("popups are not supported on this platform")]
+pub struct PopupNotSupportedError;
diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs
index bc7f5d79eac..ea2ad7b2b90 100644
--- a/crates/gpui/src/scene.rs
+++ b/crates/gpui/src/scene.rs
@@ -22,6 +22,20 @@ pub type PathVertex_ScaledPixels = PathVertex;
 #[expect(missing_docs)]
 pub type DrawOrder = u32;
 
+/// A boolean stored as a `u32` so that GPU-facing structs contain no
+/// compiler-inserted padding bytes, which would be undefined behavior to
+/// reinterpret as `&[u8]` when writing instance buffers. Guaranteed to be
+/// `0` or `1` by construction; shaders read it as a `u32`/`uint`.
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[repr(transparent)]
+pub struct PaddedBool32(u32);
+
+impl From for PaddedBool32 {
+    fn from(value: bool) -> Self {
+        PaddedBool32(value as u32)
+    }
+}
+
 #[derive(Default)]
 #[expect(missing_docs)]
 pub struct Scene {
@@ -511,7 +525,7 @@ pub struct Underline {
     pub content_mask: ContentMask,
     pub color: Hsla,
     pub thickness: ScaledPixels,
-    pub wavy: u32,
+    pub wavy: PaddedBool32,
 }
 
 impl From for Primitive {
@@ -701,7 +715,7 @@ impl From for Primitive {
 pub struct PolychromeSprite {
     pub order: DrawOrder,
     pub pad: u32,
-    pub grayscale: bool,
+    pub grayscale: PaddedBool32,
     pub opacity: f32,
     pub bounds: Bounds,
     pub content_mask: ContentMask,
diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs
index 5abd3341d22..35124b15ef8 100644
--- a/crates/gpui/src/svg_renderer.rs
+++ b/crates/gpui/src/svg_renderer.rs
@@ -228,13 +228,30 @@ impl SvgRenderer {
     }
 
     fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result {
+        // Cap the size of the rendered pixmap to avoid texture allocation panics
+        // Related issue: #56466
+        const MAX_SIZE: f32 = 8192.0;
+
         let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
         let svg_size = tree.size();
-        let scale = match size {
+        let mut scale = match size {
             SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(),
             SvgSize::ScaleFactor(scale) => scale,
         };
 
+        let width = svg_size.width() * scale;
+        if width > MAX_SIZE {
+            log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
+            scale *= MAX_SIZE / width;
+        }
+        let height = svg_size.height() * scale;
+        if height > MAX_SIZE {
+            log::warn!(
+                "Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})"
+            );
+            scale *= MAX_SIZE / height;
+        }
+
         // Render the SVG to a pixmap with the specified width and height.
         let mut pixmap = resvg::tiny_skia::Pixmap::new(
             (svg_size.width() * scale) as u32,
diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs
index 39b87dbb803..394f00c1082 100644
--- a/crates/gpui/src/view.rs
+++ b/crates/gpui/src/view.rs
@@ -1,36 +1,24 @@
 use crate::{
     AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId,
     Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex,
-    Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity,
+    Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity,
 };
 use crate::{Empty, Window};
 use anyhow::Result;
 use collections::FxHashSet;
 use refineable::Refineable;
 use std::mem;
-use std::rc::Rc;
 use std::{any::TypeId, fmt, ops::Range};
 
-struct AnyViewState {
-    prepaint_range: Range,
-    paint_range: Range,
-    cache_key: ViewCacheKey,
-    accessed_entities: FxHashSet,
-}
-
-#[derive(Default)]
-struct ViewCacheKey {
-    bounds: Bounds,
-    content_mask: ContentMask,
-    text_style: TextStyle,
-}
-
-/// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type.
+/// A dynamically-typed view handle that can be downcast to a specific `Entity`.
+///
+/// This is the type-erased counterpart to [`ViewElement`]: it holds an entity plus
+/// a function pointer to its render, and is itself a [`View`], so embedding it as an
+/// element goes through the same [`ViewElement`] machinery as any other view.
 #[derive(Clone, Debug)]
 pub struct AnyView {
     entity: AnyEntity,
     render: fn(&AnyView, &mut Window, &mut App) -> AnyElement,
-    cached_style: Option>,
 }
 
 impl From> for AnyView {
@@ -38,18 +26,18 @@ impl From> for AnyView {
         AnyView {
             entity: value.into_any(),
             render: any_view::render::,
-            cached_style: None,
         }
     }
 }
 
 impl AnyView {
-    /// Indicate that this view should be cached when using it as an element.
-    /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [Context::notify] has not been called since it was rendered.
-    /// The one exception is when [Window::refresh] is called, in which case caching is ignored.
-    pub fn cached(mut self, style: StyleRefinement) -> Self {
-        self.cached_style = Some(style.into());
-        self
+    /// Embed this view as a cached [`ViewElement`] laid out at `style`.
+    ///
+    /// The rendered subtree is recycled from the previous frame unless
+    /// [Context::notify] was called on the backing entity since it was rendered
+    /// (or [Window::refresh] is called, which ignores caching).
+    pub fn cached(self, style: StyleRefinement) -> ViewElement {
+        ViewElement::new(self).cached(style)
     }
 
     /// Convert this to a weak handle.
@@ -68,7 +56,6 @@ impl AnyView {
             Err(entity) => Err(Self {
                 entity,
                 render: self.render,
-                cached_style: self.cached_style,
             }),
         }
     }
@@ -78,7 +65,7 @@ impl AnyView {
         self.entity.entity_type
     }
 
-    /// Gets the entity id of this handle.
+    /// The [`EntityId`] of this view.
     pub fn entity_id(&self) -> EntityId {
         self.entity.entity_id()
     }
@@ -92,184 +79,48 @@ impl PartialEq for AnyView {
 
 impl Eq for AnyView {}
 
-impl Element for AnyView {
-    type RequestLayoutState = Option;
-    type PrepaintState = Option;
-
-    fn id(&self) -> Option {
-        Some(ElementId::View(self.entity_id()))
+/// `AnyView` is the type-erased [`View`]: its `render` is a function pointer rather
+/// than a concrete type, but it participates in the reactive graph exactly like any
+/// other view via [`ViewElement`].
+impl View for AnyView {
+    fn entity_id(&self) -> Option {
+        Some(self.entity.entity_id())
     }
 
-    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
-        None
-    }
-
-    fn request_layout(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> (LayoutId, Self::RequestLayoutState) {
-        window.with_rendered_view(self.entity_id(), |window| {
-            // Disable caching when inspecting so that mouse_hit_test has all hitboxes.
-            let caching_disabled = window.is_inspector_picking(cx);
-            match self.cached_style.as_ref() {
-                Some(style) if !caching_disabled => {
-                    let mut root_style = Style::default();
-                    root_style.refine(style);
-                    let layout_id = window.request_layout(root_style, None, cx);
-                    (layout_id, None)
-                }
-                _ => {
-                    let mut element = (self.render)(self, window, cx);
-                    let layout_id = element.request_layout(window, cx);
-                    (layout_id, Some(element))
-                }
-            }
-        })
-    }
-
-    fn prepaint(
-        &mut self,
-        global_id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        bounds: Bounds,
-        element: &mut Self::RequestLayoutState,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Option {
-        window.set_view_id(self.entity_id());
-        window.with_rendered_view(self.entity_id(), |window| {
-            if let Some(mut element) = element.take() {
-                element.prepaint(window, cx);
-                return Some(element);
-            }
-
-            window.with_element_state::(
-                global_id.unwrap(),
-                |element_state, window| {
-                    let content_mask = window.content_mask();
-                    let text_style = window.text_style();
-
-                    if let Some(mut element_state) = element_state
-                        && element_state.cache_key.bounds == bounds
-                        && element_state.cache_key.content_mask == content_mask
-                        && element_state.cache_key.text_style == text_style
-                        && !window.dirty_views.contains(&self.entity_id())
-                        && !window.refreshing
-                    {
-                        let prepaint_start = window.prepaint_index();
-                        window.reuse_prepaint(element_state.prepaint_range.clone());
-                        cx.entities
-                            .extend_accessed(&element_state.accessed_entities);
-                        let prepaint_end = window.prepaint_index();
-                        element_state.prepaint_range = prepaint_start..prepaint_end;
-
-                        return (None, element_state);
-                    }
-
-                    let refreshing = mem::replace(&mut window.refreshing, true);
-                    let prepaint_start = window.prepaint_index();
-                    let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| {
-                        let mut element = (self.render)(self, window, cx);
-                        element.layout_as_root(bounds.size.into(), window, cx);
-                        element.prepaint_at(bounds.origin, window, cx);
-                        element
-                    });
-
-                    let prepaint_end = window.prepaint_index();
-                    window.refreshing = refreshing;
-
-                    (
-                        Some(element),
-                        AnyViewState {
-                            accessed_entities,
-                            prepaint_range: prepaint_start..prepaint_end,
-                            paint_range: PaintIndex::default()..PaintIndex::default(),
-                            cache_key: ViewCacheKey {
-                                bounds,
-                                content_mask,
-                                text_style,
-                            },
-                        },
-                    )
-                },
-            )
-        })
-    }
-
-    fn paint(
-        &mut self,
-        global_id: Option<&GlobalElementId>,
-        _inspector_id: Option<&InspectorElementId>,
-        _bounds: Bounds,
-        _: &mut Self::RequestLayoutState,
-        element: &mut Self::PrepaintState,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        window.with_rendered_view(self.entity_id(), |window| {
-            let caching_disabled = window.is_inspector_picking(cx);
-            if self.cached_style.is_some() && !caching_disabled {
-                window.with_element_state::(
-                    global_id.unwrap(),
-                    |element_state, window| {
-                        let mut element_state = element_state.unwrap();
-
-                        let paint_start = window.paint_index();
-
-                        if let Some(element) = element {
-                            let refreshing = mem::replace(&mut window.refreshing, true);
-                            element.paint(window, cx);
-                            window.refreshing = refreshing;
-                        } else {
-                            window.reuse_paint(element_state.paint_range.clone());
-                        }
-
-                        let paint_end = window.paint_index();
-                        element_state.paint_range = paint_start..paint_end;
-
-                        ((), element_state)
-                    },
-                )
-            } else {
-                element.as_mut().unwrap().paint(window, cx);
-            }
-        });
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        (self.render)(&self, window, cx)
     }
 }
 
 impl IntoElement for Entity {
-    type Element = AnyView;
+    type Element = ViewElement>;
 
     fn into_element(self) -> Self::Element {
-        self.into()
+        ViewElement::new(self)
     }
 }
 
 impl IntoElement for AnyView {
-    type Element = Self;
+    type Element = ViewElement;
 
     fn into_element(self) -> Self::Element {
-        self
+        ViewElement::new(self)
     }
 }
 
-/// A weak, dynamically-typed view handle that does not prevent the view from being released.
+/// A weak, dynamically-typed view handle.
 pub struct AnyWeakView {
     entity: AnyWeakEntity,
     render: fn(&AnyView, &mut Window, &mut App) -> AnyElement,
 }
 
 impl AnyWeakView {
-    /// Convert to a strongly-typed handle if the referenced view has not yet been released.
+    /// Upgrade to a strong `AnyView` handle, if the view is still alive.
     pub fn upgrade(&self) -> Option {
         let entity = self.entity.upgrade()?;
         Some(AnyView {
             entity,
             render: self.render,
-            cached_style: None,
         })
     }
 }
@@ -310,6 +161,335 @@ mod any_view {
     }
 }
 
+/// A renderable that participates in GPUI's reactive graph — the unifying model
+/// behind [`Render`] and [`RenderOnce`].
+///
+/// When `entity_id()` returns `Some`, that id becomes the view's identity: it gets
+/// a unique element-id space (so internal `use_state` / `.id(..)` never collide
+/// across siblings) and `cx.notify()` on that entity re-renders only this view's
+/// subtree. `None` behaves like a stateless component.
+///
+/// You rarely implement `View` directly. `Entity` and any `T: RenderOnce`
+/// get a blanket impl below; implement it by hand only when a component needs both
+/// parent-supplied props *and* a backing entity for identity.
+pub trait View: 'static + Sized {
+    /// This view's identity, if it has one. A view typically holds the backing
+    /// entity as a field and returns its [`EntityId`] here.
+    ///
+    /// The id becomes this view's [`ElementId`], so two views keyed on the same
+    /// entity must not be rendered at the same position in the element tree
+    /// (e.g. as siblings under the same parent): their internal element state
+    /// (`use_state`, scroll offsets, etc.) would silently collide. Nesting is
+    /// fine — the id is scoped by the parent path.
+    fn entity_id(&self) -> Option;
+
+    /// Render this view into an element tree, consuming `self`.
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement;
+}
+
+/// A stateless component (`RenderOnce`) is a `View` with no identity.
+impl View for T {
+    fn entity_id(&self) -> Option {
+        None
+    }
+
+    #[inline]
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        RenderOnce::render(self, window, cx)
+    }
+}
+
+/// An entity that renders itself (`Render`) is a `View` keyed on its own id.
+impl View for Entity {
+    fn entity_id(&self) -> Option {
+        Some(Entity::entity_id(self))
+    }
+
+    #[inline]
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        self.update(cx, |this, cx| {
+            Render::render(this, window, cx).into_any_element()
+        })
+    }
+}
+
+impl Entity {
+    /// Embed this entity as a cached [`ViewElement`] laid out at `style`.
+    ///
+    /// The rendered subtree is reused until the entity is notified (or the
+    /// cached bounds / text style change). Caching requires a definite size:
+    /// a cached view is laid out from `style` and is *not* measured from its
+    /// contents. Use [`ViewElement::new`] (or `.child(entity)`) for the
+    /// uncached case.
+    #[track_caller]
+    pub fn cached(self, style: StyleRefinement) -> ViewElement> {
+        ViewElement::new(self).cached(style)
+    }
+}
+
+/// The element type for [`View`] implementations. Wraps a `View` and hooks it
+/// into layout, prepaint, and paint. Constructed via [`ViewElement::new`].
+#[doc(hidden)]
+pub struct ViewElement {
+    view: Option,
+    entity_id: Option,
+    cached_style: Option,
+    #[cfg(debug_assertions)]
+    source: &'static core::panic::Location<'static>,
+}
+
+impl ViewElement {
+    /// Wrap a [`View`] as an element.
+    #[track_caller]
+    pub fn new(view: V) -> Self {
+        let entity_id = view.entity_id();
+        ViewElement {
+            entity_id,
+            cached_style: None,
+            view: Some(view),
+            #[cfg(debug_assertions)]
+            source: core::panic::Location::caller(),
+        }
+    }
+
+    /// Enable caching of this view's rendered subtree, laid out at `style`.
+    /// The composer supplies the layout style because caching skips rendering
+    /// the contents to measure them.
+    ///
+    /// Crate-private on purpose: caching is only sound for entity-backed views,
+    /// where [`Context::notify`] is the contract that busts the cache. A stateless
+    /// view has no such contract, so a frozen subtree could never be invalidated.
+    /// Reach this through [`Entity::cached`] or [`AnyView::cached`], which are
+    /// entity-backed by construction.
+    pub(crate) fn cached(mut self, style: StyleRefinement) -> Self {
+        self.cached_style = Some(style);
+        self
+    }
+}
+
+impl IntoElement for ViewElement {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+struct ViewElementState {
+    prepaint_range: Range,
+    paint_range: Range,
+    cache_key: ViewElementCacheKey,
+    accessed_entities: FxHashSet,
+}
+
+struct ViewElementCacheKey {
+    bounds: Bounds,
+    content_mask: ContentMask,
+    text_style: TextStyle,
+}
+
+impl Element for ViewElement {
+    type RequestLayoutState = Option;
+    type PrepaintState = Option;
+
+    fn id(&self) -> Option {
+        self.entity_id.map(ElementId::View)
+    }
+
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        #[cfg(debug_assertions)]
+        return Some(self.source);
+
+        #[cfg(not(debug_assertions))]
+        return None;
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        if let Some(entity_id) = self.entity_id {
+            // Stateful path: create a reactive boundary.
+            window.with_rendered_view(entity_id, |window| {
+                let caching_disabled = window.is_inspector_picking(cx);
+                match self.cached_style.as_ref() {
+                    Some(style) if !caching_disabled => {
+                        let mut root_style = Style::default();
+                        root_style.refine(style);
+                        let layout_id = window.request_layout(root_style, None, cx);
+                        (layout_id, None)
+                    }
+                    _ => {
+                        let mut element = self
+                            .view
+                            .take()
+                            .unwrap()
+                            .render(window, cx)
+                            .into_any_element();
+                        let layout_id = element.request_layout(window, cx);
+                        (layout_id, Some(element))
+                    }
+                }
+            })
+        } else {
+            // Stateless path: isolate subtree via type name (no entity identity).
+            window.with_id(
+                ElementId::Name(std::any::type_name::().into()),
+                |window| {
+                    let mut element = self
+                        .view
+                        .take()
+                        .unwrap()
+                        .render(window, cx)
+                        .into_any_element();
+                    let layout_id = element.request_layout(window, cx);
+                    (layout_id, Some(element))
+                },
+            )
+        }
+    }
+
+    fn prepaint(
+        &mut self,
+        global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        bounds: Bounds,
+        element: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option {
+        if let Some(entity_id) = self.entity_id {
+            // Stateful path.
+            window.set_view_id(entity_id);
+            window.with_rendered_view(entity_id, |window| {
+                if let Some(mut element) = element.take() {
+                    element.prepaint(window, cx);
+                    return Some(element);
+                }
+
+                window.with_element_state::(
+                    global_id.unwrap(),
+                    |element_state, window| {
+                        let content_mask = window.content_mask();
+                        let text_style = window.text_style();
+
+                        if let Some(mut element_state) = element_state
+                            && element_state.cache_key.bounds == bounds
+                            && element_state.cache_key.content_mask == content_mask
+                            && element_state.cache_key.text_style == text_style
+                            && !window.dirty_views.contains(&entity_id)
+                            && !window.refreshing
+                        {
+                            let prepaint_start = window.prepaint_index();
+                            window.reuse_prepaint(element_state.prepaint_range.clone());
+                            cx.entities
+                                .extend_accessed(&element_state.accessed_entities);
+                            let prepaint_end = window.prepaint_index();
+                            element_state.prepaint_range = prepaint_start..prepaint_end;
+
+                            return (None, element_state);
+                        }
+
+                        let refreshing = mem::replace(&mut window.refreshing, true);
+                        let prepaint_start = window.prepaint_index();
+                        let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| {
+                            let mut element = self
+                                .view
+                                .take()
+                                .unwrap()
+                                .render(window, cx)
+                                .into_any_element();
+                            element.layout_as_root(bounds.size.into(), window, cx);
+                            element.prepaint_at(bounds.origin, window, cx);
+                            element
+                        });
+
+                        let prepaint_end = window.prepaint_index();
+                        window.refreshing = refreshing;
+
+                        (
+                            Some(element),
+                            ViewElementState {
+                                accessed_entities,
+                                prepaint_range: prepaint_start..prepaint_end,
+                                paint_range: PaintIndex::default()..PaintIndex::default(),
+                                cache_key: ViewElementCacheKey {
+                                    bounds,
+                                    content_mask,
+                                    text_style,
+                                },
+                            },
+                        )
+                    },
+                )
+            })
+        } else {
+            // Stateless path: just prepaint the element.
+            window.with_id(
+                ElementId::Name(std::any::type_name::().into()),
+                |window| {
+                    element.as_mut().unwrap().prepaint(window, cx);
+                },
+            );
+            Some(element.take().unwrap())
+        }
+    }
+
+    fn paint(
+        &mut self,
+        global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        _bounds: Bounds,
+        _request_layout: &mut Self::RequestLayoutState,
+        element: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        if let Some(entity_id) = self.entity_id {
+            // Stateful path.
+            window.with_rendered_view(entity_id, |window| {
+                let caching_disabled = window.is_inspector_picking(cx);
+                if self.cached_style.is_some() && !caching_disabled {
+                    window.with_element_state::(
+                        global_id.unwrap(),
+                        |element_state, window| {
+                            let mut element_state = element_state.unwrap();
+
+                            let paint_start = window.paint_index();
+
+                            if let Some(element) = element {
+                                let refreshing = mem::replace(&mut window.refreshing, true);
+                                element.paint(window, cx);
+                                window.refreshing = refreshing;
+                            } else {
+                                window.reuse_paint(element_state.paint_range.clone());
+                            }
+
+                            let paint_end = window.paint_index();
+                            element_state.paint_range = paint_start..paint_end;
+
+                            ((), element_state)
+                        },
+                    )
+                } else {
+                    element.as_mut().unwrap().paint(window, cx);
+                }
+            });
+        } else {
+            // Stateless path: just paint the element.
+            window.with_id(
+                ElementId::Name(std::any::type_name::().into()),
+                |window| {
+                    element.as_mut().unwrap().paint(window, cx);
+                },
+            );
+        }
+    }
+}
+
 /// A view that renders nothing
 pub struct EmptyView;
 
diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs
index 198fab268e8..7059cf863a6 100644
--- a/crates/gpui/src/window.rs
+++ b/crates/gpui/src/window.rs
@@ -1999,6 +1999,16 @@ impl Window {
         self.platform_window.start_window_resize(edge);
     }
 
+    /// Linux (wayland) only: Set the window's input region, the area that receives pointer
+    /// and touch input. Events outside it pass through to whatever is below the window.
+    ///
+    /// - `Some(rects)` restricts input to the union of `rects`, in window coordinates.
+    /// - `Some(&[])` is an empty region, so the window receives no pointer or touch input.
+    /// - `None` resets the region to the default, so the whole window receives input again.
+    pub fn set_input_region(&self, region: Option<&[Bounds]>) {
+        self.platform_window.set_input_region(region);
+    }
+
     /// Return the `WindowBounds` to indicate that how a window should be opened
     /// after it has been closed
     pub fn window_bounds(&self) -> WindowBounds {
@@ -2240,6 +2250,7 @@ impl Window {
         self.scale_factor = self.platform_window.scale_factor();
         self.viewport_size = self.platform_window.content_size();
         self.display_id = self.platform_window.display().map(|display| display.id());
+        self.mouse_position = self.platform_window.mouse_position();
 
         self.refresh();
 
@@ -2813,7 +2824,7 @@ impl Window {
         };
 
         // Layout all root elements.
-        let mut root_element = self.root.as_ref().unwrap().clone().into_any();
+        let mut root_element = self.root.as_ref().unwrap().clone().into_any_element();
         root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx);
 
         #[cfg(any(feature = "inspector", debug_assertions))]
@@ -2825,12 +2836,12 @@ impl Window {
         let mut active_drag_element = None;
         let mut tooltip_element = None;
         if let Some(prompt) = self.prompt.take() {
-            let mut element = prompt.view.any_view().into_any();
+            let mut element = prompt.view.any_view().into_any_element();
             element.prepaint_as_root(Point::default(), root_size.into(), self, cx);
             prompt_element = Some(element);
             self.prompt = Some(prompt);
         } else if let Some(active_drag) = cx.active_drag.take() {
-            let mut element = active_drag.view.clone().into_any();
+            let mut element = active_drag.view.clone().into_any_element();
             let offset = self.mouse_position() - active_drag.cursor_offset;
             element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx);
             active_drag_element = Some(element);
@@ -2894,7 +2905,7 @@ impl Window {
                 log::error!("Unexpectedly absent TooltipRequest");
                 continue;
             };
-            let mut element = tooltip_request.tooltip.view.clone().into_any();
+            let mut element = tooltip_request.tooltip.view.clone().into_any_element();
             let mouse_position = tooltip_request.tooltip.mouse_position;
             let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self, cx);
 
@@ -3801,7 +3812,7 @@ impl Window {
             content_mask: self.snapped_content_mask(),
             color: style.color.unwrap_or_default().opacity(element_opacity),
             thickness,
-            wavy: if style.wavy { 1 } else { 0 },
+            wavy: style.wavy.into(),
         });
     }
 
@@ -3831,7 +3842,7 @@ impl Window {
             content_mask: self.snapped_content_mask(),
             thickness: self.snap_stroke(style.thickness),
             color: style.color.unwrap_or_default().opacity(opacity),
-            wavy: 0,
+            wavy: false.into(),
         });
     }
 
@@ -3991,7 +4002,7 @@ impl Window {
             self.next_frame.scene.insert_primitive(PolychromeSprite {
                 order: 0,
                 pad: 0,
-                grayscale: false,
+                grayscale: false.into(),
                 bounds,
                 corner_radii: Default::default(),
                 content_mask,
@@ -4106,7 +4117,7 @@ impl Window {
         self.next_frame.scene.insert_primitive(PolychromeSprite {
             order: 0,
             pad: 0,
-            grayscale,
+            grayscale: grayscale.into(),
             bounds,
             content_mask,
             corner_radii,
diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml
index a1e0531f3d5..262da8d4dff 100644
--- a/crates/gpui_linux/Cargo.toml
+++ b/crates/gpui_linux/Cargo.toml
@@ -96,7 +96,7 @@ scap = { workspace = true, optional = true }
 
 # Wayland
 calloop-wayland-source = { version = "0.4.1", optional = true }
-wayland-backend = { version = "0.3.3", features = [
+wayland-backend = { version = "0.3.15", features = [
     "client_system",
     "dlopen",
 ], optional = true }
diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs
index 2a178521edc..fd9caf89fc1 100644
--- a/crates/gpui_linux/src/linux/dispatcher.rs
+++ b/crates/gpui_linux/src/linux/dispatcher.rs
@@ -127,9 +127,12 @@ impl PlatformDispatcher for LinuxDispatcher {
     }
 
     fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) {
-        self.timer_sender
-            .send(TimerAfter { duration, runnable })
-            .ok();
+        if let Err(err) = self.timer_sender.send(TimerAfter { duration, runnable }) {
+            // The timer thread has shut down. Dropping a scheduled runnable cancels its task
+            // and makes the next poll of any awaiter panic. Leaking leaves the task pending,
+            // which is acceptable during shutdown.
+            std::mem::forget(err);
+        }
     }
 
     fn spawn_realtime(&self, f: Box) {
diff --git a/crates/gpui_linux/src/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs
index 2237aeb1941..6667a3b5a1f 100644
--- a/crates/gpui_linux/src/linux/headless.rs
+++ b/crates/gpui_linux/src/linux/headless.rs
@@ -1,3 +1,4 @@
 mod client;
+mod window;
 
 pub(crate) use client::*;
diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs
index e127da1f2f2..06da3cbd945 100644
--- a/crates/gpui_linux/src/linux/headless/client.rs
+++ b/crates/gpui_linux/src/linux/headless/client.rs
@@ -4,6 +4,7 @@ use std::rc::Rc;
 use calloop::{EventLoop, LoopHandle};
 use gpui_util::ResultExt;
 
+use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow};
 use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout};
 use gpui::{
     AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout,
@@ -14,6 +15,7 @@ pub struct HeadlessClientState {
     pub(crate) _loop_handle: LoopHandle<'static, HeadlessClient>,
     pub(crate) event_loop: Option>,
     pub(crate) common: LinuxCommon,
+    pub(crate) display: Rc,
 }
 
 #[derive(Clone)]
@@ -47,6 +49,7 @@ impl HeadlessClient {
             event_loop: Some(event_loop),
             _loop_handle: handle,
             common,
+            display: Rc::new(HeadlessDisplay::new()),
         })))
     }
 }
@@ -61,15 +64,16 @@ impl LinuxClient for HeadlessClient {
     }
 
     fn displays(&self) -> Vec> {
-        vec![]
+        vec![self.0.borrow().display.clone()]
     }
 
     fn primary_display(&self) -> Option> {
-        None
+        Some(self.0.borrow().display.clone())
     }
 
-    fn display(&self, _id: DisplayId) -> Option> {
-        None
+    fn display(&self, id: DisplayId) -> Option> {
+        let display = self.0.borrow().display.clone();
+        (display.id() == id).then_some(display)
     }
 
     #[cfg(feature = "screen-capture")]
@@ -96,9 +100,12 @@ impl LinuxClient for HeadlessClient {
     fn open_window(
         &self,
         _handle: AnyWindowHandle,
-        _params: WindowParams,
+        params: WindowParams,
     ) -> anyhow::Result> {
-        anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode");
+        Ok(Box::new(HeadlessWindow::new(
+            params,
+            self.0.borrow().display.clone(),
+        )))
     }
 
     fn compositor_name(&self) -> &'static str {
diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs
new file mode 100644
index 00000000000..e41b09723de
--- /dev/null
+++ b/crates/gpui_linux/src/linux/headless/window.rs
@@ -0,0 +1,287 @@
+//! Windows for the headless platform client.
+//!
+//! A headless window has no compositor surface and no GPU: layout, text
+//! shaping, and entity plumbing run normally, `draw` discards the scene, and
+//! the sprite atlas hands out tiles without uploading pixels (mirroring
+//! GPUI's `TestWindow`/`TestAtlas`). This lets command-line tools drive real
+//! `Window`-based code paths without a display server.
+
+use std::cell::RefCell;
+use std::rc::Rc;
+use std::sync::Arc;
+
+use collections::HashMap;
+use parking_lot::Mutex;
+use uuid::Uuid;
+
+use gpui::{
+    AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult,
+    DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
+    PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
+    Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
+    WindowControlArea, WindowParams, px,
+};
+
+#[derive(Debug)]
+pub(crate) struct HeadlessDisplay {
+    bounds: Bounds,
+}
+
+impl HeadlessDisplay {
+    pub(crate) fn new() -> Self {
+        Self {
+            bounds: Bounds::from_corners(Point::default(), Point::new(px(1920.), px(1080.))),
+        }
+    }
+}
+
+impl PlatformDisplay for HeadlessDisplay {
+    fn id(&self) -> DisplayId {
+        DisplayId::new(0)
+    }
+
+    fn uuid(&self) -> anyhow::Result {
+        // Stable identity: there is exactly one headless display.
+        Ok(Uuid::nil())
+    }
+
+    fn bounds(&self) -> Bounds {
+        self.bounds
+    }
+}
+
+struct HeadlessWindowState {
+    bounds: Bounds,
+    display: Rc,
+    input_handler: Option,
+    title: Option,
+    is_fullscreen: bool,
+}
+
+pub(crate) struct HeadlessWindow(Rc>);
+
+impl raw_window_handle::HasWindowHandle for HeadlessWindow {
+    fn window_handle(
+        &self,
+    ) -> Result, raw_window_handle::HandleError> {
+        // Headless windows are not backed by a native window.
+        Err(raw_window_handle::HandleError::NotSupported)
+    }
+}
+
+impl raw_window_handle::HasDisplayHandle for HeadlessWindow {
+    fn display_handle(
+        &self,
+    ) -> Result, raw_window_handle::HandleError> {
+        Err(raw_window_handle::HandleError::NotSupported)
+    }
+}
+
+impl HeadlessWindow {
+    pub(crate) fn new(params: WindowParams, display: Rc) -> Self {
+        Self(Rc::new(RefCell::new(HeadlessWindowState {
+            bounds: params.bounds,
+            display,
+            input_handler: None,
+            title: None,
+            is_fullscreen: false,
+        })))
+    }
+}
+
+impl PlatformWindow for HeadlessWindow {
+    fn bounds(&self) -> Bounds {
+        self.0.borrow().bounds
+    }
+
+    fn is_maximized(&self) -> bool {
+        false
+    }
+
+    fn window_bounds(&self) -> WindowBounds {
+        WindowBounds::Windowed(self.bounds())
+    }
+
+    fn content_size(&self) -> Size {
+        self.bounds().size
+    }
+
+    fn resize(&mut self, size: Size) {
+        self.0.borrow_mut().bounds.size = size;
+    }
+
+    fn scale_factor(&self) -> f32 {
+        1.0
+    }
+
+    fn appearance(&self) -> WindowAppearance {
+        WindowAppearance::Dark
+    }
+
+    fn display(&self) -> Option> {
+        Some(self.0.borrow().display.clone())
+    }
+
+    fn mouse_position(&self) -> Point {
+        Point::default()
+    }
+
+    fn modifiers(&self) -> Modifiers {
+        Modifiers::default()
+    }
+
+    fn capslock(&self) -> Capslock {
+        Capslock::default()
+    }
+
+    fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
+        self.0.borrow_mut().input_handler = Some(input_handler);
+    }
+
+    fn take_input_handler(&mut self) -> Option {
+        self.0.borrow_mut().input_handler.take()
+    }
+
+    fn prompt(
+        &self,
+        _level: PromptLevel,
+        _msg: &str,
+        _detail: Option<&str>,
+        _answers: &[PromptButton],
+    ) -> Option> {
+        // Fall back to GPUI's rendered prompts.
+        None
+    }
+
+    fn activate(&self) {}
+
+    fn is_active(&self) -> bool {
+        false
+    }
+
+    fn is_hovered(&self) -> bool {
+        false
+    }
+
+    fn background_appearance(&self) -> WindowBackgroundAppearance {
+        WindowBackgroundAppearance::Opaque
+    }
+
+    fn set_title(&mut self, title: &str) {
+        self.0.borrow_mut().title = Some(title.to_owned());
+    }
+
+    fn get_title(&self) -> String {
+        self.0.borrow().title.clone().unwrap_or_default()
+    }
+
+    fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {}
+
+    fn minimize(&self) {}
+
+    fn zoom(&self) {}
+
+    fn toggle_fullscreen(&self) {
+        let mut state = self.0.borrow_mut();
+        state.is_fullscreen = !state.is_fullscreen;
+    }
+
+    fn is_fullscreen(&self) -> bool {
+        self.0.borrow().is_fullscreen
+    }
+
+    // No compositor drives a frame loop, so frame and status callbacks are
+    // dropped: anything that awaits a frame will never resolve headlessly.
+    fn on_request_frame(&self, _callback: Box) {}
+
+    fn on_input(&self, _callback: Box DispatchEventResult>) {}
+
+    fn on_active_status_change(&self, _callback: Box) {}
+
+    fn on_hover_status_change(&self, _callback: Box) {}
+
+    fn on_resize(&self, _callback: Box, f32)>) {}
+
+    fn on_moved(&self, _callback: Box) {}
+
+    fn on_should_close(&self, _callback: Box bool>) {}
+
+    fn on_close(&self, _callback: Box) {}
+
+    fn on_hit_test_window_control(&self, _callback: Box Option>) {
+    }
+
+    fn on_appearance_changed(&self, _callback: Box) {}
+
+    fn draw(&self, _scene: &Scene) {}
+
+    fn sprite_atlas(&self) -> Arc {
+        Arc::new(HeadlessAtlas::default())
+    }
+
+    fn is_subpixel_rendering_supported(&self) -> bool {
+        false
+    }
+
+    fn update_ime_position(&self, _bounds: Bounds) {}
+
+    fn gpu_specs(&self) -> Option {
+        None
+    }
+}
+
+/// Allocates atlas tiles without uploading pixels, so glyph and sprite
+/// painting completes headlessly.
+#[derive(Default)]
+struct HeadlessAtlas(Mutex);
+
+#[derive(Default)]
+struct HeadlessAtlasState {
+    next_id: u32,
+    tiles: HashMap,
+}
+
+impl PlatformAtlas for HeadlessAtlas {
+    fn get_or_insert_with<'a>(
+        &self,
+        key: &AtlasKey,
+        build: &mut dyn FnMut() -> anyhow::Result<
+            Option<(Size, std::borrow::Cow<'a, [u8]>)>,
+        >,
+    ) -> anyhow::Result> {
+        {
+            let state = self.0.lock();
+            if let Some(&tile) = state.tiles.get(key) {
+                return Ok(Some(tile));
+            }
+        }
+
+        let Some((size, _)) = build()? else {
+            return Ok(None);
+        };
+
+        let mut state = self.0.lock();
+        state.next_id += 1;
+        let texture_id = state.next_id;
+        state.next_id += 1;
+        let tile_id = state.next_id;
+        let tile = AtlasTile {
+            texture_id: AtlasTextureId {
+                index: texture_id,
+                kind: key.texture_kind(),
+            },
+            tile_id: TileId(tile_id),
+            padding: 0,
+            bounds: Bounds {
+                origin: Point::default(),
+                size,
+            },
+        };
+        state.tiles.insert(key.clone(), tile);
+        Ok(Some(tile))
+    }
+
+    fn remove(&self, key: &AtlasKey) {
+        self.0.lock().tiles.remove(key);
+    }
+}
diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs
index 3e90688d1bd..cbc962bcffe 100644
--- a/crates/gpui_linux/src/linux/wayland.rs
+++ b/crates/gpui_linux/src/linux/wayland.rs
@@ -2,6 +2,7 @@ mod client;
 mod clipboard;
 mod cursor;
 mod display;
+mod popup;
 mod serial;
 mod window;
 
diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs
index ac637d3fc46..a4cc9fde9f1 100644
--- a/crates/gpui_linux/src/linux/wayland/client.rs
+++ b/crates/gpui_linux/src/linux/wayland/client.rs
@@ -57,7 +57,9 @@ use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xd
 use wayland_protocols::xdg::decoration::zv1::client::{
     zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
 };
-use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+use wayland_protocols::xdg::shell::client::{
+    xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base,
+};
 use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1;
 use wayland_protocols::{
     wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
@@ -96,7 +98,7 @@ use gpui::{
     ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent,
     MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection,
     Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point,
-    ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout,
+    ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, WindowKind,
     WindowParams, point, profiler, px, size,
 };
 use gpui_wgpu::{CompositorGpuHint, GpuContext};
@@ -388,7 +390,7 @@ impl WaylandClientStatePtr {
     pub fn update_ime_position(&self, bounds: Bounds) {
         let client = self.get_client();
         let state = client.borrow_mut();
-        if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() {
+        if state.text_input.is_none() || state.pre_edit_text.is_some() {
             return;
         }
 
@@ -846,7 +848,29 @@ impl LinuxClient for WaylandClient {
     ) -> anyhow::Result> {
         let mut state = self.0.borrow_mut();
 
-        let parent = state.keyboard_focused_window.clone();
+        // Popups name their parent explicitly. Other kinds are parented to the focused window.
+        let (parent, popup_grab) = match ¶ms.kind {
+            WindowKind::AnchoredPopup(options) => {
+                let parent = state
+                    .windows
+                    .values()
+                    .find(|window| window.handle() == options.parent)
+                    .cloned()
+                    .ok_or_else(|| anyhow::anyhow!("popup parent window not found"))?;
+                // A popup grab must reference a press event or the compositor declines it and
+                // immediately dismisses the popup, so use the most recent press serial, or no
+                // grab before any press.
+                let popup_grab = options.grab.then(|| {
+                    let serial = state
+                        .serial_tracker
+                        .get(SerialKind::MousePress)
+                        .max(state.serial_tracker.get(SerialKind::KeyPress));
+                    (serial != 0).then(|| (serial, state.wl_seat.clone()))
+                });
+                (Some(parent), popup_grab.flatten())
+            }
+            _ => (state.keyboard_focused_window.clone(), None),
+        };
 
         let target_output = params.display_id.and_then(|display_id| {
             let target_protocol_id: u64 = display_id.into();
@@ -859,6 +883,7 @@ impl LinuxClient for WaylandClient {
 
         let appearance = state.common.appearance;
         let compositor_gpu = state.compositor_gpu.take();
+
         let (window, surface_id) = WaylandWindow::new(
             handle,
             state.globals.clone(),
@@ -868,8 +893,10 @@ impl LinuxClient for WaylandClient {
             params,
             appearance,
             parent,
+            popup_grab,
             target_output,
         )?;
+
         if window.0.toplevel().is_some() {
             state.consume_startup_activation_token(&window.0.surface());
         }
@@ -1196,6 +1223,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion);
 delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1);
 delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
 delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1);
+delegate_noop!(WaylandClientStatePtr: ignore xdg_positioner::XdgPositioner);
 delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager);
 delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3);
 delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur);
@@ -1359,6 +1387,31 @@ impl Dispatch for WaylandCl
         drop(state);
         let should_close = window.handle_layersurface_event(event);
 
+        if should_close {
+            // Close logic will be handled in drop_window()
+            window.close();
+        }
+    }
+}
+
+impl Dispatch for WaylandClientStatePtr {
+    fn event(
+        this: &mut Self,
+        _: &xdg_popup::XdgPopup,
+        event: ::Event,
+        surface_id: &ObjectId,
+        _: &Connection,
+        _: &QueueHandle,
+    ) {
+        let client = this.get_client();
+        let mut state = client.borrow_mut();
+        let Some(window) = get_window(&mut state, surface_id) else {
+            return;
+        };
+
+        drop(state);
+        let should_close = window.handle_popup_event(event);
+
         if should_close {
             // The close logic will be handled in drop_window()
             window.close();
@@ -1831,8 +1884,9 @@ impl Dispatch for WaylandClientStatePtr {
                 surface_y,
                 ..
             } => {
+                let position = point(px(surface_x as f32), px(surface_y as f32));
                 state.serial_tracker.update(SerialKind::MouseEnter, serial);
-                state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
+                state.mouse_location = Some(position);
                 state.button_pressed = None;
 
                 if let Some(window) = get_window(&mut state, &surface.id()) {
@@ -1855,8 +1909,16 @@ impl Dispatch for WaylandClientStatePtr {
                             );
                         }
                     }
+                    let modifiers = state.modifiers;
                     drop(state);
                     window.set_hovered(true);
+                    // No Motion follows Enter unless the pointer keeps moving, so synthesize
+                    // a MouseMove to establish hover at the entry position.
+                    window.handle_input(PlatformInput::MouseMove(MouseMoveEvent {
+                        position,
+                        pressed_button: None,
+                        modifiers,
+                    }));
                 }
             }
             wl_pointer::Event::Leave { .. } => {
@@ -1934,7 +1996,11 @@ impl Dispatch for WaylandClientStatePtr {
                 state: WEnum::Value(button_state),
                 ..
             } => {
-                state.serial_tracker.update(SerialKind::MousePress, serial);
+                // Record presses only. Requests referencing this serial (popup grabs,
+                // interactive moves) are declined when given a release serial.
+                if button_state == wl_pointer::ButtonState::Pressed {
+                    state.serial_tracker.update(SerialKind::MousePress, serial);
+                }
                 let button = linux_button_to_gpui(button);
                 let Some(button) = button else { return };
                 if state.mouse_focused_window.is_none() {
diff --git a/crates/gpui_linux/src/linux/wayland/popup.rs b/crates/gpui_linux/src/linux/wayland/popup.rs
new file mode 100644
index 00000000000..4a15e78211b
--- /dev/null
+++ b/crates/gpui_linux/src/linux/wayland/popup.rs
@@ -0,0 +1,38 @@
+pub use gpui::popup::*;
+
+use wayland_protocols::xdg::shell::client::xdg_positioner;
+
+pub(crate) fn wayland_anchor(anchor: PopupAnchor) -> xdg_positioner::Anchor {
+    match anchor {
+        PopupAnchor::Center => xdg_positioner::Anchor::None,
+        PopupAnchor::Top => xdg_positioner::Anchor::Top,
+        PopupAnchor::Bottom => xdg_positioner::Anchor::Bottom,
+        PopupAnchor::Left => xdg_positioner::Anchor::Left,
+        PopupAnchor::Right => xdg_positioner::Anchor::Right,
+        PopupAnchor::TopLeft => xdg_positioner::Anchor::TopLeft,
+        PopupAnchor::BottomLeft => xdg_positioner::Anchor::BottomLeft,
+        PopupAnchor::TopRight => xdg_positioner::Anchor::TopRight,
+        PopupAnchor::BottomRight => xdg_positioner::Anchor::BottomRight,
+    }
+}
+
+pub(crate) fn wayland_gravity(gravity: PopupGravity) -> xdg_positioner::Gravity {
+    match gravity {
+        PopupGravity::Center => xdg_positioner::Gravity::None,
+        PopupGravity::Top => xdg_positioner::Gravity::Top,
+        PopupGravity::Bottom => xdg_positioner::Gravity::Bottom,
+        PopupGravity::Left => xdg_positioner::Gravity::Left,
+        PopupGravity::Right => xdg_positioner::Gravity::Right,
+        PopupGravity::TopLeft => xdg_positioner::Gravity::TopLeft,
+        PopupGravity::BottomLeft => xdg_positioner::Gravity::BottomLeft,
+        PopupGravity::TopRight => xdg_positioner::Gravity::TopRight,
+        PopupGravity::BottomRight => xdg_positioner::Gravity::BottomRight,
+    }
+}
+
+pub(crate) fn wayland_constraint_adjustment(
+    adjustment: PopupConstraintAdjustment,
+) -> xdg_positioner::ConstraintAdjustment {
+    // The flag values match the protocol bitfield, so the bits map across directly.
+    xdg_positioner::ConstraintAdjustment::from_bits_truncate(adjustment.bits())
+}
diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs
index da759cfebc7..675602082bb 100644
--- a/crates/gpui_linux/src/linux/wayland/window.rs
+++ b/crates/gpui_linux/src/linux/wayland/window.rs
@@ -1,12 +1,12 @@
 use std::{
-    cell::{Ref, RefCell, RefMut},
+    cell::{Cell, Ref, RefCell, RefMut},
     ffi::c_void,
     ptr::NonNull,
     rc::Rc,
     sync::Arc,
 };
 
-use collections::{FxHashSet, HashMap};
+use collections::{FxHashMap, HashMap};
 use futures::channel::oneshot::Receiver;
 
 use raw_window_handle as rwh;
@@ -14,10 +14,12 @@ use wayland_backend::client::ObjectId;
 use wayland_client::WEnum;
 use wayland_client::{
     Proxy,
-    protocol::{wl_output, wl_surface},
+    protocol::{wl_output, wl_seat, wl_surface},
 };
 use wayland_protocols::wp::viewporter::client::wp_viewport;
 use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1;
+use wayland_protocols::xdg::shell::client::xdg_popup;
+use wayland_protocols::xdg::shell::client::xdg_positioner;
 use wayland_protocols::xdg::shell::client::xdg_surface;
 use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
 use wayland_protocols::{
@@ -34,8 +36,8 @@ use gpui::{
     PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
     PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling,
     WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls,
-    WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px,
-    size,
+    WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError,
+    popup::PopupOptions, px, size,
 };
 use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu};
 
@@ -93,7 +95,9 @@ pub struct WaylandWindowState {
     surface_state: WaylandSurfaceState,
     acknowledged_first_configure: bool,
     parent: Option,
-    children: FxHashSet,
+    /// Child surfaces mapped to whether they block this window's input (dialogs
+    /// block, popups don't). Children are closed before this window closes.
+    children: FxHashMap,
     pub surface: wl_surface::WlSurface,
     app_id: Option,
     appearance: WindowAppearance,
@@ -129,6 +133,7 @@ pub struct WaylandWindowState {
 pub enum WaylandSurfaceState {
     Xdg(WaylandXdgSurfaceState),
     LayerShell(WaylandLayerSurfaceState),
+    Popup(WaylandPopupSurfaceState),
 }
 
 impl WaylandSurfaceState {
@@ -137,6 +142,7 @@ impl WaylandSurfaceState {
         globals: &Globals,
         params: &WindowParams,
         parent: Option,
+        popup_grab: Option<(u32, wl_seat::WlSeat)>,
         target_output: Option,
     ) -> anyhow::Result {
         // For layer_shell windows, create a layer surface instead of an xdg surface
@@ -186,6 +192,54 @@ impl WaylandSurfaceState {
             }));
         }
 
+        if let WindowKind::AnchoredPopup(options) = ¶ms.kind {
+            let Some(parent) = parent.as_ref() else {
+                return Err(anyhow::anyhow!("popup parent window not found"));
+            };
+
+            let positioner = build_popup_positioner(
+                globals,
+                options,
+                params.bounds.size,
+                parent.window_geometry(),
+            );
+
+            let xdg_surface = globals
+                .wm_base
+                .get_xdg_surface(&surface, &globals.qh, surface.id());
+
+            // A layer-shell parent takes a null xdg parent and is attached via the layer
+            // surface. Every other surface kind has an xdg_surface to parent to directly.
+            let xdg_popup = if let Some(parent_layer_surface) = parent.layer_surface() {
+                let xdg_popup = xdg_surface.get_popup(None, &positioner, &globals.qh, surface.id());
+                parent_layer_surface.get_popup(&xdg_popup);
+                xdg_popup
+            } else {
+                xdg_surface.get_popup(
+                    parent.xdg_surface().as_ref(),
+                    &positioner,
+                    &globals.qh,
+                    surface.id(),
+                )
+            };
+            positioner.destroy();
+
+            if let Some((serial, seat)) = popup_grab {
+                xdg_popup.grab(&seat, serial);
+            }
+
+            // Non-blocking: the parent keeps its input so it can dismiss the popup on
+            // clicks in its own window.
+            parent.add_child(surface.id(), false);
+
+            return Ok(WaylandSurfaceState::Popup(WaylandPopupSurfaceState {
+                xdg_surface,
+                xdg_popup,
+                options: options.clone(),
+                next_reposition_token: Cell::new(0),
+            }));
+        }
+
         // All other WindowKinds result in a regular xdg surface
         let xdg_surface = globals
             .wm_base
@@ -206,7 +260,7 @@ impl WaylandSurfaceState {
             });
 
             if let Some(parent) = parent.as_ref() {
-                parent.add_child(surface.id());
+                parent.add_child(surface.id(), true);
             }
 
             dialog
@@ -246,6 +300,65 @@ pub struct WaylandLayerSurfaceState {
     layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
 }
 
+pub struct WaylandPopupSurfaceState {
+    xdg_surface: xdg_surface::XdgSurface,
+    xdg_popup: xdg_popup::XdgPopup,
+    // Kept so the popup can be re-anchored via `xdg_popup.reposition` when resized.
+    options: PopupOptions,
+    next_reposition_token: Cell,
+}
+
+fn build_popup_positioner(
+    globals: &Globals,
+    options: &PopupOptions,
+    size: Size,
+    parent_geometry: Bounds,
+) -> xdg_positioner::XdgPositioner {
+    let positioner = globals.wm_base.create_positioner(&globals.qh, ());
+    // A zero or negative size is a protocol error.
+    positioner.set_size(
+        f32::from(size.width).max(1.0) as i32,
+        f32::from(size.height).max(1.0) as i32,
+    );
+
+    // The protocol wants the anchor rect relative to the parent's window geometry, while
+    // `options.anchor_rect` is in gpui window coordinates (surface-local). A rect extending
+    // outside the geometry or with a zero size is a protocol error, so translate, then clamp
+    // to at least one pixel inside the geometry, pulling the origin inward at the edges.
+    let anchor_rect = Bounds {
+        origin: options.anchor_rect.origin - parent_geometry.origin,
+        size: options.anchor_rect.size,
+    };
+    let one = Point::new(px(1.0), px(1.0));
+    let geometry_bottom_right: Point = parent_geometry.size.into();
+    let top_left = anchor_rect
+        .origin
+        .min(&(geometry_bottom_right - one))
+        .max(&Point::default());
+    let bottom_right = anchor_rect
+        .bottom_right()
+        .min(&geometry_bottom_right)
+        .max(&(top_left + one));
+    let anchor_rect = Bounds::from_corners(top_left, bottom_right);
+    positioner.set_anchor_rect(
+        f32::from(anchor_rect.origin.x) as i32,
+        f32::from(anchor_rect.origin.y) as i32,
+        f32::from(anchor_rect.size.width) as i32,
+        f32::from(anchor_rect.size.height) as i32,
+    );
+
+    positioner.set_anchor(super::popup::wayland_anchor(options.anchor));
+    positioner.set_gravity(super::popup::wayland_gravity(options.gravity));
+    positioner.set_constraint_adjustment(super::popup::wayland_constraint_adjustment(
+        options.constraint_adjustment,
+    ));
+    positioner.set_offset(
+        f32::from(options.offset.x) as i32,
+        f32::from(options.offset.y) as i32,
+    );
+    positioner
+}
+
 impl WaylandSurfaceState {
     fn ack_configure(&self, serial: u32) {
         match self {
@@ -255,6 +368,9 @@ impl WaylandSurfaceState {
             WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => {
                 layer_surface.ack_configure(serial);
             }
+            WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => {
+                xdg_surface.ack_configure(serial);
+            }
         }
     }
 
@@ -274,6 +390,28 @@ impl WaylandSurfaceState {
         }
     }
 
+    fn xdg_surface(&self) -> Option<&xdg_surface::XdgSurface> {
+        match self {
+            WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => {
+                Some(xdg_surface)
+            }
+            WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => {
+                Some(xdg_surface)
+            }
+            WaylandSurfaceState::LayerShell(_) => None,
+        }
+    }
+
+    fn layer_surface(&self) -> Option<&zwlr_layer_surface_v1::ZwlrLayerSurfaceV1> {
+        if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) =
+            self
+        {
+            Some(layer_surface)
+        } else {
+            None
+        }
+    }
+
     fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) {
         match self {
             WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => {
@@ -283,6 +421,34 @@ impl WaylandSurfaceState {
                 // cannot set window position of a layer surface
                 layer_surface.set_size(width as u32, height as u32);
             }
+            WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => {
+                xdg_surface.set_window_geometry(x, y, width, height);
+            }
+        }
+    }
+
+    // Re-anchors a mapped popup at a new size via `xdg_popup.reposition`. Repositioning an
+    // unmapped popup (before the first configure) is a protocol error.
+    fn reposition_popup(
+        &self,
+        globals: &Globals,
+        size: Size,
+        parent_geometry: Bounds,
+    ) {
+        if let WaylandSurfaceState::Popup(WaylandPopupSurfaceState {
+            xdg_popup,
+            options,
+            next_reposition_token,
+            ..
+        }) = self
+            && xdg_popup.version() >= xdg_popup::REQ_REPOSITION_SINCE
+        {
+            let token = next_reposition_token.get();
+            next_reposition_token.set(token.wrapping_add(1));
+
+            let positioner = build_popup_positioner(globals, options, size, parent_geometry);
+            xdg_popup.reposition(&positioner, token);
+            positioner.destroy();
         }
     }
 
@@ -307,6 +473,15 @@ impl WaylandSurfaceState {
             WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => {
                 layer_surface.destroy();
             }
+            WaylandSurfaceState::Popup(WaylandPopupSurfaceState {
+                xdg_surface,
+                xdg_popup,
+                ..
+            }) => {
+                // Role object before its xdg_surface, as with the toplevel above.
+                xdg_popup.destroy();
+                xdg_surface.destroy();
+            }
         }
     }
 }
@@ -374,7 +549,7 @@ impl WaylandWindowState {
             surface_state,
             acknowledged_first_configure: false,
             parent,
-            children: FxHashSet::default(),
+            children: FxHashMap::default(),
             surface,
             app_id: options.app_id,
             blur: None,
@@ -523,11 +698,18 @@ impl WaylandWindow {
         params: WindowParams,
         appearance: WindowAppearance,
         parent: Option,
+        popup_grab: Option<(u32, wl_seat::WlSeat)>,
         target_output: Option,
     ) -> anyhow::Result<(Self, ObjectId)> {
         let surface = globals.compositor.create_surface(&globals.qh, ());
-        let surface_state =
-            WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?;
+        let surface_state = WaylandSurfaceState::new(
+            &surface,
+            &globals,
+            ¶ms,
+            parent.clone(),
+            popup_grab,
+            target_output,
+        )?;
 
         if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
             fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
@@ -575,18 +757,39 @@ impl WaylandWindowStatePtr {
         self.state.borrow().surface_state.toplevel().cloned()
     }
 
+    /// The `xdg_surface` backing this window, if it has one. Used to anchor child popups.
+    pub fn xdg_surface(&self) -> Option {
+        self.state.borrow().surface_state.xdg_surface().cloned()
+    }
+
+    /// The layer-shell surface backing this window, if it is one. Used to anchor child popups.
+    pub fn layer_surface(&self) -> Option {
+        self.state.borrow().surface_state.layer_surface().cloned()
+    }
+
+    /// This window's xdg window geometry in surface-local coordinates. Child popup anchor
+    /// rectangles are relative to it, while gpui coordinates are surface-local.
+    pub fn window_geometry(&self) -> Bounds {
+        let state = self.state.borrow();
+        inset_by_tiling(
+            state.bounds.map_origin(|_| px(0.0)),
+            state.inset(),
+            state.tiling,
+        )
+    }
+
     pub fn ptr_eq(&self, other: &Self) -> bool {
         Rc::ptr_eq(&self.state, &other.state)
     }
 
-    pub fn add_child(&self, child: ObjectId) {
+    pub fn add_child(&self, child: ObjectId, blocking: bool) {
         let mut state = self.state.borrow_mut();
-        state.children.insert(child);
+        state.children.insert(child, blocking);
     }
 
     pub fn is_blocked(&self) -> bool {
         let state = self.state.borrow();
-        !state.children.is_empty()
+        state.children.values().any(|&blocking| blocking)
     }
 
     pub fn frame(&self) {
@@ -889,6 +1092,35 @@ impl WaylandWindowStatePtr {
         }
     }
 
+    // Returns `true` if the popup should be closed.
+    pub fn handle_popup_event(&self, event: xdg_popup::Event) -> bool {
+        match event {
+            // Only the size is needed, the position is the compositor's. The following
+            // xdg_surface.configure applies the change.
+            xdg_popup::Event::Configure { width, height, .. } => {
+                let size = if width <= 0 || height <= 0 {
+                    None
+                } else {
+                    Some(size(px(width as f32), px(height as f32)))
+                };
+
+                self.state.borrow_mut().in_progress_configure = Some(InProgressConfigure {
+                    size,
+                    fullscreen: false,
+                    maximized: false,
+                    resizing: false,
+                    tiling: Tiling::default(),
+                });
+
+                false
+            }
+            xdg_popup::Event::PopupDone => true,
+            // Precedes the reposition's Configure, which does the work. The token is not needed.
+            xdg_popup::Event::Repositioned { .. } => false,
+            _ => false,
+        }
+    }
+
     #[allow(clippy::mutable_key_type)]
     pub fn handle_surface_event(
         &self,
@@ -1025,8 +1257,7 @@ impl WaylandWindowStatePtr {
     pub fn close(&self) {
         let state = self.state.borrow();
         let client = state.client.get_client();
-        #[allow(clippy::mutable_key_type)]
-        let children = state.children.clone();
+        let children = state.children.keys().cloned().collect::>();
         drop(state);
 
         for child in children {
@@ -1192,6 +1423,23 @@ impl PlatformWindow for WaylandWindow {
         let state = self.borrow();
         let state_ptr = self.0.clone();
 
+        // A popup's placement is the compositor's, so a resize re-runs the positioner and the
+        // configure reply drives the buffer resize. Before the first configure the popup is
+        // unmapped and cannot reposition, but the initial positioner already carries the size.
+        if matches!(state.surface_state, WaylandSurfaceState::Popup(_)) {
+            if state.acknowledged_first_configure {
+                let parent_geometry = state
+                    .parent
+                    .as_ref()
+                    .map(|parent| parent.window_geometry())
+                    .unwrap_or_default();
+                state
+                    .surface_state
+                    .reposition_popup(&state.globals, size, parent_geometry);
+            }
+            return;
+        }
+
         // Keep window geometry consistent with configure handling. On Wayland, window geometry is
         // surface-local: resizing should not attempt to translate the window; the compositor
         // controls placement. We also account for client-side decoration insets and tiling.
@@ -1489,6 +1737,38 @@ impl PlatformWindow for WaylandWindow {
         }
     }
 
+    fn set_input_region(&self, region: Option<&[Bounds]>) {
+        let state = self.borrow();
+        match region {
+            // No region means the whole surface receives input.
+            None => state.surface.set_input_region(None),
+            // A region restricts input to its rectangles. An empty region
+            // receives no input at all.
+            Some(rects) => {
+                let wl_region = state
+                    .globals
+                    .compositor
+                    .create_region(&state.globals.qh, ());
+                for rect in rects {
+                    let rect = rect.map(|pixels| f32::from(pixels) as i32);
+                    wl_region.add(
+                        rect.origin.x,
+                        rect.origin.y,
+                        rect.size.width,
+                        rect.size.height,
+                    );
+                }
+                state.surface.set_input_region(Some(&wl_region));
+                wl_region.destroy();
+            }
+        }
+
+        // Commit so the new input region applies immediately. Otherwise it
+        // waits for the next frame, which could be the very click we want to
+        // allow passing through.
+        state.surface.commit();
+    }
+
     fn window_decorations(&self) -> Decorations {
         let state = self.borrow();
         match state.decorations {
diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs
index 147630d054d..a459ed03b5d 100644
--- a/crates/gpui_linux/src/linux/x11/window.rs
+++ b/crates/gpui_linux/src/linux/x11/window.rs
@@ -7,7 +7,7 @@ use gpui::{
     Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
     Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size,
     Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
-    WindowDecorations, WindowKind, WindowParams, px,
+    WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px,
 };
 use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig};
 
@@ -428,6 +428,12 @@ impl X11WindowState {
         supports_xinput_gestures: bool,
         is_bgr: bool,
     ) -> anyhow::Result {
+        // Native popups are not implemented on X11 yet. Rejecting lets callers fall back to
+        // gpui's in-window popovers.
+        if let WindowKind::AnchoredPopup(_) = params.kind {
+            return Err(PopupNotSupportedError.into());
+        }
+
         let x_screen_index = params
             .display_id
             .map_or(x_main_screen_index, |did| u64::from(did) as usize);
@@ -1410,7 +1416,11 @@ impl PlatformWindow for X11Window {
         )
         .log_err()
         .map_or(Point::new(Pixels::ZERO, Pixels::ZERO), |reply| {
-            Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into())
+            let scale_factor = self.0.state.borrow().scale_factor;
+            Point::new(
+                px(reply.win_x as f32 / scale_factor),
+                px(reply.win_y as f32 / scale_factor),
+            )
         })
     }
 
diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs
index 8266cade4d2..eed9e90bcd3 100644
--- a/crates/gpui_macos/src/platform.rs
+++ b/crates/gpui_macos/src/platform.rs
@@ -31,7 +31,8 @@ use gpui::{
     Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
     KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
     PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
-    PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams,
+    PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind,
+    WindowParams, popup::PopupNotSupportedError,
 };
 use gpui_util::{ResultExt, new_std_command};
 use itertools::Itertools;
@@ -640,6 +641,12 @@ impl Platform for MacPlatform {
         handle: AnyWindowHandle,
         options: WindowParams,
     ) -> Result> {
+        // Native popups are not implemented on macOS yet. Rejecting lets callers fall back to
+        // gpui's in-window popovers.
+        if let WindowKind::AnchoredPopup(_) = options.kind {
+            return Err(PopupNotSupportedError.into());
+        }
+
         let (cursor_visible, foreground_executor, background_executor, renderer_context) = {
             let guard = self.0.lock();
             (
diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs
index 2808e535ad5..fbc48864ce5 100644
--- a/crates/gpui_macos/src/text_system.rs
+++ b/crates/gpui_macos/src/text_system.rs
@@ -1,6 +1,6 @@
 use anyhow::anyhow;
 use cocoa::appkit::CGFloat;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use core_foundation::{
     array::{CFArray, CFArrayRef},
     attributed_string::CFMutableAttributedString,
@@ -282,6 +282,7 @@ impl MacTextSystemState {
         let name = gpui::font_name_with_fallbacks(name, ".AppleSystemUIFont");
 
         let mut font_ids = SmallVec::new();
+        let mut postscript_names_seen = HashSet::default();
         let family = self
             .memory_source
             .select_family_by_name(name)
@@ -340,15 +341,38 @@ impl MacTextSystemState {
                         .is_some())
             } {
                 log::error!(
-                    "Failed to read traits for font {:?}",
-                    font.postscript_name().unwrap()
+                    "Failed to read traits for font {:?} (PostScript name {:?})",
+                    font.full_name(),
+                    font.postscript_name(),
                 );
                 continue;
             }
 
+            let Some(postscript_name) = font.postscript_name() else {
+                log::warn!(
+                    "font {:?} in family {:?} has no PostScript name; skipping",
+                    font.full_name(),
+                    name,
+                );
+                continue;
+            };
+            // Dedup is scoped to this single `load_family` call (issue #55472).
+            // The same family can be reloaded later under a different `FontKey`
+            // (different features/fallbacks); a global check against
+            // `font_ids_by_postscript_name` would skip every already-registered
+            // font and leave the second call's `font_ids` empty.
+            if !postscript_names_seen.insert(postscript_name.clone()) {
+                log::warn!(
+                    "skipping duplicate font {:?} with PostScript name {:?} \
+                     in family {:?}",
+                    font.full_name(),
+                    postscript_name,
+                    name,
+                );
+                continue;
+            }
             let font_id = FontId(self.fonts.len());
             font_ids.push(font_id);
-            let postscript_name = font.postscript_name().unwrap();
             self.font_ids_by_postscript_name
                 .insert(postscript_name.clone(), font_id);
             self.postscript_names_by_font_id
diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs
index 938633d4749..6221b141610 100644
--- a/crates/gpui_macos/src/window.rs
+++ b/crates/gpui_macos/src/window.rs
@@ -655,7 +655,10 @@ impl MacWindowState {
                 return;
             }
         }
-        let display_id = unsafe { display_id_for_screen(self.native_window.screen()) };
+        let Some(display_id) = display_id_for_screen(unsafe { self.native_window.screen() }) else {
+            // AppKit can temporarily report no screen while displays are being reconfigured.
+            return;
+        };
         if let Some(mut display_link) =
             DisplayLink::new(display_id, self.native_view.as_ptr() as *mut c_void, step).log_err()
         {
@@ -792,7 +795,9 @@ impl MacWindow {
                 WindowKind::Normal => {
                     msg_send![WINDOW_CLASS, alloc]
                 }
-                WindowKind::PopUp => {
+                // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only
+                // for exhaustiveness.
+                WindowKind::PopUp | WindowKind::AnchoredPopup(_) => {
                     style_mask |= NSWindowStyleMaskNonactivatingPanel;
                     msg_send![PANEL_CLASS, alloc]
                 }
@@ -812,8 +817,10 @@ impl MacWindow {
             let count: u64 = cocoa::foundation::NSArray::count(screens);
             for i in 0..count {
                 let screen = cocoa::foundation::NSArray::objectAtIndex(screens, i);
+                let Some(display_id) = display_id_for_screen(screen) else {
+                    continue;
+                };
                 let frame = NSScreen::frame(screen);
-                let display_id = display_id_for_screen(screen);
                 if display_id == display.0 {
                     screen_frame = Some(frame);
                     target_screen = screen;
@@ -983,7 +990,9 @@ impl MacWindow {
                         let _: () = msg_send![native_window, setTabbingIdentifier:nil];
                     }
                 }
-                WindowKind::PopUp => {
+                // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only
+                // for exhaustiveness.
+                WindowKind::PopUp | WindowKind::AnchoredPopup(_) => {
                     // Use a tracking area to allow receiving MouseMoved events even when
                     // the window or application aren't active, which is often the case
                     // e.g. for notification windows.
@@ -2995,13 +3004,17 @@ where
     }
 }
 
-unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID {
+fn display_id_for_screen(screen: id) -> Option {
+    if screen.is_null() {
+        return None;
+    }
+
     unsafe {
         let device_description = NSScreen::deviceDescription(screen);
         let screen_number_key: id = ns_string("NSScreenNumber");
         let screen_number = device_description.objectForKey_(screen_number_key);
         let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue];
-        screen_number as CGDirectDisplayID
+        Some(screen_number as CGDirectDisplayID)
     }
 }
 
@@ -3153,3 +3166,13 @@ extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn display_id_for_screen_returns_none_for_null_screen() {
+        assert_eq!(display_id_for_screen(nil), None);
+    }
+}
diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs
index 89d609ae65d..51d2a8ab3f7 100644
--- a/crates/gpui_macros/src/derive_into_element.rs
+++ b/crates/gpui_macros/src/derive_into_element.rs
@@ -11,11 +11,11 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream {
         impl #impl_generics gpui::IntoElement for #type_name #type_generics
         #where_clause
         {
-            type Element = gpui::Component;
+            type Element = gpui::ViewElement;
 
             #[track_caller]
             fn into_element(self) -> Self::Element {
-                gpui::Component::new(self)
+                gpui::ViewElement::new(self)
             }
         }
     };
diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs
index f3958bca568..50305af752c 100644
--- a/crates/gpui_macros/src/gpui_macros.rs
+++ b/crates/gpui_macros/src/gpui_macros.rs
@@ -29,8 +29,8 @@ pub fn register_action(ident: TokenStream) -> TokenStream {
     register_action::register_action(ident)
 }
 
-/// #[derive(IntoElement)] is used to create a Component out of anything that implements
-/// the `RenderOnce` trait.
+/// #[derive(IntoElement)] generates an `IntoElement` impl for any `RenderOnce`
+/// type, wrapping it in a `ViewElement` so it can be used as a child.
 #[proc_macro_derive(IntoElement)]
 pub fn derive_into_element(input: TokenStream) -> TokenStream {
     derive_into_element::derive_into_element(input)
diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs
index fecc39f368d..c39723ea5b2 100644
--- a/crates/gpui_web/src/platform.rs
+++ b/crates/gpui_web/src/platform.rs
@@ -8,7 +8,7 @@ use gpui::{
     Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper,
     ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay,
     PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task,
-    ThermalState, WindowAppearance, WindowParams,
+    ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError,
 };
 use gpui_wgpu::WgpuContext;
 use std::{
@@ -166,6 +166,12 @@ impl Platform for WebPlatform {
         handle: AnyWindowHandle,
         params: WindowParams,
     ) -> anyhow::Result> {
+        // Native popups are not implemented on the web yet. Rejecting lets callers fall back to
+        // gpui's in-window popovers.
+        if let WindowKind::AnchoredPopup(_) = params.kind {
+            return Err(PopupNotSupportedError.into());
+        }
+
         let context_ref = self.wgpu_context.borrow();
         let context = context_ref.as_ref().ok_or_else(|| {
             anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?")
diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl
index 933b88e84d7..747a34d81e0 100644
--- a/crates/gpui_wgpu/src/shaders.wgsl
+++ b/crates/gpui_wgpu/src/shaders.wgsl
@@ -1191,7 +1191,7 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 {
     }
 
     let underline = b_underlines[input.underline_id];
-    if ((underline.wavy & 0xFFu) == 0u)
+    if (underline.wavy == 0u)
     {
         return blend_color(input.color, input.color.a);
     }
@@ -1305,7 +1305,7 @@ fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4 {
     let distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii);
 
     var color = sample;
-    if ((sprite.grayscale & 0xFFu) != 0u) {
+    if (sprite.grayscale != 0u) {
         let grayscale = dot(color.rgb, GRAYSCALE_FACTORS);
         color = vec4(vec3(grayscale), sample.a);
     }
diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs
index 08f30dc0090..587315debfa 100644
--- a/crates/gpui_wgpu/src/wgpu_renderer.rs
+++ b/crates/gpui_wgpu/src/wgpu_renderer.rs
@@ -962,7 +962,9 @@ impl WgpuRenderer {
             self.surface_config.height = clamped_height.max(1);
             let surface_config = self.surface_config.clone();
 
-            let resources = self.resources_mut();
+            let Some(resources) = self.resources.as_mut() else {
+                return;
+            };
 
             // Wait for any in-flight GPU work to complete before destroying textures
             if let Err(e) = resources.device.poll(wgpu::PollType::Wait {
@@ -1035,7 +1037,9 @@ impl WgpuRenderer {
             let surface_config = self.surface_config.clone();
             let path_sample_count = self.rendering_params.path_sample_count;
             let dual_source_blending = self.dual_source_blending;
-            let resources = self.resources_mut();
+            let Some(resources) = self.resources.as_mut() else {
+                return;
+            };
             resources
                 .surface
                 .configure(&resources.device, &surface_config);
diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs
index d1dd5685b07..ba492515edb 100644
--- a/crates/gpui_windows/src/dispatcher.rs
+++ b/crates/gpui_windows/src/dispatcher.rs
@@ -1,4 +1,6 @@
 use std::{
+    ffi::c_void,
+    ptr::NonNull,
     sync::atomic::{AtomicBool, Ordering},
     thread::{ThreadId, current},
     time::Duration,
@@ -6,16 +8,17 @@ use std::{
 
 use anyhow::Context;
 use gpui_util::ResultExt;
-use windows::{
+use windows::Win32::{
+    Foundation::{FILETIME, LPARAM, WPARAM},
+    Media::{timeBeginPeriod, timeEndPeriod},
     System::Threading::{
-        ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority,
-    },
-    Win32::{
-        Foundation::{LPARAM, WPARAM},
-        Media::{timeBeginPeriod, timeEndPeriod},
-        System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL},
-        UI::WindowsAndMessaging::PostMessageW,
+        CloseThreadpoolTimer, CloseThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWork,
+        GetCurrentThread, PTP_CALLBACK_INSTANCE, PTP_TIMER, PTP_WORK, SetThreadPriority,
+        SetThreadpoolTimer, SubmitThreadpoolWork, THREAD_PRIORITY_TIME_CRITICAL,
+        TP_CALLBACK_ENVIRON_V3, TP_CALLBACK_PRIORITY, TP_CALLBACK_PRIORITY_HIGH,
+        TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL,
     },
+    UI::WindowsAndMessaging::PostMessageW,
 };
 
 use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD};
@@ -49,29 +52,44 @@ impl WindowsDispatcher {
         }
     }
 
-    fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) {
-        let handler = {
-            let mut task_wrapper = Some(runnable);
-            WorkItemHandler::new(move |_| {
-                let runnable = task_wrapper.take().unwrap();
-                Self::execute_runnable(runnable);
-                Ok(())
-            })
+    fn dispatch_on_threadpool(&self, priority: TP_CALLBACK_PRIORITY, runnable: RunnableVariant) {
+        let environ = TP_CALLBACK_ENVIRON_V3 {
+            Version: 3,
+            CallbackPriority: priority,
+            Size: size_of::() as u32,
+            ..Default::default()
         };
 
-        ThreadPool::RunWithPriorityAsync(&handler, priority).log_err();
+        // If the thread pool never runs our callback, the matching `from_raw` is never called, which leaks the runnable.
+        // Dropping the scheduled runnable would cancel its task and make the next poll of any awaiter panic. Since we expect
+        // the scenario to usually happen during shutdown, this leak is acceptable.
+        let context = runnable.into_raw().as_ptr() as *mut c_void;
+
+        unsafe {
+            if let Ok(work) =
+                CreateThreadpoolWork(Some(run_work_callback), Some(context), Some(&environ))
+            {
+                SubmitThreadpoolWork(work);
+            }
+        }
     }
 
     fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) {
-        let handler = {
-            let mut task_wrapper = Some(runnable);
-            TimerElapsedHandler::new(move |_| {
-                let runnable = task_wrapper.take().unwrap();
-                Self::execute_runnable(runnable);
-                Ok(())
-            })
-        };
-        ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err();
+        let context = runnable.into_raw().as_ptr() as *mut c_void;
+
+        unsafe {
+            if let Ok(timer) = CreateThreadpoolTimer(Some(run_timer_callback), Some(context), None)
+            {
+                // Negative FILETIME expresses a relative delay in 100ns ticks
+                let ticks = (duration.as_nanos() / 100).min(i64::MAX as u128) as i64;
+                let due = (-ticks) as u64;
+                let due_time = FILETIME {
+                    dwLowDateTime: due as u32,
+                    dwHighDateTime: (due >> 32) as u32,
+                };
+                SetThreadpoolTimer(timer, Some(&due_time), 0, None);
+            }
+        }
     }
 
     #[inline(always)]
@@ -94,9 +112,9 @@ impl PlatformDispatcher for WindowsDispatcher {
             Priority::RealtimeAudio => {
                 panic!("RealtimeAudio priority should use spawn_realtime, not dispatch")
             }
-            Priority::High => WorkItemPriority::High,
-            Priority::Medium => WorkItemPriority::Normal,
-            Priority::Low => WorkItemPriority::Low,
+            Priority::High => TP_CALLBACK_PRIORITY_HIGH,
+            Priority::Medium => TP_CALLBACK_PRIORITY_NORMAL,
+            Priority::Low => TP_CALLBACK_PRIORITY_LOW,
         };
         self.dispatch_on_threadpool(priority, runnable);
     }
@@ -157,3 +175,23 @@ impl PlatformDispatcher for WindowsDispatcher {
         }))
     }
 }
+
+unsafe extern "system" fn run_work_callback(
+    _instance: PTP_CALLBACK_INSTANCE,
+    context: *mut c_void,
+    work: PTP_WORK,
+) {
+    let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) };
+    WindowsDispatcher::execute_runnable(runnable);
+    unsafe { CloseThreadpoolWork(work) };
+}
+
+unsafe extern "system" fn run_timer_callback(
+    _instance: PTP_CALLBACK_INSTANCE,
+    context: *mut c_void,
+    timer: PTP_TIMER,
+) {
+    let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) };
+    WindowsDispatcher::execute_runnable(runnable);
+    unsafe { CloseThreadpoolTimer(timer) };
+}
diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs
index 619765a2fd6..b293763bd9e 100644
--- a/crates/gpui_windows/src/events.rs
+++ b/crates/gpui_windows/src/events.rs
@@ -903,8 +903,12 @@ impl WindowsWindowInner {
                 WindowControlArea::Drag if self.is_movable => Some(HTCAPTION as _),
                 WindowControlArea::Drag => None,
                 WindowControlArea::Close => Some(HTCLOSE as _),
-                WindowControlArea::Max => Some(HTMAXBUTTON as _),
-                WindowControlArea::Min => Some(HTMINBUTTON as _),
+                WindowControlArea::Max if self.is_resizable => Some(HTMAXBUTTON as _),
+                WindowControlArea::Max if self.is_movable => Some(HTCAPTION as _),
+                WindowControlArea::Max => Some(HTNOWHERE as _),
+                WindowControlArea::Min if self.is_minimizable => Some(HTMINBUTTON as _),
+                WindowControlArea::Min if self.is_movable => Some(HTCAPTION as _),
+                WindowControlArea::Min => Some(HTNOWHERE as _),
             })
         } else {
             None
@@ -926,7 +930,11 @@ impl WindowsWindowInner {
         };
 
         unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
-        if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y {
+        if self.is_resizable
+            && !self.state.is_maximized()
+            && 0 <= cursor_point.y
+            && cursor_point.y <= frame_y
+        {
             // x-axis actually goes from -frame_x to 0
             return Some(if cursor_point.x <= 0 {
                 HTTOPLEFT
@@ -1052,11 +1060,12 @@ impl WindowsWindowInner {
             && let Some(last_pressed) = last_pressed
         {
             let handled = match (wparam.0 as u32, last_pressed) {
-                (HTMINBUTTON, HTMINBUTTON) => {
+                (HTMINBUTTON, HTMINBUTTON) if self.is_minimizable => {
                     unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() };
                     true
                 }
-                (HTMAXBUTTON, HTMAXBUTTON) => {
+                (HTMINBUTTON, HTMINBUTTON) => true,
+                (HTMAXBUTTON, HTMAXBUTTON) if self.is_resizable => {
                     if self.state.is_maximized() {
                         unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() };
                     } else {
@@ -1064,6 +1073,7 @@ impl WindowsWindowInner {
                     }
                     true
                 }
+                (HTMAXBUTTON, HTMAXBUTTON) => true,
                 (HTCLOSE, HTCLOSE) => {
                     unsafe {
                         PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default())
diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs
index 871da413e3b..9ba94563a40 100644
--- a/crates/gpui_windows/src/platform.rs
+++ b/crates/gpui_windows/src/platform.rs
@@ -741,6 +741,15 @@ impl Platform for WindowsPlatform {
     }
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> {
+        // CredWriteW rejects larger blobs with the opaque RPC error
+        // 0x800706F7 "The stub received bad data", so fail with a clear
+        // message instead.
+        if password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize {
+            return Task::ready(Err(anyhow!(
+                "credential for {url} is {} bytes, which exceeds the Windows Credential Manager limit of {CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes",
+                password.len()
+            )));
+        }
         let password = password.to_vec();
         let mut username = username.encode_utf16().chain(Some(0)).collect_vec();
         let mut target_name = windows_credentials_target_name(url)
diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl
index 89c12489b24..483f0399385 100644
--- a/crates/gpui_windows/src/shaders.hlsl
+++ b/crates/gpui_windows/src/shaders.hlsl
@@ -1249,7 +1249,7 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe
     float distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii);
 
     float4 color = sample;
-    if ((sprite.grayscale & 0xFFu) != 0u) {
+    if (sprite.grayscale != 0u) {
         float3 grayscale = dot(color.rgb, GRAYSCALE_FACTORS);
         color = float4(grayscale, sample.a);
     }
diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs
index c9352a60035..5a4129b493c 100644
--- a/crates/gpui_windows/src/window.rs
+++ b/crates/gpui_windows/src/window.rs
@@ -94,6 +94,8 @@ pub(crate) struct WindowsWindowInner {
     pub(crate) handle: AnyWindowHandle,
     pub(crate) hide_title_bar: bool,
     pub(crate) is_movable: bool,
+    pub(crate) is_resizable: bool,
+    pub(crate) is_minimizable: bool,
     pub(crate) executor: ForegroundExecutor,
     pub(crate) validation_number: usize,
     pub(crate) main_receiver: PriorityQueueReceiver,
@@ -262,6 +264,8 @@ impl WindowsWindowInner {
             handle: context.handle,
             hide_title_bar: context.hide_title_bar,
             is_movable: context.is_movable,
+            is_resizable: context.is_resizable,
+            is_minimizable: context.is_minimizable,
             executor: context.executor.clone(),
             validation_number: context.validation_number,
             main_receiver: context.main_receiver.clone(),
@@ -384,6 +388,8 @@ struct WindowCreateContext {
     hide_title_bar: bool,
     display: WindowsDisplay,
     is_movable: bool,
+    is_resizable: bool,
+    is_minimizable: bool,
     min_size: Option>,
     executor: ForegroundExecutor,
     current_cursor: Option,
@@ -405,6 +411,12 @@ impl WindowsWindow {
         params: WindowParams,
         creation_info: WindowCreationInfo,
     ) -> Result {
+        // Native popups are not implemented on Windows yet. Rejecting lets callers fall back to
+        // gpui's in-window popovers.
+        if let WindowKind::AnchoredPopup(_) = params.kind {
+            return Err(popup::PopupNotSupportedError.into());
+        }
+
         let WindowCreationInfo {
             icon,
             executor,
@@ -487,6 +499,8 @@ impl WindowsWindow {
             hide_title_bar,
             display,
             is_movable: params.is_movable,
+            is_resizable: params.is_resizable,
+            is_minimizable: params.is_minimizable,
             min_size: params.window_min_size,
             executor,
             current_cursor,
@@ -1531,8 +1545,12 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32
             .log_err()
         {
             let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8);
+            let Some(raw_set_window_composition_attribute) = GetProcAddress(user32, func_name)
+            else {
+                return;
+            };
             let set_window_composition_attribute: SetWindowCompositionAttributeType =
-                std::mem::transmute(GetProcAddress(user32, func_name));
+                std::mem::transmute(raw_set_window_composition_attribute);
             let mut color = color.unwrap_or_default();
             let is_acrylic = state == 4;
             if is_acrylic && color.3 == 0 {
diff --git a/crates/grammars/src/bash/config.toml b/crates/grammars/src/bash/config.toml
index adc2063341a..d7cabce3c2a 100644
--- a/crates/grammars/src/bash/config.toml
+++ b/crates/grammars/src/bash/config.toml
@@ -1,7 +1,7 @@
 name = "Shell Script"
 code_fence_block_name = "bash"
 grammar = "bash"
-path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"]
+path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD", "ebuild"]
 modeline_aliases = ["sh", "shell", "shell-script", "zsh"]
 line_comments = ["# "]
 first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b'
diff --git a/crates/grammars/src/go/outline.scm b/crates/grammars/src/go/outline.scm
index da42904fab9..29b9bd7554a 100644
--- a/crates/grammars/src/go/outline.scm
+++ b/crates/grammars/src/go/outline.scm
@@ -23,7 +23,7 @@
   receiver: (parameter_list
     "(" @context
     (parameter_declaration
-      name: (_) @context
+      name: (_)? @context
       type: (_) @context)
     ")" @context)
   name: (field_identifier) @name
diff --git a/crates/grammars/src/javascript/injections.scm b/crates/grammars/src/javascript/injections.scm
index 8ccfc5028de..9d07511f952 100644
--- a/crates/grammars/src/javascript/injections.scm
+++ b/crates/grammars/src/javascript/injections.scm
@@ -142,3 +142,25 @@
   ])
   (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/")
   (#set! injection.language "css"))
+
+; '/* glsl */' or '/*glsl*/'
+(((comment) @_ecma_comment
+  [
+    (string
+      (string_fragment) @injection.content)
+    (template_string
+      (string_fragment) @injection.content)
+  ])
+  (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/")
+  (#set! injection.language "glsl"))
+
+; '/* wgsl */' or '/*wgsl*/'
+(((comment) @_ecma_comment
+  [
+    (string
+      (string_fragment) @injection.content)
+    (template_string
+      (string_fragment) @injection.content)
+  ])
+  (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/")
+  (#set! injection.language "WGSL/WESL"))
diff --git a/crates/grammars/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm
index ce6d9e6bd94..c87c53cbc28 100644
--- a/crates/grammars/src/javascript/outline.scm
+++ b/crates/grammars/src/javascript/outline.scm
@@ -169,6 +169,7 @@
   name: (_) @name) @item
 
 ; Add support for (node:test, bun:test and Jest) runnable
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -188,7 +189,10 @@
       (identifier) @name
     ]))) @item
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -199,7 +203,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#eq? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/grammars/src/javascript/runnables.scm b/crates/grammars/src/javascript/runnables.scm
index b410fb4d8ca..8b0b160c862 100644
--- a/crates/grammars/src/javascript/runnables.scm
+++ b/crates/grammars/src/javascript/runnables.scm
@@ -1,5 +1,6 @@
 ; Add support for (node:test, bun:test and Jest) runnable
 ; Function expression that has `it`, `test` or `describe` as the function name
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -20,7 +21,10 @@
     ])) @_js-test
   (#set! tag js-test))
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -31,7 +35,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#eq? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/grammars/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm
index 2c08c30ad58..19099b0076a 100644
--- a/crates/grammars/src/tsx/outline.scm
+++ b/crates/grammars/src/tsx/outline.scm
@@ -175,6 +175,7 @@
   name: (_) @name) @item
 
 ; Add support for (node:test, bun:test and Jest) runnable
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -194,7 +195,10 @@
       (identifier) @name
     ]))) @item
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -205,7 +209,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#any-of? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/grammars/src/tsx/runnables.scm b/crates/grammars/src/tsx/runnables.scm
index db1f69a2c22..8b0b160c862 100644
--- a/crates/grammars/src/tsx/runnables.scm
+++ b/crates/grammars/src/tsx/runnables.scm
@@ -1,5 +1,6 @@
 ; Add support for (node:test, bun:test and Jest) runnable
 ; Function expression that has `it`, `test` or `describe` as the function name
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -20,7 +21,10 @@
     ])) @_js-test
   (#set! tag js-test))
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -31,7 +35,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#any-of? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/grammars/src/typescript/injections.scm b/crates/grammars/src/typescript/injections.scm
index a8cf9a41b5f..19ffe697918 100644
--- a/crates/grammars/src/typescript/injections.scm
+++ b/crates/grammars/src/typescript/injections.scm
@@ -197,3 +197,25 @@
   ])
   (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/")
   (#set! injection.language "css"))
+
+; '/* glsl */' or '/*glsl*/'
+(((comment) @_ecma_comment
+  [
+    (string
+      (string_fragment) @injection.content)
+    (template_string
+      (string_fragment) @injection.content)
+  ])
+  (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/")
+  (#set! injection.language "glsl"))
+
+; '/* wgsl */' or '/*wgsl*/'
+(((comment) @_ecma_comment
+  [
+    (string
+      (string_fragment) @injection.content)
+    (template_string
+      (string_fragment) @injection.content)
+  ])
+  (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/")
+  (#set! injection.language "WGSL/WESL"))
diff --git a/crates/grammars/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm
index 2c08c30ad58..19099b0076a 100644
--- a/crates/grammars/src/typescript/outline.scm
+++ b/crates/grammars/src/typescript/outline.scm
@@ -175,6 +175,7 @@
   name: (_) @name) @item
 
 ; Add support for (node:test, bun:test and Jest) runnable
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -194,7 +195,10 @@
       (identifier) @name
     ]))) @item
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -205,7 +209,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#any-of? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/grammars/src/typescript/runnables.scm b/crates/grammars/src/typescript/runnables.scm
index 38fee610e85..b2321b816ca 100644
--- a/crates/grammars/src/typescript/runnables.scm
+++ b/crates/grammars/src/typescript/runnables.scm
@@ -1,5 +1,6 @@
 ; Add support for (node:test, bun:test, Jest and Deno.test) runnable
 ; Function expression that has `it`, `test` or `describe` as the function name
+; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest)
 ((call_expression
   function: [
     (identifier) @_name
@@ -20,7 +21,10 @@
     ])) @_js-test
   (#set! tag js-test))
 
-; Add support for parameterized tests
+; Parameterized and conditional tests. Docs per runner:
+;   Jest:   https://jestjs.io/docs/api#testeachtablename-fn-timeout
+;   Vitest: https://vitest.dev/api/
+;   Bun:    https://bun.sh/docs/test/writing-tests#test-modifiers
 ((call_expression
   function: (call_expression
     function: (member_expression
@@ -31,7 +35,13 @@
       ]
       property: (property_identifier) @_property)
     (#any-of? @_name "it" "test" "describe" "context" "suite")
-    (#any-of? @_property "each"))
+    (#any-of? @_property
+      ; Jest, Bun, Vitest
+      "each"
+      ; Vitest
+      "skipIf" "runIf"
+      ; Bun
+      "if" "todoIf"))
   arguments: (arguments
     .
     [
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 56182da2133..3e22896e325 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -281,6 +281,7 @@ pub enum IconName {
     TriangleRight,
     Undo,
     Unpin,
+    UserArrowUp,
     UserCheck,
     UserGroup,
     UserRoundPen,
diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs
index 0bb66ab988c..db405135994 100644
--- a/crates/keymap_editor/src/keymap_editor.rs
+++ b/crates/keymap_editor/src/keymap_editor.rs
@@ -1436,8 +1436,15 @@ impl KeymapEditor {
             self.table_interaction_state.read(cx).scroll_offset(),
         ));
         let keyboard_mapper = cx.keyboard_mapper().clone();
+        let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone();
         cx.spawn(async move |_, _| {
-            remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await
+            remove_keybinding(
+                to_remove,
+                &fs,
+                keyboard_mapper.as_ref(),
+                &deprecated_aliases,
+            )
+            .await
         })
         .detach_and_notify_err(self.workspace.clone(), window, cx);
     }
@@ -2839,6 +2846,7 @@ impl KeybindingEditorModal {
 
         let create = self.creating;
         let keyboard_mapper = cx.keyboard_mapper().clone();
+        let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone();
 
         let action_name = self
             .get_selected_action_name(cx)
@@ -2869,6 +2877,7 @@ impl KeybindingEditorModal {
                 new_action_args.as_deref(),
                 &fs,
                 keyboard_mapper.as_ref(),
+                &deprecated_aliases,
             )
             .await
             {
@@ -3607,6 +3616,7 @@ async fn save_keybinding_update(
     new_args: Option<&str>,
     fs: &Arc,
     keyboard_mapper: &dyn PlatformKeyboardMapper,
+    deprecated_aliases: &HashMap<&'static str, &'static str>,
 ) -> anyhow::Result<()> {
     let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
         .await
@@ -3656,8 +3666,9 @@ async fn save_keybinding_update(
         keymap_contents,
         tab_size,
         keyboard_mapper,
+        deprecated_aliases,
     )
-    .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?;
+    .map_err(|err| err.context("Could not save updated keybinding"))?;
     fs.write(
         paths::keymap_file().as_path(),
         updated_keymap_contents.as_bytes(),
@@ -3678,6 +3689,7 @@ async fn remove_keybinding(
     existing: ProcessedBinding,
     fs: &Arc,
     keyboard_mapper: &dyn PlatformKeyboardMapper,
+    deprecated_aliases: &HashMap<&'static str, &'static str>,
 ) -> anyhow::Result<()> {
     let Some(keystrokes) = existing.keystrokes() else {
         anyhow::bail!("Cannot remove a keybinding that does not exist");
@@ -3707,6 +3719,7 @@ async fn remove_keybinding(
         keymap_contents,
         tab_size,
         keyboard_mapper,
+        deprecated_aliases,
     )
     .context("Failed to update keybinding")?;
     fs.write(
@@ -4015,6 +4028,204 @@ mod persistence {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use fs::FakeFs;
+    use gpui::{TestAppContext, VisualTestContext};
+    use project::Project;
+    use serde_json::json;
+    use settings::KeymapFileLoadResult;
+    use workspace::{AppState, MultiWorkspace};
+
+    async fn reload_keymap_from_file(fs: &Arc, cx: &mut TestAppContext) {
+        let content = fs.load(paths::keymap_file().as_path()).await.unwrap();
+        cx.update(|cx| {
+            let mut key_bindings = match KeymapFile::load(&content, cx) {
+                KeymapFileLoadResult::Success { key_bindings } => key_bindings,
+                KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => {
+                    panic!("keymap failed to load: {error_message:?}")
+                }
+                KeymapFileLoadResult::JsonParseFailure { error } => {
+                    panic!("keymap json parse failure: {error}")
+                }
+            };
+            cx.clear_key_bindings();
+            for key_binding in &mut key_bindings {
+                key_binding.set_meta(KeybindSource::User.meta());
+            }
+            cx.bind_keys(key_bindings);
+            KeymapEventChannel::trigger_keymap_changed(cx);
+        });
+    }
+
+    async fn setup_keymap_editor(
+        cx: &mut TestAppContext,
+        keymap_content: &str,
+    ) -> (Arc, Entity, VisualTestContext) {
+        cx.update(|cx| {
+            let _state = AppState::test(cx);
+            editor::init(cx);
+            cx.set_global(KeymapEventChannel::new());
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            paths::config_dir(),
+            json!({ "keymap.json": keymap_content }),
+        )
+        .await;
+
+        reload_keymap_from_file(&fs, cx).await;
+
+        let project = Project::test(fs.clone(), [], cx).await;
+        let window_handle =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window_handle
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
+        let keymap_editor = cx
+            .update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace.downgrade(), window, cx)));
+        cx.run_until_parked();
+        (fs, keymap_editor, cx)
+    }
+
+    fn visible_rows_for_action(editor: &KeymapEditor, action_name: &str) -> Vec {
+        editor
+            .matches
+            .iter()
+            .enumerate()
+            .filter(|(_, string_match)| {
+                let binding = &editor.keybindings[string_match.candidate_id];
+                binding.action().name == action_name && binding.keystrokes().is_some()
+            })
+            .map(|(index, _)| index)
+            .collect()
+    }
+
+    #[gpui::test]
+    async fn test_delete_one_of_two_identical_user_bindings(cx: &mut TestAppContext) {
+        let keymap_content = r#"[
+    {
+        "bindings": {
+            "alt-cmd-shift-c": "zed::OpenKeymap"
+        }
+    },
+    {
+        "bindings": {
+            "alt-cmd-shift-c": "zed::OpenKeymap"
+        }
+    }
+]"#;
+        let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await;
+        let cx = &mut cx;
+
+        let rows = keymap_editor.read_with(cx, |editor, _| {
+            visible_rows_for_action(editor, "zed::OpenKeymap")
+        });
+        assert_eq!(
+            rows.len(),
+            2,
+            "expected the two duplicate bindings to show as two rows"
+        );
+
+        keymap_editor.update_in(cx, |editor, window, cx| {
+            editor.selected_index = Some(rows[1]);
+            editor.delete_binding(&DeleteBinding, window, cx);
+        });
+        cx.run_until_parked();
+
+        let content = fs.load(paths::keymap_file().as_path()).await.unwrap();
+        assert_eq!(
+            content.matches("alt-cmd-shift-c").count(),
+            1,
+            "expected exactly one binding remaining in the keymap file, got:\n{content}"
+        );
+
+        // Simulate the keymap file watcher reacting to the change.
+        reload_keymap_from_file(&fs, cx).await;
+        cx.run_until_parked();
+
+        let rows = keymap_editor.read_with(cx, |editor, _| {
+            visible_rows_for_action(editor, "zed::OpenKeymap")
+        });
+        assert_eq!(rows.len(), 1, "expected one row remaining after deletion");
+    }
+
+    // Regression test: one of the two entries in the keymap file uses a
+    // deprecated alias of the action (`editor::CopyRelativePath` instead of
+    // `workspace::CopyRelativePath`). Both rows display identically in the
+    // keymap editor (aliases resolve to the canonical action on load), but
+    // deletion targets the canonical action name, so `KeymapFile::update_keybinding`
+    // used to never find the alias entry, making it impossible to delete.
+    #[gpui::test]
+    async fn test_delete_binding_with_deprecated_action_alias(cx: &mut TestAppContext) {
+        let keymap_content = r#"[
+    {
+        "bindings": {
+            "alt-cmd-shift-c": "editor::CopyRelativePath"
+        }
+    },
+    {
+        "bindings": {
+            "alt-cmd-shift-c": "workspace::CopyRelativePath"
+        }
+    }
+]"#;
+        let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await;
+        let cx = &mut cx;
+
+        let rows = keymap_editor.read_with(cx, |editor, _| {
+            visible_rows_for_action(editor, "workspace::CopyRelativePath")
+        });
+        assert_eq!(
+            rows.len(),
+            2,
+            "both the alias and the canonical entry should show as (identical) rows"
+        );
+
+        // Delete the first row. Both rows report the canonical action name, so
+        // `find_binding` matches the canonical file entry and removes it.
+        keymap_editor.update_in(cx, |editor, window, cx| {
+            editor.selected_index = Some(rows[0]);
+            editor.delete_binding(&DeleteBinding, window, cx);
+        });
+        cx.run_until_parked();
+
+        let content = fs.load(paths::keymap_file().as_path()).await.unwrap();
+        assert_eq!(
+            content.matches("alt-cmd-shift-c").count(),
+            1,
+            "first deletion should remove one of the two entries, got:\n{content}"
+        );
+
+        // Simulate the keymap file watcher reacting to the change.
+        reload_keymap_from_file(&fs, cx).await;
+        cx.run_until_parked();
+
+        let rows = keymap_editor.read_with(cx, |editor, _| {
+            visible_rows_for_action(editor, "workspace::CopyRelativePath")
+        });
+        assert_eq!(
+            rows.len(),
+            1,
+            "one row should remain after the first deletion"
+        );
+
+        // Delete the remaining row (the alias entry). `find_binding` must
+        // resolve the deprecated alias in the file to the canonical action
+        // name to find and remove it.
+        keymap_editor.update_in(cx, |editor, window, cx| {
+            editor.selected_index = Some(rows[0]);
+            editor.delete_binding(&DeleteBinding, window, cx);
+        });
+        cx.run_until_parked();
+
+        let content = fs.load(paths::keymap_file().as_path()).await.unwrap();
+        assert_eq!(
+            content.matches("alt-cmd-shift-c").count(),
+            0,
+            "second deletion should remove the remaining (alias) entry, got:\n{content}"
+        );
+    }
 
     #[test]
     fn normalized_ctx_cmp() {
diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs
index 413c50d2627..c37065146f2 100644
--- a/crates/language/src/buffer.rs
+++ b/crates/language/src/buffer.rs
@@ -2163,6 +2163,11 @@ impl Buffer {
                             for row in row_range.skip(1) {
                                 indent_sizes.entry(row).or_insert_with(|| {
                                     let mut size = snapshot.indent_size_for_line(row);
+                                    // A line with no indentation has an arbitrary
+                                    // indent kind, so it can adopt the new kind.
+                                    if size.len == 0 {
+                                        size.kind = new_indent.kind;
+                                    }
                                     if size.kind == new_indent.kind {
                                         match delta.cmp(&0) {
                                             Ordering::Greater => size.len += delta as u32,
@@ -2279,14 +2284,21 @@ impl Buffer {
         })
     }
 
-    /// Spawns a background task that searches the buffer for any whitespace
-    /// at the ends of a lines, and returns a `Diff` that removes that whitespace.
-    pub fn remove_trailing_whitespace(&self, cx: &App) -> Task {
+    /// Spawns a background task that returns a `Diff` removing trailing whitespace from line ends.
+    ///
+    /// When `modified_rows` is `Some`, only lines whose row falls within one of the given ranges
+    /// are trimmed; when it is `None`, the whole buffer is scanned.
+    pub fn remove_trailing_whitespace(
+        &self,
+        modified_rows: Option<&[Range]>,
+        cx: &App,
+    ) -> Task {
         let old_text = self.as_rope().clone();
         let line_ending = self.line_ending();
         let base_version = self.version();
+        let modified_rows = modified_rows.map(|rows| rows.to_vec());
         cx.background_spawn(async move {
-            let ranges = trailing_whitespace_ranges(&old_text);
+            let ranges = trailing_whitespace_ranges(&old_text, modified_rows.as_deref());
             let empty = Arc::::from("");
             Diff {
                 base_version,
@@ -2299,28 +2311,60 @@ impl Buffer {
         })
     }
 
-    /// Ensures that the buffer ends with a single newline character, and
-    /// no other whitespace. Skips if the buffer is empty.
-    pub fn ensure_final_newline(&mut self, cx: &mut Context) {
+    /// Returns a `Diff` ensuring the buffer ends with a trailing newline.
+    ///
+    /// When `modified_rows` is `None`, the whole buffer is considered: trailing whitespace and
+    /// blank lines at the end of the file are collapsed into a single newline.
+    ///
+    /// When `modified_rows` is `Some`, the operation is scoped to a "Format Selection": a single
+    /// newline is appended only when the last line is non-empty and its row falls within one of
+    /// the ranges. Trailing blank lines are left intact so that formatting a selection cannot
+    /// delete unselected rows.
+    pub fn ensure_final_newline(&self, modified_rows: Option<&[Range]>) -> Diff {
         let len = self.len();
-        if len == 0 {
-            return;
-        }
-        let mut offset = len;
-        for chunk in self.as_rope().reversed_chunks_in_range(0..len) {
-            let non_whitespace_len = chunk
-                .trim_end_matches(|c: char| c.is_ascii_whitespace())
-                .len();
-            offset -= chunk.len();
-            offset += non_whitespace_len;
-            if non_whitespace_len != 0 {
-                if offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n") {
-                    return;
-                }
-                break;
+        let line_ending = self.line_ending();
+        let base_version = self.version();
+        let newline = Arc::::from("\n");
+
+        let edits = if len == 0 {
+            Vec::new()
+        } else if let Some(modified_rows) = modified_rows {
+            let max_point = self.max_point();
+            let last_line_is_empty = max_point.column == 0;
+            let last_row_is_modified = modified_rows
+                .iter()
+                .any(|range| range.contains(&max_point.row));
+            if last_line_is_empty || !last_row_is_modified {
+                Vec::new()
+            } else {
+                Vec::from([(len..len, newline)])
             }
+        } else {
+            let mut offset = len;
+            let mut already_normalized = false;
+            for chunk in self.as_rope().reversed_chunks_in_range(0..len) {
+                let non_whitespace_len = chunk
+                    .trim_end_matches(|c: char| c.is_ascii_whitespace())
+                    .len();
+                offset -= chunk.len();
+                offset += non_whitespace_len;
+                if non_whitespace_len != 0 {
+                    already_normalized =
+                        offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n");
+                    break;
+                }
+            }
+            if already_normalized {
+                Vec::new()
+            } else {
+                Vec::from([(offset..len, newline)])
+            }
+        };
+        Diff {
+            base_version,
+            line_ending,
+            edits,
         }
-        self.edit([(offset..len, "\n")], None, cx);
     }
 
     /// Applies a diff to the buffer. If the buffer has changed since the given diff was
@@ -6133,10 +6177,24 @@ impl CharClassifier {
 ///
 /// This could also be done with a regex search, but this implementation
 /// avoids copying text.
-pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> {
+/// Returns the byte ranges of trailing whitespace at the end of each line.
+///
+/// When `row_ranges` is `Some`, only lines whose row falls within one of the ranges are
+/// included. The single pass keeps filtering cheap, avoiding collecting every range up front.
+pub(crate) fn trailing_whitespace_ranges(
+    rope: &Rope,
+    row_ranges: Option<&[Range]>,
+) -> Vec> {
     let mut ranges = Vec::new();
 
+    let is_row_included = |row: u32| match row_ranges {
+        Some(row_ranges) => row_ranges.iter().any(|range| range.contains(&row)),
+        None => true,
+    };
+
     let mut offset = 0;
+    let mut current_row: u32 = 0;
+    let mut prev_row: Option = None;
     let mut prev_chunk_trailing_whitespace_range = 0..0;
     for chunk in rope.chunks() {
         let mut prev_line_trailing_whitespace_range = 0..0;
@@ -6148,19 +6206,24 @@ pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> {
             if i == 0 && trimmed_line_len == 0 {
                 trailing_whitespace_range.start = prev_chunk_trailing_whitespace_range.start;
             }
-            if !prev_line_trailing_whitespace_range.is_empty() {
-                ranges.push(prev_line_trailing_whitespace_range);
+            if let Some(row) = prev_row {
+                if !prev_line_trailing_whitespace_range.is_empty() && is_row_included(row) {
+                    ranges.push(prev_line_trailing_whitespace_range);
+                }
             }
 
+            prev_row = Some(current_row);
             offset = line_end_offset + 1;
+            current_row += 1;
             prev_line_trailing_whitespace_range = trailing_whitespace_range;
         }
 
         offset -= 1;
+        current_row -= 1;
         prev_chunk_trailing_whitespace_range = prev_line_trailing_whitespace_range;
     }
 
-    if !prev_chunk_trailing_whitespace_range.is_empty() {
+    if !prev_chunk_trailing_whitespace_range.is_empty() && is_row_included(current_row) {
         ranges.push(prev_chunk_trailing_whitespace_range);
     }
 
diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs
index ba7fd7ea957..29afbe84b72 100644
--- a/crates/language/src/buffer_tests.rs
+++ b/crates/language/src/buffer_tests.rs
@@ -571,7 +571,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) {
 
     // Spawn a task to format the buffer's whitespace.
     // Pause so that the formatting task starts running.
-    let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx));
+    let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(None, cx));
     yield_now().await;
 
     // Edit the buffer while the normalization task is running.
@@ -2180,6 +2180,38 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) {
     });
 }
 
+#[gpui::test]
+fn test_autoindent_block_mode_with_hard_tabs(cx: &mut App) {
+    init_settings(cx, |settings| {
+        settings.defaults.hard_tabs = Some(true);
+    });
+
+    cx.new(|cx| {
+        let text = "fn a() {\n\tb();\n}";
+        let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx);
+
+        // Insert a block whose indentation mixes tab-indented lines with
+        // lines that have no leading whitespace, like a snippet body.
+        let inserted_text = "if c {\n\td();\n}\n";
+        buffer.edit(
+            [(Point::new(2, 0)..Point::new(2, 0), inserted_text)],
+            Some(AutoindentMode::Block {
+                original_indent_columns: Vec::new(),
+            }),
+            cx,
+        );
+
+        // All of the block's lines are indented, including the ones that
+        // originally had no indentation.
+        assert_eq!(
+            buffer.text(),
+            "fn a() {\n\tb();\n\tif c {\n\t\td();\n\t}\n}"
+        );
+
+        buffer
+    });
+}
+
 #[gpui::test]
 fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) {
     init_settings(cx, |_| {});
@@ -3792,7 +3824,7 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) {
     }
 
     let rope = Rope::from(text.as_str());
-    let actual_ranges = trailing_whitespace_ranges(&rope);
+    let actual_ranges = trailing_whitespace_ranges(&rope, None);
     let expected_ranges = TRAILING_WHITESPACE_REGEX
         .find_iter(&text)
         .map(|m| m.range())
@@ -3805,6 +3837,228 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) {
     );
 }
 
+#[gpui::test(iterations = 500)]
+fn test_trailing_whitespace_ranges_in_rows(mut rng: StdRng) {
+    let mut text = String::new();
+    for _ in 0..rng.random_range(0..16) {
+        for _ in 0..rng.random_range(0..36) {
+            text.push(match rng.random_range(0..10) {
+                0..=1 => ' ',
+                3 => '\t',
+                _ => rng.random_range('a'..='z'),
+            });
+        }
+        text.push('\n');
+    }
+    match rng.random_range(0..10) {
+        0..=1 => drop(text.pop()),
+        2..=3 => text.push_str(&"\n".repeat(rng.random_range(1..5))),
+        _ => {}
+    }
+
+    let rope = Rope::from(text.as_str());
+    let all_ranges = trailing_whitespace_ranges(&rope, None);
+    let lines = text.split('\n').collect::>();
+
+    // A range covering every row must reproduce the unfiltered full scan exactly.
+    assert_eq!(
+        trailing_whitespace_ranges(&rope, Some(&[0..u32::MAX])),
+        all_ranges,
+        "full-coverage mismatch for lines:\n{lines:?}",
+    );
+
+    // For a random (possibly gappy) subset of rows, the filtered variant must equal
+    // the full scan restricted to ranges whose line is in the subset.
+    let max_row = rope.max_point().row;
+    let mut row_ranges = Vec::new();
+    let mut row = 0;
+    while row <= max_row {
+        let span = rng.random_range(0..=3);
+        if span > 0 {
+            let end = (row + span).min(max_row + 1);
+            row_ranges.push(row..end);
+            row = end;
+        }
+        row += 1;
+    }
+
+    let expected = all_ranges
+        .iter()
+        .filter(|range| {
+            let row = rope.offset_to_point(range.start).row;
+            row_ranges.iter().any(|r| r.contains(&row))
+        })
+        .cloned()
+        .collect::>();
+    assert_eq!(
+        trailing_whitespace_ranges(&rope, Some(&row_ranges)),
+        expected,
+        "subset mismatch for ranges {row_ranges:?} and lines:\n{lines:?}",
+    );
+}
+
+#[gpui::test]
+async fn test_trailing_whitespace_in_ranges(cx: &mut gpui::TestAppContext) {
+    // line 0: "zero"      (no trailing whitespace)
+    // line 1: "one  "     (2 trailing spaces)
+    // line 2: "two"       (no trailing whitespace)
+    // line 3: "three   "  (3 trailing spaces)
+    // line 4: "four"      (no trailing whitespace)
+    // line 5: "five    "  (4 trailing spaces)
+    let text = ["zero", "one  ", "two", "three   ", "four", "five    "].join("\n");
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+
+    // Only rows 1 and 5 are modified, so only those lines get cleaned; line 3 stays untouched.
+    let modified_rows = [1u32..2, 5..6];
+    let diff = buffer
+        .update(cx, |buffer, cx| {
+            buffer.remove_trailing_whitespace(Some(&modified_rows), cx)
+        })
+        .await;
+    buffer.update(cx, |buffer, cx| {
+        buffer.apply_diff(diff, cx);
+        assert_eq!(
+            buffer.text(),
+            ["zero", "one", "two", "three   ", "four", "five"].join("\n")
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_trailing_whitespace_empty_ranges(cx: &mut gpui::TestAppContext) {
+    let text = ["zero", "one  ", "two  "].join("\n");
+    let buffer = cx.new(|cx| Buffer::local(text.clone(), cx));
+
+    let diff = buffer
+        .update(cx, |buffer, cx| {
+            buffer.remove_trailing_whitespace(Some(&[]), cx)
+        })
+        .await;
+    buffer.update(cx, |buffer, cx| {
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), text);
+    });
+}
+
+#[gpui::test]
+async fn test_final_newline_modified_last_line(cx: &mut gpui::TestAppContext) {
+    // No final newline; the modified range (rows 0..3) includes the last line (row 2).
+    let text = "line0\nline1\nline2";
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+
+    buffer.update(cx, |buffer, cx| {
+        let diff = buffer.ensure_final_newline(Some(&[0u32..3]));
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "line0\nline1\nline2\n");
+    });
+}
+
+#[gpui::test]
+async fn test_final_newline_unmodified_last_line(cx: &mut gpui::TestAppContext) {
+    // No final newline; the modified range (rows 0..2) excludes the last line (row 2), so nothing changes.
+    let text = "line0\nline1\nline2";
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+
+    buffer.update(cx, |buffer, cx| {
+        let diff = buffer.ensure_final_newline(Some(&[0u32..2]));
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "line0\nline1\nline2");
+    });
+}
+
+// An empty last line (file already ends with a newline) is left untouched, even with extra
+// trailing blank lines. With `None` these would collapse; scoped to rows they must not, to
+// avoid deleting unselected rows.
+#[gpui::test]
+async fn test_final_newline_does_not_collapse_trailing_blank_lines(cx: &mut gpui::TestAppContext) {
+    let text = "line0\nline1\n\n";
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+
+    buffer.update(cx, |buffer, cx| {
+        let diff = buffer.ensure_final_newline(Some(&[0u32..4]));
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "line0\nline1\n\n");
+    });
+}
+
+// When scoped to rows, only a newline is inserted; unlike the `None` (whole-buffer) case, it
+// does not trim trailing whitespace on the last line.
+#[gpui::test]
+async fn test_final_newline_in_range_only_inserts(cx: &mut gpui::TestAppContext) {
+    let text = "line0\nline1  ";
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+
+    buffer.update(cx, |buffer, cx| {
+        let diff = buffer.ensure_final_newline(Some(&[0u32..2]));
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "line0\nline1  \n");
+    });
+}
+
+#[gpui::test]
+async fn test_final_newline_whole_buffer(cx: &mut gpui::TestAppContext) {
+    // (input, expected) pairs for the whole-buffer (`None`) case.
+    let cases = [
+        // Content without a trailing newline gets exactly one appended.
+        ("line0\nline1", "line0\nline1\n"),
+        // A buffer already ending in a single newline is left untouched.
+        ("line0\nline1\n", "line0\nline1\n"),
+        // Trailing blank lines and whitespace at the end of the file collapse to one newline.
+        ("line0\nline1\n\n\n", "line0\nline1\n"),
+        ("line0\nline1  \n  ", "line0\nline1\n"),
+        // An empty buffer stays empty.
+        ("", ""),
+    ];
+
+    for (input, expected) in cases {
+        let buffer = cx.new(|cx| Buffer::local(input, cx));
+        buffer.update(cx, |buffer, cx| {
+            let diff = buffer.ensure_final_newline(None);
+            buffer.apply_diff(diff, cx);
+            assert_eq!(buffer.text(), expected, "wrong result for input {input:?}");
+        });
+    }
+}
+
+#[gpui::test]
+async fn test_trailing_whitespace_in_ranges_crlf(cx: &mut gpui::TestAppContext) {
+    let text = "zero\r\none  \r\ntwo\r\nthree   \r\nfour\r\nfive    ";
+    let buffer = cx.new(|cx| {
+        let buffer = Buffer::local(text, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+        buffer
+    });
+
+    let modified_rows = [1u32..2, 5..6];
+    let diff = buffer
+        .update(cx, |buffer, cx| {
+            buffer.remove_trailing_whitespace(Some(&modified_rows), cx)
+        })
+        .await;
+    buffer.update(cx, |buffer, cx| {
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "zero\none\ntwo\nthree   \nfour\nfive");
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+}
+
+#[gpui::test]
+async fn test_final_newline_in_range_crlf(cx: &mut gpui::TestAppContext) {
+    let text = "line0\r\nline1\r\nline2";
+    let buffer = cx.new(|cx| {
+        let buffer = Buffer::local(text, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+        buffer
+    });
+
+    buffer.update(cx, |buffer, cx| {
+        let diff = buffer.ensure_final_newline(Some(&[0u32..3]));
+        buffer.apply_diff(diff, cx);
+        assert_eq!(buffer.text(), "line0\nline1\nline2\n");
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+}
+
 #[gpui::test]
 fn test_words_in_range(cx: &mut gpui::App) {
     init_settings(cx, |_| {});
diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs
index de67375cccc..1cf70338f46 100644
--- a/crates/language/src/language_settings.rs
+++ b/crates/language/src/language_settings.rs
@@ -19,7 +19,8 @@ pub use settings::{
     AutoIndentMode, CompletionSettingsContent, EditPredictionDataCollectionChoice,
     EditPredictionPromptFormatContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
     Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LineEndingSetting,
-    LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
+    LspInsertMode, REST_OF_LANGUAGE_SERVERS, RewrapBehavior, ShowWhitespaceSetting, SoftWrap,
+    WordsCompletionMode,
 };
 use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore, merge_from::MergeFrom};
 use shellexpand;
@@ -276,9 +277,6 @@ pub struct PrettierSettings {
 }
 
 impl LanguageSettings {
-    /// A token representing the rest of the available language servers.
-    const REST_OF_LANGUAGE_SERVERS: &'static str = "...";
-
     pub fn for_buffer<'a>(buffer: &'a Buffer, cx: &'a App) -> Cow<'a, LanguageSettings> {
         Self::resolve(Some(buffer), None, cx)
     }
@@ -382,7 +380,7 @@ impl LanguageSettings {
         enabled_language_servers
             .into_iter()
             .flat_map(|language_server| {
-                if language_server.0.as_ref() == Self::REST_OF_LANGUAGE_SERVERS {
+                if language_server.0.as_ref() == REST_OF_LANGUAGE_SERVERS {
                     rest.clone()
                 } else {
                     vec![language_server]
@@ -1090,7 +1088,7 @@ mod tests {
         // A value of just `["..."]` is the same as taking all of the available language servers.
         assert_eq!(
             LanguageSettings::resolve_language_servers(
-                &[LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()],
+                &[REST_OF_LANGUAGE_SERVERS.into()],
                 &available_language_servers,
             ),
             available_language_servers
@@ -1101,7 +1099,7 @@ mod tests {
             LanguageSettings::resolve_language_servers(
                 &[
                     "biome".into(),
-                    LanguageSettings::REST_OF_LANGUAGE_SERVERS.into(),
+                    REST_OF_LANGUAGE_SERVERS.into(),
                     "deno".into()
                 ],
                 &available_language_servers
@@ -1122,7 +1120,7 @@ mod tests {
                     "deno".into(),
                     "!typescript-language-server".into(),
                     "!biome".into(),
-                    LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()
+                    REST_OF_LANGUAGE_SERVERS.into()
                 ],
                 &available_language_servers
             ),
@@ -1134,7 +1132,7 @@ mod tests {
             LanguageSettings::resolve_language_servers(
                 &[
                     "my-cool-language-server".into(),
-                    LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()
+                    REST_OF_LANGUAGE_SERVERS.into()
                 ],
                 &available_language_servers
             ),
diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs
index 3c28e07e6b3..44092bf2639 100644
--- a/crates/language_extension/src/extension_lsp_adapter.rs
+++ b/crates/language_extension/src/extension_lsp_adapter.rs
@@ -79,16 +79,19 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy {
 
         let mut tasks = Vec::new();
         match &self.lsp_access {
-            LspAccess::ViaLspStore(lsp_store) => lsp_store.update(cx, |lsp_store, cx| {
-                let stop_task = lsp_store.stop_language_servers_for_buffers(
-                    Vec::new(),
-                    HashSet::from_iter([LanguageServerSelector::Name(
-                        language_server_name.clone(),
-                    )]),
-                    cx,
-                );
-                tasks.push(stop_task);
-            }),
+            LspAccess::ViaLspStore(lsp_store) => {
+                if let Ok(stop_task) = lsp_store.update(cx, |lsp_store, cx| {
+                    lsp_store.stop_language_servers_for_buffers(
+                        Vec::new(),
+                        HashSet::from_iter([LanguageServerSelector::Name(
+                            language_server_name.clone(),
+                        )]),
+                        cx,
+                    )
+                }) {
+                    tasks.push(stop_task);
+                }
+            }
             LspAccess::ViaWorkspaces(lsp_store_provider) => {
                 if let Ok(lsp_stores) = lsp_store_provider(cx) {
                     for lsp_store in lsp_stores {
diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs
index 96536b6c021..ffddd3d90f8 100644
--- a/crates/language_extension/src/language_extension.rs
+++ b/crates/language_extension/src/language_extension.rs
@@ -5,13 +5,13 @@ use std::sync::Arc;
 
 use anyhow::Result;
 use extension::{ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy};
-use gpui::{App, Entity};
+use gpui::{App, Entity, WeakEntity};
 use language::{LanguageMatcher, LanguageName, LanguageRegistry, LoadedLanguage};
 use project::LspStore;
 
 #[derive(Clone)]
 pub enum LspAccess {
-    ViaLspStore(Entity),
+    ViaLspStore(WeakEntity),
     ViaWorkspaces(Arc Result>> + Send + Sync + 'static>),
     Noop,
 }
diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs
index f58130cccf4..a9cb4c5b5fb 100644
--- a/crates/language_model/src/fake_provider.rs
+++ b/crates/language_model/src/fake_provider.rs
@@ -1,12 +1,12 @@
 use crate::{
-    AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice,
+    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice,
 };
 use anyhow::anyhow;
 use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream, stream::StreamExt};
-use gpui::{AnyView, App, AsyncApp, Entity, Task, Window};
+use gpui::{App, AsyncApp, Entity, Task};
 use http_client::Result;
 use parking_lot::Mutex;
 use std::sync::{
@@ -68,17 +68,8 @@ impl LanguageModelProvider for FakeLanguageModelProvider {
         Task::ready(Ok(()))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        _: &mut App,
-    ) -> AnyView {
-        unimplemented!()
-    }
-
-    fn reset_credentials(&self, _: &mut App) -> Task> {
-        Task::ready(Ok(()))
+    fn settings_view(&self, _: &mut App) -> Option {
+        None
     }
 }
 
diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs
index 7a8aa00f92d..2e060049d0b 100644
--- a/crates/language_model/src/language_model.rs
+++ b/crates/language_model/src/language_model.rs
@@ -15,6 +15,8 @@ use icons::IconName;
 use parking_lot::Mutex;
 use std::sync::Arc;
 
+pub type CreateProviderSettingsView = Arc AnyView + 'static>;
+
 pub use crate::api_key::{ApiKey, ApiKeyState};
 pub use crate::registry::*;
 pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui};
@@ -320,13 +322,11 @@ pub trait LanguageModelProvider: 'static {
     }
     fn is_authenticated(&self, cx: &App) -> bool;
     fn authenticate(&self, cx: &mut App) -> Task>;
-    fn configuration_view(
-        &self,
-        target_agent: ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView;
-    fn reset_credentials(&self, cx: &mut App) -> Task>;
+    fn settings_view(&self, cx: &mut App) -> Option;
+
+    fn set_api_key(&self, _key: Option, _cx: &mut App) -> Task> {
+        Task::ready(Ok(()))
+    }
 
     /// Copy shown when this provider rejects a request as unauthenticated
     /// (HTTP 401). The default assumes API-key authentication; providers using
@@ -335,7 +335,7 @@ pub trait LanguageModelProvider: 'static {
     fn authentication_error_message(&self) -> SharedString {
         format!(
             "The API key for {} is invalid or has expired. \
-            Update your key via the Agent Panel settings to continue.",
+            Update your key in Settings > AI > LLM Providers to continue.",
             self.name().0
         )
         .into()
@@ -348,45 +348,12 @@ pub trait LanguageModelProvider: 'static {
     fn missing_credentials_error_message(&self) -> SharedString {
         format!(
             "No API key is configured for {}. \
-            Add your key via the Agent Panel settings to continue.",
+            Add your key in Settings > AI > LLM Providers to continue.",
             self.name().0
         )
         .into()
     }
 
-    /// Returns the provider's configuration UI together with how it prefers to
-    /// be presented: [`ProviderConfigurationView::Inline`] for a compact control
-    /// that can sit in a list row (e.g. a single API-key field), or
-    /// [`ProviderConfigurationView::SubPage`] for a richer view that needs its
-    /// own surface.
-    ///
-    /// The default reuses [`Self::configuration_view`] as a sub-page, so
-    /// providers only override this when they have a compact inline form.
-    fn configuration_view_v2(
-        &self,
-        target_agent: ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        ProviderConfigurationView::SubPage(self.configuration_view(target_agent, window, cx))
-    }
-
-    fn inline_title(&self, _cx: &App) -> Option {
-        None
-    }
-
-    fn inline_description(&self, _cx: &App) -> Option {
-        None
-    }
-
-    fn api_key_configuration(&self, _cx: &App) -> Option {
-        None
-    }
-
-    fn set_api_key(&self, _key: String, _cx: &mut App) -> Task> {
-        Task::ready(Ok(()))
-    }
-
     /// Copy shown the first time a user enables fast mode for a model from
     /// this provider. Returning `None` skips the confirmation prompt and lets
     /// the toggle apply silently.
@@ -395,11 +362,55 @@ pub trait LanguageModelProvider: 'static {
     }
 }
 
-/// How a provider's configuration UI prefers to be presented by the settings UI.
+/// A provider's settings UI, modeled as mutually exclusive presentation modes.
 #[derive(Clone)]
-pub enum ProviderConfigurationView {
-    Inline { view: AnyView },
-    SubPage(AnyView),
+pub enum ProviderSettingsView {
+    ApiKey(ApiKeyConfiguration),
+    Inline(InlineProviderSettings),
+    SubPage(SubPageProviderSettings),
+}
+
+#[derive(Clone)]
+pub struct InlineProviderSettings {
+    pub title: Option,
+    pub description: Option,
+    pub create_view: CreateProviderSettingsView,
+}
+
+#[derive(Clone)]
+pub struct SubPageProviderSettings {
+    pub description: Option,
+    pub create_view: CreateProviderSettingsView,
+}
+
+impl SubPageProviderSettings {
+    pub fn new(create_view: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
+        Self {
+            description: None,
+            create_view: Arc::new(create_view),
+        }
+    }
+
+    pub fn description(mut self, description: InlineDescription) -> Self {
+        self.description = Some(description);
+        self
+    }
+}
+
+impl ApiKeyConfiguration {
+    pub fn new(
+        has_key: bool,
+        is_from_env_var: bool,
+        env_var_name: SharedString,
+        api_key_url: SharedString,
+    ) -> Self {
+        Self {
+            has_key,
+            is_from_env_var,
+            env_var_name,
+            api_key_url,
+        }
+    }
 }
 
 /// A live snapshot of a single-API-key provider's credential state, used by the
@@ -430,13 +441,6 @@ pub struct FastModeConfirmation {
     pub message: SharedString,
 }
 
-#[derive(Default, Clone, PartialEq, Eq)]
-pub enum ConfigurationViewTargetAgent {
-    #[default]
-    ZedAgent,
-    Other(SharedString),
-}
-
 pub trait LanguageModelProviderState: 'static {
     type ObservableEntity;
 
diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs
index ddc30b5d30a..28033e482f3 100644
--- a/crates/language_model/src/registry.rs
+++ b/crates/language_model/src/registry.rs
@@ -44,6 +44,8 @@ impl std::fmt::Debug for ConfigurationError {
 
 #[derive(Default)]
 pub struct LanguageModelRegistry {
+    /// True if the user has *NO* default model configured in settings
+    should_use_fallback: bool,
     default_model: Option,
     /// This model is automatically configured by a user's environment after
     /// authenticating all providers. It's only used when `default_model` is not set.
@@ -151,6 +153,10 @@ impl LanguageModelRegistry {
         self.default_model.as_ref().unwrap().model.clone()
     }
 
+    pub fn set_should_use_fallback(&mut self, value: bool) {
+        self.should_use_fallback = value;
+    }
+
     pub fn register_provider(
         &mut self,
         provider: Arc,
@@ -357,11 +363,30 @@ impl LanguageModelRegistry {
         self.default_model = model;
     }
 
-    pub fn set_environment_fallback_model(
-        &mut self,
-        model: Option,
-        cx: &mut Context,
-    ) {
+    pub fn refresh_fallback_model(&mut self, cx: &mut Context) {
+        // If the fallback model was already set or we don't want to use it, do nothing
+        if !self.should_use_fallback || self.available_fallback_model.is_some() {
+            return;
+        }
+
+        let fallback_model = self
+            .providers()
+            .iter()
+            .filter(|provider| provider.is_authenticated(cx))
+            .find_map(|provider| {
+                let model = provider
+                    .default_model(cx)
+                    .or_else(|| provider.recommended_models(cx).first().cloned())?;
+                Some(ConfiguredModel {
+                    provider: provider.clone(),
+                    model,
+                })
+            });
+
+        self.set_fallback_model(fallback_model, cx);
+    }
+
+    fn set_fallback_model(&mut self, model: Option, cx: &mut Context) {
         if self.default_model.is_none() {
             match (self.available_fallback_model.as_ref(), model.as_ref()) {
                 (Some(old), Some(new)) if old.is_same_as(new) => {}
@@ -417,9 +442,13 @@ impl LanguageModelRegistry {
             return None;
         }
 
-        self.default_model
-            .clone()
-            .or_else(|| self.available_fallback_model.clone())
+        self.default_model.clone().or_else(|| {
+            if self.should_use_fallback {
+                self.available_fallback_model.clone()
+            } else {
+                None
+            }
+        })
     }
 
     pub fn default_fast_model(&self, cx: &App) -> Option {
@@ -617,7 +646,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) {
+    async fn test_configure_fallback_model(cx: &mut gpui::TestAppContext) {
         let registry = cx.new(|_| LanguageModelRegistry::default());
 
         let provider = Arc::new(FakeLanguageModelProvider::default());
@@ -631,7 +660,7 @@ mod tests {
             let provider = registry.provider(&provider.id()).unwrap();
             let model = provider.default_model(cx).unwrap();
 
-            registry.set_environment_fallback_model(
+            registry.set_fallback_model(
                 Some(ConfiguredModel {
                     provider: provider.clone(),
                     model: model.clone(),
@@ -639,6 +668,10 @@ mod tests {
                 cx,
             );
 
+            assert!(registry.default_model().is_none());
+
+            registry.set_should_use_fallback(true);
+
             let default_model = registry.default_model().unwrap();
             assert_eq!(default_model.model.id(), model.id());
             assert_eq!(default_model.provider.id(), provider.id());
diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml
index c254989b4d5..f4a59a3e08d 100644
--- a/crates/language_model_core/Cargo.toml
+++ b/crates/language_model_core/Cargo.toml
@@ -26,3 +26,6 @@ serde.workspace = true
 serde_json.workspace = true
 strum.workspace = true
 thiserror.workspace = true
+
+[dev-dependencies]
+pretty_assertions.workspace = true
diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs
index 26ef0867db5..991046eab08 100644
--- a/crates/language_model_core/src/language_model_core.rs
+++ b/crates/language_model_core/src/language_model_core.rs
@@ -5,7 +5,7 @@ mod role;
 pub mod tool_schema;
 pub mod util;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use cloud_llm_client::CompletionRequestStatus;
 use http_client::{StatusCode, http};
 use schemars::JsonSchema;
@@ -351,13 +351,101 @@ pub struct LanguageModelToolUse {
     pub id: LanguageModelToolUseId,
     pub name: Arc,
     pub raw_input: String,
-    pub input: serde_json::Value,
+    pub input: LanguageModelToolUseInput,
     pub is_input_complete: bool,
     /// Thought signature the model sent us. Some models require that this
     /// signature be preserved and sent back in conversation history for validation.
     pub thought_signature: Option,
 }
 
+#[derive(Debug, PartialEq, Eq, Hash, Clone)]
+pub enum LanguageModelToolUseInput {
+    Json(serde_json::Value),
+    Text(String),
+}
+
+impl Serialize for LanguageModelToolUseInput {
+    fn serialize(&self, serializer: S) -> Result
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeStruct;
+
+        let mut state = serializer.serialize_struct("LanguageModelToolUseInput", 2)?;
+        match self {
+            Self::Json(input) => {
+                state.serialize_field("type", "json")?;
+                state.serialize_field("value", input)?;
+            }
+            Self::Text(input) => {
+                state.serialize_field("type", "text")?;
+                state.serialize_field("value", input)?;
+            }
+        }
+        state.end()
+    }
+}
+
+impl<'de> Deserialize<'de> for LanguageModelToolUseInput {
+    fn deserialize(deserializer: D) -> Result
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let value = serde_json::Value::deserialize(deserializer)?;
+        if let Some(object) = value.as_object()
+            && object.len() == 2
+            && let Some(input_type) = object.get("type").and_then(|value| value.as_str())
+            && let Some(input) = object.get("value")
+        {
+            return match input_type {
+                "json" => Ok(Self::Json(input.clone())),
+                "text" => input
+                    .as_str()
+                    .map(|input| Self::Text(input.to_string()))
+                    .ok_or_else(|| serde::de::Error::custom("text tool input must be a string")),
+                _ => Ok(Self::Json(value)),
+            };
+        }
+
+        Ok(Self::Json(value))
+    }
+}
+
+impl LanguageModelToolUseInput {
+    pub fn as_json(&self) -> Option<&serde_json::Value> {
+        match self {
+            Self::Json(input) => Some(input),
+            Self::Text(_) => None,
+        }
+    }
+
+    /// Typed parsing for JSON tool inputs; freeform (Text) inputs always error.
+    ///
+    /// Callers wanting the raw value should use [`Self::as_json`] or [`Self::into_json`].
+    pub fn parse(&self) -> Result {
+        match self {
+            Self::Json(input) => {
+                serde_json::from_value(input.clone()).context("failed to parse JSON tool input")
+            }
+            Self::Text(_) => Err(anyhow!("custom tool text input cannot be parsed as JSON")),
+        }
+    }
+
+    pub fn into_json(self) -> Result {
+        match self {
+            Self::Json(input) => Ok(input),
+            Self::Text(_) => Err(anyhow!("custom tool text input cannot be used as JSON")),
+        }
+    }
+
+    pub fn to_display_json(&self) -> serde_json::Value {
+        match self {
+            Self::Json(input) => input.clone(),
+            Self::Text(input) => serde_json::Value::String(input.clone()),
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct LanguageModelEffortLevel {
     pub name: SharedString,
@@ -624,7 +712,7 @@ mod tests {
             id: LanguageModelToolUseId::from("test_id"),
             name: "test_tool".into(),
             raw_input: json!({"arg": "value"}).to_string(),
-            input: json!({"arg": "value"}),
+            input: LanguageModelToolUseInput::Json(json!({"arg": "value"})),
             is_input_complete: true,
             thought_signature: Some("test_signature".to_string()),
         };
@@ -652,9 +740,97 @@ mod tests {
 
         assert_eq!(tool_use.id, LanguageModelToolUseId::from("test_id"));
         assert_eq!(tool_use.name.as_ref(), "test_tool");
+        assert_eq!(
+            tool_use.input,
+            LanguageModelToolUseInput::Json(json!({"arg": "value"}))
+        );
         assert_eq!(tool_use.thought_signature, None);
     }
 
+    #[test]
+    fn test_language_model_tool_use_input_round_trips_json() {
+        use serde_json::json;
+
+        let input = LanguageModelToolUseInput::Json(json!({"arg": "value"}));
+        let serialized = serde_json::to_value(&input).unwrap();
+        assert_eq!(
+            serialized,
+            json!({
+                "type": "json",
+                "value": {"arg": "value"}
+            })
+        );
+
+        let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap();
+        assert_eq!(deserialized, input);
+    }
+
+    #[test]
+    fn test_language_model_tool_use_input_round_trips_text() {
+        use serde_json::json;
+
+        let input = LanguageModelToolUseInput::Text("raw custom input".to_string());
+        let serialized = serde_json::to_value(&input).unwrap();
+        assert_eq!(
+            serialized,
+            json!({
+                "type": "text",
+                "value": "raw custom input"
+            })
+        );
+
+        let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap();
+        assert_eq!(deserialized, input);
+    }
+
+    #[test]
+    fn test_language_model_tool_use_input_parse() {
+        use serde_json::json;
+
+        #[derive(Debug, Deserialize, PartialEq)]
+        struct TestInput {
+            arg: String,
+        }
+
+        let parsed: TestInput = LanguageModelToolUseInput::Json(json!({"arg": "value"}))
+            .parse()
+            .unwrap();
+        assert_eq!(
+            parsed,
+            TestInput {
+                arg: "value".to_string()
+            }
+        );
+
+        let error = LanguageModelToolUseInput::Text("raw custom input".to_string())
+            .parse::()
+            .unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("custom tool text input cannot be parsed as JSON")
+        );
+    }
+
+    #[test]
+    fn test_language_model_tool_use_input_deserializes_legacy_plain_json_as_json() {
+        use serde_json::json;
+
+        let deserialized: LanguageModelToolUseInput =
+            serde_json::from_value(json!({"arg": "value"})).unwrap();
+        assert_eq!(
+            deserialized,
+            LanguageModelToolUseInput::Json(json!({"arg": "value"}))
+        );
+
+        let deserialized: LanguageModelToolUseInput =
+            serde_json::from_value(json!("legacy string argument")).unwrap();
+        assert_eq!(
+            deserialized,
+            LanguageModelToolUseInput::Json(json!("legacy string argument"))
+        );
+    }
+
     #[test]
     fn test_language_model_tool_use_round_trip_with_signature() {
         use serde_json::json;
@@ -663,7 +839,7 @@ mod tests {
             id: LanguageModelToolUseId::from("round_trip_id"),
             name: "round_trip_tool".into(),
             raw_input: json!({"key": "value"}).to_string(),
-            input: json!({"key": "value"}),
+            input: LanguageModelToolUseInput::Json(json!({"key": "value"})),
             is_input_complete: true,
             thought_signature: Some("round_trip_sig".to_string()),
         };
@@ -684,7 +860,7 @@ mod tests {
             id: LanguageModelToolUseId::from("no_sig_id"),
             name: "no_sig_tool".into(),
             raw_input: json!({"arg": "value"}).to_string(),
-            input: json!({"arg": "value"}),
+            input: LanguageModelToolUseInput::Json(json!({"arg": "value"})),
             is_input_complete: true,
             thought_signature: None,
         };
diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs
index 61696f4158a..a661874b0eb 100644
--- a/crates/language_model_core/src/request.rs
+++ b/crates/language_model_core/src/request.rs
@@ -3,7 +3,9 @@ use std::sync::Arc;
 use serde::{Deserialize, Serialize};
 
 use crate::role::Role;
-use crate::{LanguageModelToolUse, LanguageModelToolUseId, SharedString};
+use crate::{
+    LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, SharedString,
+};
 
 /// Dimensions of a `LanguageModelImage`
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -346,8 +348,52 @@ impl LanguageModelRequestMessage {
 pub struct LanguageModelRequestTool {
     pub name: String,
     pub description: String,
-    pub input_schema: serde_json::Value,
-    pub use_input_streaming: bool,
+    pub input: LanguageModelRequestToolInput,
+}
+
+impl LanguageModelRequestTool {
+    pub fn function(
+        name: String,
+        description: String,
+        input_schema: serde_json::Value,
+        use_input_streaming: bool,
+    ) -> Self {
+        Self {
+            name,
+            description,
+            input: LanguageModelRequestToolInput::Function {
+                input_schema,
+                use_input_streaming,
+            },
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
+pub enum LanguageModelRequestToolInput {
+    Function {
+        input_schema: serde_json::Value,
+        use_input_streaming: bool,
+    },
+    Custom {
+        format: Option,
+    },
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
+pub enum LanguageModelCustomToolFormat {
+    Text,
+    Grammar {
+        syntax: LanguageModelCustomToolGrammarSyntax,
+        definition: String,
+    },
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LanguageModelCustomToolGrammarSyntax {
+    Lark,
+    Regex,
 }
 
 #[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
@@ -389,6 +435,25 @@ pub struct LanguageModelRequest {
     pub compact_at_tokens: Option,
 }
 
+impl LanguageModelRequest {
+    pub fn contains_custom_tool_input(&self) -> bool {
+        self.tools
+            .iter()
+            .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. }))
+            || self.messages.iter().any(|message| {
+                message.content.iter().any(|content| {
+                    matches!(
+                        content,
+                        MessageContent::ToolUse(LanguageModelToolUse {
+                            input: LanguageModelToolUseInput::Text(_),
+                            ..
+                        })
+                    )
+                })
+            })
+    }
+}
+
 #[derive(
     Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema,
 )]
diff --git a/crates/language_model_core/src/tool_schema.rs b/crates/language_model_core/src/tool_schema.rs
index 13e6e665244..49c5d4bbe75 100644
--- a/crates/language_model_core/src/tool_schema.rs
+++ b/crates/language_model_core/src/tool_schema.rs
@@ -82,6 +82,8 @@ pub fn adapt_schema_to_format(
         obj.remove("description");
     }
 
+    resolve_refs(json)?;
+
     match format {
         LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
         LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
@@ -106,6 +108,98 @@ fn preprocess_json_schema(json: &mut Value) -> Result<()> {
     Ok(())
 }
 
+/// Inlines same-document `$ref`s from `$defs`/`definitions` and removes those.
+fn resolve_refs(json: &mut Value) -> Result<()> {
+    let Some(root_obj) = json.as_object_mut() else {
+        return Ok(());
+    };
+
+    let defs = root_obj.remove("$defs");
+    let legacy_defs = root_obj.remove("definitions");
+    if defs.is_none() && legacy_defs.is_none() {
+        return Ok(());
+    }
+
+    resolve_refs_recursive(json, defs.as_ref(), legacy_defs.as_ref(), &mut Vec::new())
+}
+
+fn resolve_refs_recursive(
+    value: &mut Value,
+    defs: Option<&Value>,
+    legacy_defs: Option<&Value>,
+    visiting: &mut Vec,
+) -> Result<()> {
+    match value {
+        Value::Object(obj) => {
+            if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) {
+                // Guard against cycles (A -> B -> A, or self-referential
+                // schemas like a Tree node whose children are Trees)
+                if visiting.iter().any(|v| v == ref_str) {
+                    *obj = Map::new();
+                    return Ok(());
+                }
+
+                let (defs_key, name) = parse_ref(ref_str)?;
+                let defs_for_key = match defs_key {
+                    "$defs" => defs,
+                    "definitions" => legacy_defs,
+                    _ => None,
+                };
+                let Some(def) = defs_for_key.and_then(|defs| defs.get(name)) else {
+                    anyhow::bail!("$ref target not found in {defs_key}: {ref_str}");
+                };
+
+                let ref_owned = ref_str.to_string();
+
+                // Inline the referenced definition into the current object.
+                let mut resolved = def.clone();
+                if let Value::Object(resolved_obj) = &mut resolved {
+                    for (key, val) in obj.iter() {
+                        if key != "$ref" {
+                            resolved_obj.insert(key.clone(), val.clone());
+                        }
+                    }
+                }
+                *value = resolved;
+
+                visiting.push(ref_owned);
+                let result = resolve_refs_recursive(value, defs, legacy_defs, visiting);
+                visiting.pop();
+                return result;
+            }
+
+            let keys: Vec = obj.keys().cloned().collect();
+            for key in keys {
+                if let Some(child) = obj.get_mut(&key) {
+                    resolve_refs_recursive(child, defs, legacy_defs, visiting)?;
+                }
+            }
+        }
+        Value::Array(arr) => {
+            for item in arr.iter_mut() {
+                resolve_refs_recursive(item, defs, legacy_defs, visiting)?;
+            }
+        }
+        _ => {}
+    }
+    Ok(())
+}
+
+/// Parses a same-document `$ref` like `#/$defs/Foo` or `#/definitions/Foo`.
+/// Returns `(defs_key, name)` where `defs_key` is the top-level key the
+/// definition was looked up under, and `name` is the definition name.
+fn parse_ref(ref_str: &str) -> Result<(&'static str, &str)> {
+    if let Some(name) = ref_str.strip_prefix("#/$defs/") {
+        return Ok(("$defs", name));
+    }
+    if let Some(name) = ref_str.strip_prefix("#/definitions/") {
+        return Ok(("definitions", name));
+    }
+    anyhow::bail!(
+        "Unsupported $ref format (only `#/$defs/` and `#/definitions/` are supported): {ref_str}"
+    );
+}
+
 fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
     if let Value::Object(obj) = json {
         const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"];
@@ -305,6 +399,7 @@ fn collapse_nullable_only_any_of(obj: &mut Map) {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use pretty_assertions::assert_eq;
     use serde_json::json;
 
     fn obj(value: Value) -> Map {
@@ -787,6 +882,307 @@ mod tests {
         assert!(adapt_to_json_schema_subset(&mut json).is_err());
     }
 
+    #[test]
+    fn test_refs_are_resolved_via_adapt_schema_to_format() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "parent": {
+                    "$ref": "#/$defs/pageParent"
+                },
+                "title": {
+                    "type": "string",
+                    "description": "Page title"
+                }
+            },
+            "required": ["parent"],
+            "$defs": {
+                "pageParent": {
+                    "type": "object",
+                    "properties": {
+                        "type": {
+                            "type": "string",
+                            "description": "Parent type"
+                        }
+                    },
+                    "required": ["type"]
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
+
+        let expected = json!({
+            "type": "object",
+            "properties": {
+                "parent": {
+                    "type": "object",
+                    "properties": {
+                        "type": {
+                            "type": "string",
+                            "description": "Parent type"
+                        }
+                    },
+                    "required": ["type"]
+                },
+                "title": {
+                    "type": "string",
+                    "description": "Page title"
+                }
+            },
+            "required": ["parent"],
+        });
+        assert_eq!(json, expected);
+    }
+
+    #[test]
+    fn test_refs_fail_for_unsupported_prefix() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "child": {
+                    "$ref": "https://example.com/schema.json#/User"
+                }
+            },
+            "$defs": {
+                "User": { "type": "string" }
+            }
+        });
+
+        assert!(
+            adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
+                .is_err()
+        );
+    }
+
+    #[test]
+    fn test_refs_fail_for_missing_definition() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "child": {
+                    "$ref": "#/$defs/NonExistent"
+                }
+            },
+            "$defs": {
+                "Existing": { "type": "string" }
+            }
+        });
+
+        assert!(
+            adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
+                .is_err()
+        );
+    }
+
+    #[test]
+    fn test_refs_in_defs_are_resolved() {
+        // A definition that itself references another definition.
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "parent": {
+                    "$ref": "#/$defs/pageParent"
+                }
+            },
+            "$defs": {
+                "pageParent": {
+                    "type": "object",
+                    "properties": {
+                        "database_id": {
+                            "$ref": "#/$defs/databaseId"
+                        }
+                    }
+                },
+                "databaseId": {
+                    "type": "string",
+                    "description": "A database ID"
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
+
+        // The nested $ref in pageParent -> databaseId should be resolved.
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "parent": {
+                        "type": "object",
+                        "properties": {
+                            "database_id": {
+                                "type": "string",
+                                "description": "A database ID"
+                            }
+                        }
+                    }
+                }
+            })
+        );
+    }
+
+    #[test]
+    fn test_refs_resolve_when_both_defs_and_definitions_exist() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "modern": {
+                    "$ref": "#/$defs/Modern"
+                },
+                "legacy": {
+                    "$ref": "#/definitions/Legacy"
+                }
+            },
+            "$defs": {
+                "Modern": {
+                    "type": "string"
+                }
+            },
+            "definitions": {
+                "Legacy": {
+                    "type": "number"
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
+
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "modern": {
+                        "type": "string"
+                    },
+                    "legacy": {
+                        "type": "number"
+                    }
+                }
+            })
+        );
+    }
+
+    #[test]
+    fn test_refs_in_array_items_are_resolved() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "items": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/$defs/itemDef"
+                    }
+                }
+            },
+            "$defs": {
+                "itemDef": {
+                    "type": "string",
+                    "description": "An item"
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
+
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "items": {
+                        "type": "array",
+                        "items": {
+                            "type": "string",
+                            "description": "An item"
+                        }
+                    }
+                }
+            })
+        );
+    }
+
+    #[test]
+    fn test_self_referential_ref_is_replaced_with_empty_schema() {
+        // A common pattern: a Tree node with children of the same type.
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "root": { "$ref": "#/$defs/Tree" }
+            },
+            "$defs": {
+                "Tree": {
+                    "type": "object",
+                    "properties": {
+                        "value": { "type": "string" },
+                        "children": {
+                            "type": "array",
+                            "items": { "$ref": "#/$defs/Tree" }
+                        }
+                    }
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
+            .expect("self-referential $ref should not error");
+
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "root": {
+                        "type": "object",
+                        "properties": {
+                            "value": { "type": "string" },
+                            "children": {
+                                "type": "array",
+                                "items": {}
+                            }
+                        }
+                    }
+                }
+            })
+        );
+    }
+
+    #[test]
+    fn test_ref_sibling_properties_are_preserved() {
+        // JSON Schema draft 2019-09+ allows sibling properties alongside
+        // `$ref`. They must be merged into the resolved definition rather than
+        // discarded, with siblings overriding the definition's keys.
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "child": {
+                    "$ref": "#/$defs/childDef",
+                    "description": "Local description overrides def"
+                }
+            },
+            "$defs": {
+                "childDef": {
+                    "type": "string",
+                    "description": "Def description",
+                    "minLength": 1
+                }
+            }
+        });
+
+        adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
+
+        assert_eq!(
+            json["properties"]["child"],
+            json!({
+                "type": "string",
+                "description": "Local description overrides def",
+                "minLength": 1
+            })
+        );
+    }
+
     #[test]
     fn test_preprocess_json_schema_adds_additional_properties() {
         let mut json = json!({
diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml
index 57a0d530e45..8da9eb332ed 100644
--- a/crates/language_models/Cargo.toml
+++ b/crates/language_models/Cargo.toml
@@ -18,6 +18,7 @@ anthropic = { workspace = true, features = ["schemars"] }
 anyhow.workspace = true
 aws-config = { workspace = true, features = ["behavior-version-latest"] }
 aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] }
+aws-sigv4.workspace = true
 aws_http_client.workspace = true
 base64.workspace = true
 bedrock = { workspace = true, features = ["schemars"] }
diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs
index a9ec2b028ff..ce968e3d611 100644
--- a/crates/language_models/src/language_models.rs
+++ b/crates/language_models/src/language_models.rs
@@ -5,9 +5,7 @@ use client::{Client, UserStore};
 use collections::{HashMap, HashSet};
 use credentials_provider::CredentialsProvider;
 use gpui::{App, Context, Entity};
-use language_model::{
-    ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
-};
+use language_model::{LanguageModelProviderId, LanguageModelRegistry};
 use provider::deepseek::DeepSeekLanguageModelProvider;
 
 pub mod extension;
@@ -140,50 +138,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) {
     .detach();
 }
 
-/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback
-/// model based on currently authenticated providers.
-///
-/// Prefers the Zed cloud provider so that, once the user is signed in, we
-/// always pick a Zed-hosted model over models from other authenticated
-/// providers in the environment. If the Zed cloud provider is authenticated
-/// but hasn't finished loading its models yet, we don't fall back to another
-/// provider to avoid flickering between providers during sign in.
-pub fn update_environment_fallback_model(cx: &mut App) {
-    let registry = LanguageModelRegistry::global(cx);
-    let fallback_model = {
-        let registry = registry.read(cx);
-        let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID);
-        if cloud_provider
-            .as_ref()
-            .is_some_and(|provider| provider.is_authenticated(cx))
-        {
-            cloud_provider.and_then(|provider| {
-                let model = provider
-                    .default_model(cx)
-                    .or_else(|| provider.recommended_models(cx).first().cloned())?;
-                Some(ConfiguredModel { provider, model })
-            })
-        } else {
-            registry
-                .providers()
-                .iter()
-                .filter(|provider| provider.is_authenticated(cx))
-                .find_map(|provider| {
-                    let model = provider
-                        .default_model(cx)
-                        .or_else(|| provider.recommended_models(cx).first().cloned())?;
-                    Some(ConfiguredModel {
-                        provider: provider.clone(),
-                        model,
-                    })
-                })
-        }
-    };
-    registry.update(cx, |registry, cx| {
-        registry.set_environment_fallback_model(fallback_model, cx);
-    });
-}
-
 #[derive(Default, PartialEq, Eq)]
 struct CompatibleProviders(HashMap, CompatibleProviderKind>);
 
diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs
index 1841a9619d2..8a055257706 100644
--- a/crates/language_models/src/provider/anthropic.rs
+++ b/crates/language_models/src/provider/anthropic.rs
@@ -5,21 +5,19 @@ use anyhow::Result;
 use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyConfiguration, ApiKeyState,
-    AuthenticateError, ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg,
-    LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
-    LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
-    env_var,
+    AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
+    ProviderSettingsView, RateLimiter, env_var,
 };
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 pub use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic};
 pub use settings::AnthropicAvailableModel as AvailableModel;
@@ -275,34 +273,19 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        target_agent: ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://console.anthropic.com/settings/keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://console.anthropic.com/settings/keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 
     fn fast_mode_confirmation(&self, _cx: &App) -> Option {
@@ -350,6 +333,17 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::
         AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking
     );
     let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking);
+    let supports_speed = available
+        .supports_fast_mode
+        .unwrap_or_else(|| anthropic::supports_fast_mode(&available.name));
+    let mut extra_beta_headers = available.extra_beta_headers.clone();
+    if supports_speed
+        && !extra_beta_headers
+            .iter()
+            .any(|header| header.trim() == anthropic::FAST_MODE_BETA_HEADER)
+    {
+        extra_beta_headers.push(anthropic::FAST_MODE_BETA_HEADER.to_string());
+    }
 
     anthropic::Model {
         display_name: available
@@ -364,7 +358,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::
         supports_thinking,
         supports_adaptive_thinking,
         supports_images: true,
-        supports_speed: false,
+        supports_speed,
         supports_compaction: false,
         supported_effort_levels: if supports_adaptive_thinking {
             vec![
@@ -378,7 +372,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::
             vec![]
         },
         tool_override: available.tool_override.clone(),
-        extra_beta_headers: available.extra_beta_headers.clone(),
+        extra_beta_headers,
     }
 }
 
@@ -545,14 +539,17 @@ impl LanguageModel for AnthropicModel {
     > {
         let has_tools = !request.tools.is_empty();
         let request_id = self.model.request_id(has_tools).to_string();
-        let mut request = into_anthropic(
+        let mut request = match into_anthropic(
             request,
             request_id,
             self.model.default_temperature,
             self.model.max_output_tokens,
             self.model.mode.clone(),
             AnthropicPromptCacheMode::Automatic,
-        );
+        ) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         if !self.model.supports_speed {
             request.speed = None;
         }
@@ -564,144 +561,3 @@ impl LanguageModel for AnthropicModel {
         async move { Ok(future.await?.boxed()) }.boxed()
     }
 }
-
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-    target_agent: ConfigurationViewTargetAgent,
-}
-
-impl ConfigurationView {
-    const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
-
-    fn new(
-        state: Entity,
-        target_agent: ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> Self {
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn({
-            let state = state.clone();
-            async move |this, cx| {
-                let task = state.update(cx, |state, cx| state.authenticate(cx));
-                // We don't log an error, because "not signed in" is also an error.
-                let _ = task.await;
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor: cx.new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_TEXT)),
-            state,
-            load_credentials_task,
-            target_agent,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx);
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = AnthropicLanguageModelProvider::api_url(cx);
-            if api_url == ANTHROPIC_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div()
-                .child(Label::new("Loading credentials..."))
-                .into_any_element()
-        } else if self.should_render_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
-                    ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(),
-                    ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
-                })))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create one by visiting"))
-                                .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys"))
-                        )
-                        .child(
-                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
-                        )
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(
-                        format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
-                    )
-                    .size(LabelSize::Small)
-                    .color(Color::Muted)
-                    .mt_0p5(),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("anthropic-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!(
-                    "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."
-                ))
-                })
-                .into_any_element()
-        }
-    }
-}
diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs
index b0790045324..633494e3a78 100644
--- a/crates/language_models/src/provider/anthropic_compatible.rs
+++ b/crates/language_models/src/provider/anthropic_compatible.rs
@@ -3,13 +3,14 @@ use anthropic::{AnthropicError, AnthropicModelMode};
 use anyhow::Result;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window};
+use gpui::{App, AppContext, AsyncApp, Entity, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
+    LanguageModelRequest, LanguageModelToolChoice, ProviderSettingsView, RateLimiter,
+    SubPageProviderSettings,
 };
 use settings::Settings;
 use std::sync::Arc;
@@ -181,32 +182,27 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| {
-            ApiCompatibleProviderConfigurationView::new(
-                self.state.clone(),
-                "Anthropic",
-                API_KEY_PLACEHOLDER,
-                window,
-                cx,
-            )
-        })
-        .into()
+    fn settings_view(&self, _cx: &mut App) -> Option {
+        let state = self.state.clone();
+        Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new(
+            move |window, cx| {
+                cx.new(|cx| {
+                    ApiCompatibleProviderConfigurationView::new(
+                        state.clone(),
+                        "Anthropic",
+                        API_KEY_PLACEHOLDER,
+                        window,
+                        cx,
+                    )
+                })
+                .into()
+            },
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -337,14 +333,17 @@ impl LanguageModel for AnthropicCompatibleLanguageModel {
     > {
         let has_tools = !request.tools.is_empty();
         let request_id = self.model.request_id(has_tools).to_string();
-        let mut request = into_anthropic(
+        let mut request = match into_anthropic(
             request,
             request_id,
             self.model.default_temperature,
             self.model.max_output_tokens,
             self.model.mode.clone(),
             self.cache_mode,
-        );
+        ) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         if !self.model.supports_speed {
             request.speed = None;
         }
diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs
index 4d843009f76..f0902dd9cbc 100644
--- a/crates/language_models/src/provider/bedrock.rs
+++ b/crates/language_models/src/provider/bedrock.rs
@@ -5,8 +5,11 @@ use anyhow::{Context as _, Result, anyhow};
 use async_lock::OnceCell;
 use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
 use aws_config::{BehaviorVersion, Region};
+use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
 use aws_credential_types::{Credentials, Token};
 use aws_http_client::AwsHttpClient;
+use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
+use aws_sigv4::sign::v4;
 use bedrock::BedrockSystemContentBlock;
 use bedrock::bedrock_client::Client as BedrockClient;
 use bedrock::bedrock_client::config::timeout::TimeoutConfig;
@@ -20,38 +23,53 @@ use bedrock::{
     BedrockStreamingResponse, BedrockThinkingBlock, BedrockThinkingTextBlock, BedrockTool,
     BedrockToolChoice, BedrockToolConfig, BedrockToolInputSchema, BedrockToolResultBlock,
     BedrockToolResultContentBlock, BedrockToolResultStatus, BedrockToolSpec, BedrockToolUseBlock,
-    Model, value_to_aws_document,
+    ConverseModel, MantleModel, MantleProtocol, value_to_aws_document,
 };
 use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
-use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
+use futures::{
+    AsyncBufReadExt, AsyncReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, io::BufReader,
+    stream::BoxStream,
+};
 use gpui::{
-    AnyView, App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window,
-    actions,
+    App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, actions,
 };
 use gpui_tokio::Tokio;
-use http_client::HttpClient;
+use http_client::{
+    AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt,
+    http::{HeaderValue, header::AUTHORIZATION},
+};
 use language_model::{
     AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
-    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
-    TokenUsage, env_var,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
+    LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role,
+    SubPageProviderSettings, TokenUsage, env_var,
 };
+use open_ai::responses::Request as OpenAiResponseRequest;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
-use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore};
+use settings::{
+    BedrockAvailableModel as AvailableModel, BedrockMantleAvailableModel as MantleAvailableModel,
+    Settings, SettingsStore,
+};
 use std::sync::LazyLock;
+use std::time::SystemTime;
 use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
 use ui::{ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
 
 use crate::AllLanguageModelSettings;
-use http_client::CustomHeaders;
+use crate::provider::open_ai::{
+    ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
+    into_open_ai_response,
+};
 use language_model::util::{fix_streamed_json, parse_tool_arguments};
+use open_ai::{ReasoningEffort, RequestError, ResponseStreamEvent};
 
 actions!(bedrock, [Tab, TabPrev]);
 
@@ -117,6 +135,7 @@ impl BedrockCredentials {
 #[derive(Default, Clone, Debug, PartialEq)]
 pub struct AmazonBedrockSettings {
     pub available_models: Vec,
+    pub mantle_available_models: Vec,
     pub custom_headers: CustomHeaders,
     pub region: Option,
     pub endpoint: Option,
@@ -152,6 +171,13 @@ impl From for BedrockAuthMethod {
     }
 }
 
+fn mantle_protocol_from_settings(value: settings::BedrockMantleProtocolContent) -> MantleProtocol {
+    match value {
+        settings::BedrockMantleProtocolContent::ChatCompletions => MantleProtocol::ChatCompletions,
+        settings::BedrockMantleProtocolContent::Responses => MantleProtocol::Responses,
+    }
+}
+
 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[serde(tag = "type", rename_all = "lowercase")]
 pub enum ModelMode {
@@ -201,6 +227,115 @@ static ZED_BEDROCK_REGION_VAR: LazyLock = env_var!("ZED_AWS_REGION");
 static ZED_AWS_ENDPOINT_VAR: LazyLock = env_var!("ZED_AWS_ENDPOINT");
 static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock = env_var!("ZED_BEDROCK_BEARER_TOKEN");
 
+/// AWS Regions where the `bedrock-mantle` endpoint is available.
+/// See .
+const MANTLE_SUPPORTED_REGIONS: &[&str] = &[
+    "us-east-2",
+    "us-east-1",
+    "us-west-2",
+    "ap-southeast-3",
+    "ap-south-1",
+    "ap-southeast-2",
+    "ap-northeast-1",
+    "eu-central-1",
+    "eu-west-1",
+    "eu-west-2",
+    "eu-south-1",
+    "eu-north-1",
+    "sa-east-1",
+    "us-gov-west-1",
+];
+
+fn mantle_endpoint_url(region: &str) -> String {
+    format!("https://bedrock-mantle.{region}.api.aws/openai/v1")
+}
+
+enum MantleAuth {
+    ApiKey { api_key: String },
+    SigV4 { credentials: Credentials },
+}
+
+impl MantleAuth {
+    fn apply(&self, request: &mut HttpRequest, body: &[u8], region: &str) -> Result<()> {
+        match self {
+            MantleAuth::ApiKey { api_key } => {
+                let value = HeaderValue::from_str(&format!("Bearer {}", api_key.trim()))
+                    .context("building Mantle bearer token authorization header")?;
+                request.headers_mut().insert(AUTHORIZATION, value);
+            }
+            MantleAuth::SigV4 { credentials } => {
+                sign_mantle_request_sigv4(request, body, credentials, region)?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+fn sign_mantle_request_sigv4(
+    request: &mut HttpRequest,
+    body: &[u8],
+    credentials: &Credentials,
+    region: &str,
+) -> Result<()> {
+    sign_mantle_request_sigv4_at(request, body, credentials, region, SystemTime::now())
+}
+
+fn sign_mantle_request_sigv4_at(
+    request: &mut HttpRequest,
+    body: &[u8],
+    credentials: &Credentials,
+    region: &str,
+    time: SystemTime,
+) -> Result<()> {
+    if !request
+        .headers()
+        .contains_key(http_client::http::header::HOST)
+        && let Some(authority) = request.uri().authority()
+    {
+        let host = HeaderValue::from_str(authority.as_str())
+            .context("invalid host header derived from Mantle request URI")?;
+        request
+            .headers_mut()
+            .insert(http_client::http::header::HOST, host);
+    }
+
+    let identity = credentials.clone().into();
+    let signing_params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
+        .identity(&identity)
+        .region(region)
+        .name("bedrock-mantle")
+        .time(time)
+        .settings(SigningSettings::default())
+        .build()
+        .context("building Mantle SigV4 signing params")?
+        .into();
+
+    let method = request.method().as_str();
+    let uri = request.uri().to_string();
+    let headers = request
+        .headers()
+        .iter()
+        .map(|(name, value)| {
+            value
+                .to_str()
+                .map(|value| (name.as_str(), value))
+                .with_context(|| format!("header {name} is not valid UTF-8 and cannot be signed"))
+        })
+        .collect::>>()?;
+
+    let signable_request =
+        SignableRequest::new(method, uri, headers.into_iter(), SignableBody::Bytes(body))
+            .context("constructing Mantle SigV4 request")?;
+
+    let (instructions, _signature) = sign(signable_request, &signing_params)
+        .context("signing Mantle request with SigV4")?
+        .into_parts();
+    instructions.apply_to_request_http1x(request);
+
+    Ok(())
+}
+
 pub struct State {
     /// The resolved authentication method. Settings take priority over UX credentials.
     auth: Option,
@@ -408,6 +543,7 @@ impl State {
 
 pub struct BedrockLanguageModelProvider {
     http_client: AwsHttpClient,
+    plain_http_client: Arc,
     handle: tokio::runtime::Handle,
     state: Entity,
 }
@@ -429,13 +565,14 @@ impl BedrockLanguageModelProvider {
         });
 
         Self {
-            http_client: AwsHttpClient::new(http_client),
+            http_client: AwsHttpClient::new(http_client.clone()),
+            plain_http_client: http_client,
             handle: Tokio::handle(cx),
             state,
         }
     }
 
-    fn create_language_model(&self, model: bedrock::Model) -> Arc {
+    fn create_language_model(&self, model: bedrock::ConverseModel) -> Arc {
         Arc::new(BedrockModel {
             id: LanguageModelId::from(model.id().to_string()),
             model,
@@ -446,6 +583,17 @@ impl BedrockLanguageModelProvider {
             request_limiter: RateLimiter::new(4),
         })
     }
+
+    fn create_mantle_language_model(&self, model: bedrock::MantleModel) -> Arc {
+        Arc::new(BedrockMantleModel {
+            id: LanguageModelId::from(model.id().to_string()),
+            model,
+            http_client: self.plain_http_client.clone(),
+            state: self.state.clone(),
+            credentials_provider: Arc::new(OnceCell::new()),
+            request_limiter: RateLimiter::new(4),
+        })
+    }
 }
 
 impl LanguageModelProvider for BedrockLanguageModelProvider {
@@ -461,39 +609,30 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
         IconOrSvg::Icon(IconName::AiBedrock)
     }
 
-    fn inline_description(&self, _cx: &App) -> Option {
-        Some(InlineDescription::Text(
-            "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials.".into(),
-        ))
-    }
-
     fn default_model(&self, _cx: &App) -> Option> {
-        Some(self.create_language_model(bedrock::Model::default()))
+        Some(self.create_language_model(bedrock::ConverseModel::default()))
     }
 
     fn default_fast_model(&self, cx: &App) -> Option> {
         let region = self.state.read(cx).get_region();
-        Some(self.create_language_model(bedrock::Model::default_fast(region.as_str())))
+        Some(self.create_language_model(bedrock::ConverseModel::default_fast(region.as_str())))
     }
 
     fn provided_models(&self, cx: &App) -> Vec> {
+        let bedrock_settings = &AllLanguageModelSettings::get_global(cx).bedrock;
         let mut models = BTreeMap::default();
 
-        for model in bedrock::Model::iter() {
-            if !matches!(model, bedrock::Model::Custom { .. }) {
+        for model in bedrock::ConverseModel::iter() {
+            if !matches!(model, bedrock::ConverseModel::Custom { .. }) {
                 models.insert(model.id().to_string(), model);
             }
         }
 
         // Override with available models from settings
-        for model in AllLanguageModelSettings::get_global(cx)
-            .bedrock
-            .available_models
-            .iter()
-        {
+        for model in bedrock_settings.available_models.iter() {
             models.insert(
                 model.name.clone(),
-                bedrock::Model::Custom {
+                bedrock::ConverseModel::Custom {
                     name: model.name.clone(),
                     display_name: model.display_name.clone(),
                     max_tokens: model.max_tokens,
@@ -509,10 +648,43 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
             );
         }
 
-        models
+        let mut models: Vec> = models
             .into_values()
             .map(|model| self.create_language_model(model))
-            .collect()
+            .collect();
+
+        let mut mantle_models = BTreeMap::default();
+
+        for model in bedrock::MantleModel::iter() {
+            if !matches!(model, bedrock::MantleModel::Custom { .. }) {
+                mantle_models.insert(model.id().to_string(), model);
+            }
+        }
+
+        // Override with available Mantle models from settings
+        for model in bedrock_settings.mantle_available_models.iter() {
+            mantle_models.insert(
+                model.name.clone(),
+                bedrock::MantleModel::Custom {
+                    name: model.name.clone(),
+                    display_name: model.display_name.clone(),
+                    max_tokens: model.max_tokens,
+                    max_output_tokens: model.max_output_tokens,
+                    protocol: mantle_protocol_from_settings(model.protocol),
+                    supports_tools: model.supports_tools.unwrap_or(false),
+                    supports_images: model.supports_images.unwrap_or(false),
+                    supports_thinking: model.supports_thinking.unwrap_or(false),
+                },
+            );
+        }
+
+        models.extend(
+            mantle_models
+                .into_values()
+                .map(|model| self.create_mantle_language_model(model)),
+        );
+
+        models
     }
 
     fn is_authenticated(&self, cx: &App) -> bool {
@@ -523,18 +695,17 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state.update(cx, |state, cx| state.reset_auth(cx))
+    fn settings_view(&self, _cx: &mut App) -> Option {
+        let state = self.state.clone();
+        Some(ProviderSettingsView::SubPage(
+            SubPageProviderSettings::new(move |window, cx| {
+                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
+                    .into()
+            })
+            .description(InlineDescription::Text(
+                "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials. Mantle-only models (e.g. GPT-5.5, GPT-5.4, Grok 4.3) additionally require IAM permissions for the `bedrock-mantle` endpoint.".into(),
+            )),
+        ))
     }
 }
 
@@ -548,7 +719,7 @@ impl LanguageModelProviderState for BedrockLanguageModelProvider {
 
 struct BedrockModel {
     id: LanguageModelId,
-    model: Model,
+    model: ConverseModel,
     http_client: AwsHttpClient,
     handle: tokio::runtime::Handle,
     client: OnceCell,
@@ -678,6 +849,18 @@ impl LanguageModel for BedrockModel {
         self.model.supports_thinking()
     }
 
+    fn refusal_fallback_model_id(&self) -> Option<&'static str> {
+        if self
+            .model
+            .id()
+            .starts_with(anthropic::FABLE_MODEL_ID_PREFIX)
+        {
+            Some(anthropic::FABLE_FALLBACK_MODEL_ID)
+        } else {
+            None
+        }
+    }
+
     fn supported_effort_levels(&self) -> Vec {
         if self.model.supports_adaptive_thinking() {
             vec![
@@ -754,6 +937,13 @@ impl LanguageModel for BedrockModel {
             LanguageModelCompletionError,
         >,
     > {
+        if request.contains_custom_tool_input() {
+            return async move {
+                Err(anyhow::anyhow!("Bedrock does not support custom tools").into())
+            }
+            .boxed();
+        }
+
         let (region, allow_global, guardrail_identifier, guardrail_version) =
             cx.read_entity(&self.state, |state, _cx| {
                 let (gid, gv) = state.get_guardrail_config();
@@ -836,6 +1026,488 @@ impl LanguageModel for BedrockModel {
     }
 }
 
+const MANTLE_SELECTABLE_REASONING_EFFORTS: &[ReasoningEffort] = &[
+    ReasoningEffort::Low,
+    ReasoningEffort::Medium,
+    ReasoningEffort::High,
+    ReasoningEffort::XHigh,
+];
+
+fn mantle_default_reasoning_effort(model: &MantleModel) -> Option {
+    model.supports_thinking().then_some(ReasoningEffort::Medium)
+}
+
+fn mantle_selected_reasoning_effort(
+    request: &LanguageModelRequest,
+    model: &MantleModel,
+) -> Option {
+    if !model.supports_thinking() {
+        return None;
+    }
+
+    if request.thinking_allowed {
+        request
+            .thinking_effort
+            .as_deref()
+            .and_then(|effort| effort.parse::().ok())
+            .filter(|effort| *effort != ReasoningEffort::None)
+            .or_else(|| mantle_default_reasoning_effort(model))
+    } else {
+        Some(ReasoningEffort::None)
+    }
+}
+
+fn mantle_supported_effort_levels(model: &MantleModel) -> Vec {
+    let Some(default_effort) = mantle_default_reasoning_effort(model) else {
+        return Vec::new();
+    };
+
+    MANTLE_SELECTABLE_REASONING_EFFORTS
+        .iter()
+        .copied()
+        .map(|effort| LanguageModelEffortLevel {
+            name: effort.label().into(),
+            value: effort.value().into(),
+            is_default: effort == default_effort,
+        })
+        .collect()
+}
+
+/// Special-cases Mantle authorization failures with a message that points at
+/// the separate `bedrock-mantle` IAM policy namespace instead of regular
+/// `bedrock-runtime` permissions.
+fn map_mantle_error(model: &MantleModel, error: RequestError) -> LanguageModelCompletionError {
+    if let RequestError::HttpResponseError { status_code, .. } = &error
+        && *status_code == http_client::http::StatusCode::FORBIDDEN
+    {
+        return LanguageModelCompletionError::PermissionError {
+            provider: PROVIDER_NAME,
+            message: format!(
+                "Bedrock Mantle denied this request for {}. Mantle-only models require IAM \
+                 permissions for the `bedrock-mantle` endpoint (for example via the \
+                 `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever \
+                 permissions your existing Bedrock credentials already have.",
+                model.display_name()
+            ),
+        };
+    }
+    error.into()
+}
+
+/// Resolves an AWS credentials provider for profile/SSO/automatic auth.
+/// Cached in `cell` since building it may read config files from disk;
+/// credentials themselves are still re-resolved on every call. Async so this
+/// never blocks the foreground thread (unlike `BedrockModel::get_or_init_client`).
+async fn resolve_mantle_credentials_provider(
+    cell: &OnceCell,
+    profile_name: Option,
+    region: String,
+) -> Result {
+    let provider = cell
+        .get_or_try_init(move || async move {
+            let mut config_builder =
+                aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region));
+
+            if let Some(profile_name) = profile_name.filter(|name| !name.is_empty()) {
+                config_builder = config_builder.profile_name(profile_name);
+            }
+
+            let config = config_builder.load().await;
+            config
+                .credentials_provider()
+                .context("no AWS credentials provider is configured")
+        })
+        .await
+        .context("resolving AWS credentials for Bedrock Mantle")?;
+    Ok(provider.clone())
+}
+
+/// Resolves provider settings into concrete Mantle request auth. A configured
+/// Bedrock API key is sent as bearer auth; every AWS-credential-based method
+/// signs the Mantle HTTP request directly with SigV4.
+async fn resolve_mantle_auth(
+    credentials_provider: Arc>,
+    auth: Option,
+    region: String,
+) -> Result {
+    match auth {
+        Some(BedrockAuth::ApiKey { api_key }) => Ok(MantleAuth::ApiKey { api_key }),
+        Some(BedrockAuth::IamCredentials {
+            access_key_id,
+            secret_access_key,
+            session_token,
+        }) => Ok(MantleAuth::SigV4 {
+            credentials: Credentials::new(
+                access_key_id,
+                secret_access_key,
+                session_token,
+                None,
+                "zed-bedrock-provider",
+            ),
+        }),
+        Some(BedrockAuth::NamedProfile { profile_name })
+        | Some(BedrockAuth::SingleSignOn { profile_name }) => {
+            let provider = resolve_mantle_credentials_provider(
+                &credentials_provider,
+                Some(profile_name),
+                region.clone(),
+            )
+            .await?;
+            let credentials = provider
+                .provide_credentials()
+                .await
+                .context("failed to resolve AWS credentials")?;
+            Ok(MantleAuth::SigV4 { credentials })
+        }
+        Some(BedrockAuth::Automatic) | None => {
+            let provider =
+                resolve_mantle_credentials_provider(&credentials_provider, None, region.clone())
+                    .await?;
+            let credentials = provider
+                .provide_credentials()
+                .await
+                .context("failed to resolve AWS credentials")?;
+            Ok(MantleAuth::SigV4 { credentials })
+        }
+    }
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum MantleChatStreamResult {
+    Ok(ResponseStreamEvent),
+    Err { error: MantleChatStreamError },
+}
+
+#[derive(Deserialize)]
+struct MantleChatStreamError {
+    message: String,
+}
+
+fn parse_mantle_chat_stream_line(line: &str) -> Result {
+    match serde_json::from_str(line) {
+        Ok(MantleChatStreamResult::Ok(response)) => Ok(response),
+        Ok(MantleChatStreamResult::Err { error }) => Err(anyhow!(error.message)),
+        Err(error) => {
+            log::error!(
+                "Failed to parse Mantle chat completion stream event: `{}`\nResponse: `{}`",
+                error,
+                line,
+            );
+            Err(anyhow!(error))
+        }
+    }
+}
+
+fn parse_mantle_response_stream_line(line: &str) -> Result {
+    serde_json::from_str(line).map_err(|error| {
+        log::error!(
+            "Failed to parse Mantle responses stream event: `{}`\nResponse: `{}`",
+            error,
+            line,
+        );
+        anyhow!(error)
+    })
+}
+
+async fn stream_mantle_sse(
+    client: &dyn HttpClient,
+    provider_name: &str,
+    url: &str,
+    region: &str,
+    auth: &MantleAuth,
+    request: Request,
+    extra_headers: &CustomHeaders,
+    parse_stream_line: fn(&str) -> Result,
+) -> std::result::Result>, RequestError>
+where
+    Request: Serialize,
+    Event: Send + 'static,
+{
+    let body = serde_json::to_vec(&request).map_err(|error| RequestError::Other(error.into()))?;
+    let mut request = HttpRequest::builder()
+        .method(Method::POST)
+        .uri(url)
+        .header("Content-Type", "application/json")
+        .extra_headers(extra_headers)
+        .body(AsyncBody::from(body.clone()))
+        .map_err(|error| RequestError::Other(error.into()))?;
+
+    auth.apply(&mut request, &body, region)
+        .map_err(RequestError::Other)?;
+
+    let mut response = client.send(request).await?;
+    if response.status().is_success() {
+        let reader = BufReader::new(response.into_body());
+        Ok(reader
+            .lines()
+            .filter_map(move |line| async move {
+                match line {
+                    Ok(line) => {
+                        let line = line
+                            .strip_prefix("data: ")
+                            .or_else(|| line.strip_prefix("data:"))?;
+                        if line == "[DONE]" || line.is_empty() {
+                            None
+                        } else {
+                            Some(parse_stream_line(line))
+                        }
+                    }
+                    Err(error) => Some(Err(anyhow!(error))),
+                }
+            })
+            .boxed())
+    } else {
+        let mut body = String::new();
+        response
+            .body_mut()
+            .read_to_string(&mut body)
+            .await
+            .map_err(|error| RequestError::Other(error.into()))?;
+
+        Err(RequestError::HttpResponseError {
+            provider: provider_name.to_owned(),
+            status_code: response.status(),
+            body,
+            headers: response.headers().clone(),
+        })
+    }
+}
+
+fn strip_unsupported_mantle_response_fields(request: &mut OpenAiResponseRequest) {
+    request.context_management = None;
+}
+
+struct BedrockMantleModel {
+    id: LanguageModelId,
+    model: MantleModel,
+    http_client: Arc,
+    state: Entity,
+    credentials_provider: Arc>,
+    request_limiter: RateLimiter,
+}
+
+impl BedrockMantleModel {
+    fn stream_mantle_request(
+        &self,
+        request: Request,
+        cx: &AsyncApp,
+        endpoint: &'static str,
+        parse_stream_line: fn(&str) -> Result,
+    ) -> BoxFuture<'static, Result>, LanguageModelCompletionError>>
+    where
+        Request: Serialize + Send + 'static,
+        Event: Send + 'static,
+    {
+        let http_client = self.http_client.clone();
+        let model = self.model.clone();
+        let credentials_provider = self.credentials_provider.clone();
+        let (auth, region) = cx.read_entity(&self.state, |state, _cx| {
+            (state.auth.clone(), state.get_region())
+        });
+        let url = format!("{}/{}", mantle_endpoint_url(®ion), endpoint);
+        let extra_headers = cx.read_entity(&self.state, |_, cx| {
+            AllLanguageModelSettings::get_global(cx)
+                .bedrock
+                .custom_headers
+                .clone()
+        });
+        let provider_name = PROVIDER_NAME.0.to_string();
+        let auth_task = Tokio::spawn_result(
+            cx,
+            resolve_mantle_auth(credentials_provider, auth, region.clone()),
+        );
+
+        let future = self.request_limiter.stream(async move {
+            let auth = auth_task
+                .await
+                .map_err(LanguageModelCompletionError::Other)?;
+            stream_mantle_sse(
+                http_client.as_ref(),
+                &provider_name,
+                &url,
+                ®ion,
+                &auth,
+                request,
+                &extra_headers,
+                parse_stream_line,
+            )
+            .await
+            .map_err(|err| map_mantle_error(&model, err))
+        });
+
+        async move { Ok(future.await?.boxed()) }.boxed()
+    }
+
+    fn stream_completion(
+        &self,
+        request: open_ai::Request,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result>, LanguageModelCompletionError>,
+    > {
+        self.stream_mantle_request(
+            request,
+            cx,
+            "chat/completions",
+            parse_mantle_chat_stream_line,
+        )
+    }
+
+    fn stream_response(
+        &self,
+        request: OpenAiResponseRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            BoxStream<'static, Result>,
+            LanguageModelCompletionError,
+        >,
+    > {
+        let mut request = request;
+        strip_unsupported_mantle_response_fields(&mut request);
+        self.stream_mantle_request(request, cx, "responses", parse_mantle_response_stream_line)
+    }
+}
+
+impl LanguageModel for BedrockMantleModel {
+    fn id(&self) -> LanguageModelId {
+        self.id.clone()
+    }
+
+    fn name(&self) -> LanguageModelName {
+        LanguageModelName::from(self.model.display_name().to_string())
+    }
+
+    fn provider_id(&self) -> LanguageModelProviderId {
+        PROVIDER_ID
+    }
+
+    fn provider_name(&self) -> LanguageModelProviderName {
+        PROVIDER_NAME
+    }
+
+    fn supports_tools(&self) -> bool {
+        self.model.supports_tools()
+    }
+
+    fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
+        LanguageModelToolSchemaFormat::JsonSchemaSubset
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_images()
+    }
+
+    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
+        match choice {
+            LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => {
+                self.model.supports_tools()
+            }
+            LanguageModelToolChoice::None => true,
+        }
+    }
+
+    fn supports_streaming_tools(&self) -> bool {
+        true
+    }
+
+    fn supports_thinking(&self) -> bool {
+        self.model.supports_thinking()
+    }
+
+    fn supported_effort_levels(&self) -> Vec {
+        mantle_supported_effort_levels(&self.model)
+    }
+
+    fn supports_split_token_display(&self) -> bool {
+        true
+    }
+
+    fn telemetry_id(&self) -> String {
+        format!("bedrock-mantle/{}", self.model.id())
+    }
+
+    fn max_token_count(&self) -> u64 {
+        self.model.max_token_count()
+    }
+
+    fn max_output_tokens(&self) -> Option {
+        Some(self.model.max_output_tokens())
+    }
+
+    fn stream_completion(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            BoxStream<'static, Result>,
+            LanguageModelCompletionError,
+        >,
+    > {
+        let region = cx.read_entity(&self.state, |state, _cx| state.get_region());
+
+        if !MANTLE_SUPPORTED_REGIONS.contains(®ion.as_str()) {
+            let display_name = self.model.display_name().to_string();
+            let supported = MANTLE_SUPPORTED_REGIONS.join(", ");
+            return futures::future::ready(Err(LanguageModelCompletionError::Other(anyhow!(
+                "{display_name} is not available in {region} because Bedrock Mantle isn't offered \
+                 there. Try switching to one of the following regions: {supported}."
+            ))))
+            .boxed();
+        }
+
+        let model_id = self.model.request_id().to_string();
+        let max_output_tokens = Some(self.model.max_output_tokens());
+
+        match self.model.protocol() {
+            MantleProtocol::Responses => {
+                let request = into_open_ai_response(
+                    request,
+                    &model_id,
+                    self.model.supports_tools(),
+                    false,
+                    max_output_tokens,
+                    mantle_default_reasoning_effort(&self.model),
+                    self.model.supports_thinking(),
+                );
+                let completions = self.stream_response(request, cx);
+                async move {
+                    let mapper = OpenAiResponseEventMapper::new();
+                    Ok(mapper.map_stream(completions.await?).boxed())
+                }
+                .boxed()
+            }
+            MantleProtocol::ChatCompletions => {
+                let reasoning_effort = mantle_selected_reasoning_effort(&request, &self.model);
+                let request = match into_open_ai(
+                    request,
+                    &model_id,
+                    self.model.supports_tools(),
+                    false,
+                    max_output_tokens,
+                    ChatCompletionMaxTokensParameter::MaxCompletionTokens,
+                    reasoning_effort,
+                    false,
+                ) {
+                    Ok(request) => request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
+                let completions = self.stream_completion(request, cx);
+                async move {
+                    let mapper = OpenAiEventMapper::new();
+                    Ok(mapper.map_stream(completions.await?).boxed())
+                }
+                .boxed()
+            }
+        }
+    }
+}
+
 fn deny_tool_use_events(
     events: impl Stream>,
 ) -> impl Stream> {
@@ -864,6 +1536,10 @@ pub fn into_bedrock(
     guardrail_identifier: Option,
     guardrail_version: Option,
 ) -> Result {
+    if request.contains_custom_tool_input() {
+        anyhow::bail!("Bedrock does not support custom tools");
+    }
+
     let mut new_messages: Vec = Vec::new();
     let mut system_message = String::new();
 
@@ -891,7 +1567,7 @@ pub fn into_bedrock(
                         }
                         MessageContent::Compaction(_) => None,
                         MessageContent::Thinking { text, signature } => {
-                            if model.contains(Model::DeepSeekR1.request_id()) {
+                            if model.contains(ConverseModel::DeepSeekR1.request_id()) {
                                 // DeepSeekR1 doesn't support thinking blocks
                                 // And the AWS API demands that you strip them
                                 return None;
@@ -914,7 +1590,7 @@ pub fn into_bedrock(
                             ))
                         }
                         MessageContent::RedactedThinking(blob) => {
-                            if model.contains(Model::DeepSeekR1.request_id()) {
+                            if model.contains(ConverseModel::DeepSeekR1.request_id()) {
                                 // DeepSeekR1 doesn't support thinking blocks
                                 // And the AWS API demands that you strip them
                                 return None;
@@ -926,12 +1602,19 @@ pub fn into_bedrock(
                         }
                         MessageContent::ToolUse(tool_use) => {
                             messages_contain_tool_content = true;
-                            let input = if tool_use.input.is_null() {
-                                // Bedrock API requires valid JsonValue, not null, for tool use input
-                                value_to_aws_document(&serde_json::json!({}))
-                            } else {
-                                value_to_aws_document(&tool_use.input)
-                            };
+                            let input =
+                                if let language_model::LanguageModelToolUseInput::Json(input) =
+                                    &tool_use.input
+                                {
+                                    if input.is_null() {
+                                        // Bedrock API requires valid JsonValue, not null, for tool use input
+                                        value_to_aws_document(&serde_json::json!({}))
+                                    } else {
+                                        value_to_aws_document(input)
+                                    }
+                                } else {
+                                    value_to_aws_document(&serde_json::json!({}))
+                                };
                             BedrockToolUseBlock::builder()
                                 .name(tool_use.name.to_string())
                                 .tool_use_id(tool_use.id.to_string())
@@ -1021,7 +1704,7 @@ pub fn into_bedrock(
                         }
                     })
                     .collect();
-                if message.cache && supports_caching {
+                if message.cache && supports_caching && !bedrock_message_content.is_empty() {
                     bedrock_message_content.push(BedrockInnerContent::CachePoint(
                         CachePointBlock::builder()
                             .r#type(CachePointType::Default)
@@ -1065,19 +1748,25 @@ pub fn into_bedrock(
         request
             .tools
             .iter()
-            .filter_map(|tool| {
-                Some(BedrockTool::ToolSpec(
+            .map(|tool| {
+                let language_model::LanguageModelRequestToolInput::Function {
+                    input_schema, ..
+                } = &tool.input
+                else {
+                    anyhow::bail!("Bedrock does not support custom tools");
+                };
+                Ok(BedrockTool::ToolSpec(
                     BedrockToolSpec::builder()
                         .name(tool.name.clone())
                         .description(tool.description.clone())
                         .input_schema(BedrockToolInputSchema::Json(value_to_aws_document(
-                            &tool.input_schema,
+                            input_schema,
                         )))
                         .build()
-                        .log_err()?,
+                        .context("failed to build Bedrock tool spec")?,
                 ))
             })
-            .collect()
+            .collect::>()?
     } else {
         Vec::new()
     };
@@ -1232,7 +1921,10 @@ pub fn map_to_language_model_completion_events(
                                                 name: tool_use.name.clone().into(),
                                                 is_input_complete: false,
                                                 raw_input: tool_use.input_json.clone(),
-                                                input,
+                                                input:
+                                                    language_model::LanguageModelToolUseInput::Json(
+                                                        input,
+                                                    ),
                                                 thought_signature: None,
                                             },
                                         )))
@@ -1297,7 +1989,9 @@ pub fn map_to_language_model_completion_events(
                                         name: tool_use.name.into(),
                                         is_input_complete: true,
                                         raw_input: tool_use.input_json,
-                                        input,
+                                        input: language_model::LanguageModelToolUseInput::Json(
+                                            input,
+                                        ),
                                         thought_signature: None,
                                     },
                                 ))
@@ -1743,3 +2437,305 @@ impl ConfigurationView {
             )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use language_model::LanguageModelRequestMessage;
+
+    fn into_bedrock_request(messages: Vec) -> bedrock::Request {
+        into_bedrock(
+            LanguageModelRequest {
+                messages,
+                ..Default::default()
+            },
+            "claude-sonnet-4-5".to_string(),
+            1.0,
+            4096,
+            BedrockModelMode::Default,
+            true,
+            true,
+            None,
+            None,
+        )
+        .unwrap()
+    }
+
+    #[test]
+    fn test_cache_marked_message_that_filters_to_empty_is_dropped() {
+        let request = into_bedrock_request(vec![
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![MessageContent::Text("What's the weather?".into())],
+                cache: false,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::Assistant,
+                content: vec![MessageContent::Thinking {
+                    text: "Let me think about this...".into(),
+                    signature: None,
+                }],
+                cache: true,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![MessageContent::Text("Summarize this conversation.".into())],
+                cache: false,
+                reasoning_details: None,
+            },
+        ]);
+
+        for message in &request.messages {
+            assert!(
+                message
+                    .content()
+                    .iter()
+                    .any(|block| !matches!(block, BedrockInnerContent::CachePoint(_))),
+                "message must not consist solely of cache points: {:?}",
+                message
+            );
+        }
+        assert!(
+            request
+                .messages
+                .iter()
+                .all(|message| *message.role() == bedrock::BedrockRole::User),
+            "the assistant message stripped to empty content should be dropped entirely"
+        );
+    }
+
+    #[test]
+    fn test_cache_marked_message_with_content_gets_cache_point() {
+        let request = into_bedrock_request(vec![LanguageModelRequestMessage {
+            role: Role::User,
+            content: vec![MessageContent::Text("What's the weather?".into())],
+            cache: true,
+            reasoning_details: None,
+        }]);
+
+        assert_eq!(request.messages.len(), 1);
+        assert!(
+            matches!(
+                request.messages[0].content().last(),
+                Some(BedrockInnerContent::CachePoint(_))
+            ),
+            "a cache-marked message with content should end with a cache point"
+        );
+    }
+
+    #[test]
+    fn test_sign_mantle_request_sigv4_uses_mantle_service() {
+        let credentials = Credentials::new(
+            "AKIDEXAMPLE",
+            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
+            None,
+            None,
+            "test",
+        );
+        let body = br#"{"model":"openai.gpt-5.5"}"#;
+        let mut request = HttpRequest::builder()
+            .method(Method::POST)
+            .uri("https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses")
+            .header("Content-Type", "application/json")
+            .body(AsyncBody::from(body.to_vec()))
+            .unwrap();
+        let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
+
+        sign_mantle_request_sigv4_at(&mut request, body, &credentials, "us-east-1", time).unwrap();
+
+        assert_eq!(
+            request
+                .headers()
+                .get(http_client::http::header::HOST)
+                .and_then(|value| value.to_str().ok()),
+            Some("bedrock-mantle.us-east-1.api.aws")
+        );
+        assert_eq!(
+            request
+                .headers()
+                .get("x-amz-date")
+                .and_then(|value| value.to_str().ok()),
+            Some("20231114T221320Z")
+        );
+        let authorization = request
+            .headers()
+            .get(AUTHORIZATION)
+            .and_then(|value| value.to_str().ok())
+            .unwrap();
+        assert!(authorization.starts_with("AWS4-HMAC-SHA256 "));
+        assert!(
+            authorization
+                .contains("Credential=AKIDEXAMPLE/20231114/us-east-1/bedrock-mantle/aws4_request")
+        );
+        assert!(authorization.contains("SignedHeaders=content-type;host"));
+        assert!(authorization.contains("Signature="));
+    }
+
+    #[test]
+    fn test_mantle_endpoint_url_uses_openai_path_prefix() {
+        assert_eq!(
+            mantle_endpoint_url("us-east-1"),
+            "https://bedrock-mantle.us-east-1.api.aws/openai/v1"
+        );
+        assert_eq!(
+            mantle_endpoint_url("us-west-2"),
+            "https://bedrock-mantle.us-west-2.api.aws/openai/v1"
+        );
+    }
+
+    #[test]
+    fn test_mantle_protocol_from_settings() {
+        assert_eq!(
+            mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::ChatCompletions),
+            MantleProtocol::ChatCompletions
+        );
+        assert_eq!(
+            mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::Responses),
+            MantleProtocol::Responses
+        );
+    }
+
+    #[test]
+    fn test_mantle_supported_regions_matches_docs() {
+        assert!(MANTLE_SUPPORTED_REGIONS.contains(&"us-east-1"));
+        assert!(MANTLE_SUPPORTED_REGIONS.contains(&"eu-west-1"));
+        assert!(!MANTLE_SUPPORTED_REGIONS.contains(&"ap-southeast-1"));
+    }
+
+    #[test]
+    fn test_builtin_mantle_models_support_thinking() {
+        assert!(MantleModel::Gpt5_5.supports_thinking());
+        assert!(MantleModel::Gpt5_4.supports_thinking());
+        assert!(MantleModel::Grok4_3.supports_thinking());
+        assert_eq!(
+            mantle_default_reasoning_effort(&MantleModel::Gpt5_5),
+            Some(ReasoningEffort::Medium)
+        );
+        assert_eq!(
+            mantle_default_reasoning_effort(&MantleModel::Grok4_3),
+            Some(ReasoningEffort::Medium)
+        );
+    }
+
+    #[test]
+    fn test_mantle_supported_effort_levels_hide_none() {
+        let effort_levels = mantle_supported_effort_levels(&MantleModel::Gpt5_5);
+        let values = effort_levels
+            .iter()
+            .map(|level| level.value.as_ref())
+            .collect::>();
+
+        assert_eq!(values, ["low", "medium", "high", "xhigh"]);
+        assert_eq!(
+            effort_levels
+                .iter()
+                .find(|level| level.is_default)
+                .map(|level| level.value.as_ref()),
+            Some("medium")
+        );
+    }
+
+    #[test]
+    fn test_custom_mantle_model_can_disable_thinking() {
+        let model = MantleModel::Custom {
+            name: "custom-mantle-model".to_string(),
+            display_name: None,
+            max_tokens: 128_000,
+            max_output_tokens: None,
+            protocol: MantleProtocol::Responses,
+            supports_tools: true,
+            supports_images: false,
+            supports_thinking: false,
+        };
+
+        assert!(!model.supports_thinking());
+        assert_eq!(mantle_default_reasoning_effort(&model), None);
+        assert!(mantle_supported_effort_levels(&model).is_empty());
+        assert_eq!(
+            mantle_selected_reasoning_effort(
+                &LanguageModelRequest {
+                    thinking_effort: Some("high".to_string()),
+                    ..Default::default()
+                },
+                &model,
+            ),
+            None
+        );
+    }
+
+    #[test]
+    fn test_disabled_mantle_thinking_serializes_none() {
+        let request = into_open_ai_response(
+            LanguageModelRequest {
+                thinking_allowed: false,
+                ..Default::default()
+            },
+            MantleModel::Grok4_3.request_id(),
+            true,
+            false,
+            Some(MantleModel::Grok4_3.max_output_tokens()),
+            mantle_default_reasoning_effort(&MantleModel::Grok4_3),
+            MantleModel::Grok4_3.supports_thinking(),
+        );
+
+        assert_eq!(
+            serde_json::to_value(&request).unwrap()["reasoning"],
+            serde_json::json!({ "effort": "none" })
+        );
+    }
+
+    #[test]
+    fn test_mantle_reasoning_passes_known_efforts_through() {
+        for effort in ["low", "medium", "high", "xhigh", "minimal", "max"] {
+            assert_eq!(
+                mantle_selected_reasoning_effort(
+                    &LanguageModelRequest {
+                        thinking_allowed: true,
+                        thinking_effort: Some(effort.to_string()),
+                        ..Default::default()
+                    },
+                    &MantleModel::Gpt5_5,
+                )
+                .map(|effort| effort.value()),
+                Some(effort)
+            );
+        }
+
+        assert_eq!(
+            mantle_selected_reasoning_effort(
+                &LanguageModelRequest {
+                    thinking_allowed: true,
+                    thinking_effort: Some("none".to_string()),
+                    ..Default::default()
+                },
+                &MantleModel::Gpt5_5,
+            ),
+            Some(ReasoningEffort::Medium)
+        );
+    }
+
+    #[test]
+    fn test_strip_unsupported_mantle_response_fields_removes_context_management() {
+        let mut request = into_open_ai_response(
+            LanguageModelRequest {
+                compact_at_tokens: Some(10_000),
+                ..Default::default()
+            },
+            "openai.gpt-5.5",
+            true,
+            false,
+            Some(128_000),
+            Some(ReasoningEffort::Medium),
+            false,
+        );
+
+        assert!(request.context_management.is_some());
+        strip_unsupported_mantle_response_fields(&mut request);
+        assert!(request.context_management.is_none());
+
+        let request = serde_json::to_value(&request).unwrap();
+        assert!(request.get("context_management").is_none());
+    }
+}
diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs
index 926f99eae1b..06131314eef 100644
--- a/crates/language_models/src/provider/cloud.rs
+++ b/crates/language_models/src/provider/cloud.rs
@@ -9,11 +9,11 @@ use cloud_api_types::Plan;
 use futures::FutureExt;
 use futures::StreamExt;
 use futures::future::BoxFuture;
-use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt};
+use gpui::{AnyElement, App, AppContext, Context, Entity, Subscription, Task, TaskExt};
 use language_model::{
     AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID,
+    LanguageModelProviderState, ProviderSettingsView, ZED_CLOUD_PROVIDER_ID,
     ZED_CLOUD_PROVIDER_NAME,
 };
 use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider};
@@ -360,37 +360,13 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
         })
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|_| ConfigurationView::new(self.state.clone(), false))
-            .into()
-    }
-
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        ProviderConfigurationView::Inline {
-            view: cx
-                .new(|_| ConfigurationView::new(self.state.clone(), true))
-                .into(),
-        }
-    }
-
-    fn inline_description(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
         let user_store = state.user_store.read(cx);
         let is_zed_model_provider_enabled = user_store
             .current_organization_configuration()
             .map_or(true, |config| config.is_zed_model_provider_enabled);
-
-        Some(InlineDescription::Text(
+        let description = InlineDescription::Text(
             zed_ai_description(
                 !state.is_signed_out(cx),
                 user_store.plan(),
@@ -398,27 +374,34 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
                 user_store.trial_started_at().is_none(),
             )
             .into(),
-        ))
-    }
+        );
 
-    fn inline_title(&self, cx: &App) -> Option {
-        let state = self.state.read(cx);
-        if state.is_signed_out(cx) {
-            return None;
-        }
-        let plan_name = match state.user_store.read(cx).plan()? {
-            Plan::ZedPro => "Pro",
-            Plan::ZedProTrial => "Pro Trial",
-            Plan::ZedStudent => "Student",
-            Plan::ZedBusiness => "Business",
-            Plan::ZedVip => "VIP",
-            Plan::ZedFree => return None,
+        let title = if state.is_signed_out(cx) {
+            None
+        } else {
+            match state.user_store.read(cx).plan() {
+                Some(Plan::ZedPro) => Some("Subscribed to Pro".into()),
+                Some(Plan::ZedProTrial) => Some("Subscribed to Pro Trial".into()),
+                Some(Plan::ZedStudent) => Some("Subscribed to Student".into()),
+                Some(Plan::ZedBusiness) => Some("Subscribed to Business".into()),
+                Some(Plan::ZedVip) => Some("Subscribed to VIP".into()),
+                Some(Plan::ZedFree) | None => None,
+            }
         };
-        Some(format!("Subscribed to {plan_name}").into())
-    }
 
-    fn reset_credentials(&self, _cx: &mut App) -> Task> {
-        Task::ready(Ok(()))
+        Some(ProviderSettingsView::Inline(
+            language_model::InlineProviderSettings {
+                title,
+                description: Some(description),
+                create_view: Arc::new({
+                    let state = self.state.clone();
+                    move |_window, cx| {
+                        cx.new(|_| ConfigurationView::new(state.clone(), true))
+                            .into()
+                    }
+                }),
+            },
+        ))
     }
 
     fn authentication_error_message(&self) -> SharedString {
diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs
index 94bc00e72d5..4074fab35c2 100644
--- a/crates/language_models/src/provider/copilot_chat.rs
+++ b/crates/language_models/src/provider/copilot_chat.rs
@@ -16,7 +16,7 @@ use copilot_chat::{
 use futures::future::BoxFuture;
 use futures::stream::BoxStream;
 use futures::{FutureExt, Stream, StreamExt};
-use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
+use gpui::{App, AsyncApp, Entity, Subscription, Task};
 use http_client::StatusCode;
 use language::language_settings::all_language_settings;
 use language_model::{
@@ -25,7 +25,7 @@ use language_model::{
     LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage,
     LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
-    LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason,
+    LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason,
     TokenUsage,
 };
 use settings::SettingsStore;
@@ -177,76 +177,42 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         Task::ready(Err(err.into()))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| {
-            copilot_ui::ConfigurationView::new(
-                |cx| {
-                    CopilotChat::global(cx)
-                        .map(|m| m.read(cx).is_authenticated())
-                        .unwrap_or(false)
-                },
-                copilot_ui::ConfigurationMode::Chat,
-                cx,
-            )
-        })
-        .into()
-    }
-
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        // GitHub Copilot's control is just a sign-in/out button, so render it
-        // inline rather than behind a sub-page. The explanatory copy is surfaced
-        // via `inline_description` (the row's left column), so the view itself
-        // renders compactly.
-        ProviderConfigurationView::Inline {
-            view: cx
-                .new(|cx| {
-                    copilot_ui::ConfigurationView::new(
-                        |cx| {
-                            CopilotChat::global(cx)
-                                .map(|m| m.read(cx).is_authenticated())
-                                .unwrap_or(false)
-                        },
-                        copilot_ui::ConfigurationMode::Chat,
-                        cx,
-                    )
-                    .compact()
-                })
-                .into(),
-        }
-    }
-
-    fn inline_title(&self, cx: &App) -> Option {
-        if self.state.read(cx).is_authenticated(cx) {
+    fn settings_view(&self, cx: &mut App) -> Option {
+        let is_authenticated = self.state.read(cx).is_authenticated(cx);
+        let title = if is_authenticated {
             None
         } else {
             Some("Configure Copilot".into())
-        }
-    }
-
-    fn inline_description(&self, cx: &App) -> Option {
-        if self.state.read(cx).is_authenticated(cx) {
+        };
+        let description = if is_authenticated {
             None
         } else {
             Some(language_model::InlineDescription::Text(
                 "Requires an active GitHub Copilot subscription.".into(),
             ))
-        }
-    }
+        };
 
-    fn reset_credentials(&self, _cx: &mut App) -> Task> {
-        Task::ready(Err(anyhow!(
-            "Signing out of GitHub Copilot Chat is currently not supported."
-        )))
+        Some(ProviderSettingsView::Inline(
+            language_model::InlineProviderSettings {
+                title,
+                description,
+                create_view: Arc::new(|_window, cx| {
+                    cx.new(|cx| {
+                        copilot_ui::ConfigurationView::new(
+                            |cx| {
+                                CopilotChat::global(cx)
+                                    .map(|m| m.read(cx).is_authenticated())
+                                    .unwrap_or(false)
+                            },
+                            copilot_ui::ConfigurationMode::Chat,
+                            cx,
+                        )
+                        .compact()
+                    })
+                    .into()
+                }),
+            },
+        ))
     }
 }
 
@@ -402,7 +368,7 @@ impl LanguageModel for CopilotChatLanguageModel {
                         AnthropicModelMode::Default
                     },
                     AnthropicPromptCacheMode::Legacy,
-                );
+                )?;
 
                 anthropic_request.temperature = None;
 
@@ -457,7 +423,10 @@ impl LanguageModel for CopilotChatLanguageModel {
 
         if self.model.supports_response() {
             let location = intent_to_chat_location(request.intent);
-            let responses_request = into_copilot_responses(&self.model, request);
+            let responses_request = match into_copilot_responses(&self.model, request) {
+                Ok(request) => request,
+                Err(error) => return async move { Err(error.into()) }.boxed(),
+            };
             let request_limiter = self.request_limiter.clone();
             let future = cx.spawn(async move |cx| {
                 let request = CopilotChat::stream_response(
@@ -601,7 +570,9 @@ pub fn map_to_language_model_completion_events(
                                             id: entry.id.clone().into(),
                                             name: entry.name.as_str().into(),
                                             is_input_complete: false,
-                                            input,
+                                            input: language_model::LanguageModelToolUseInput::Json(
+                                                input,
+                                            ),
                                             raw_input: entry.arguments.clone(),
                                             thought_signature: entry.thought_signature.clone(),
                                         },
@@ -663,7 +634,10 @@ pub fn map_to_language_model_completion_events(
                                                 id: tool_call.id.into(),
                                                 name: tool_call.name.as_str().into(),
                                                 is_input_complete: true,
-                                                input,
+                                                input:
+                                                    language_model::LanguageModelToolUseInput::Json(
+                                                        input,
+                                                    ),
                                                 raw_input: tool_call.arguments,
                                                 thought_signature: tool_call.thought_signature,
                                             },
@@ -768,7 +742,7 @@ impl CopilotResponsesEventMapper {
                                 id: call_id.into(),
                                 name: name.as_str().into(),
                                 is_input_complete: true,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: arguments.clone(),
                                 thought_signature,
                             },
@@ -1088,12 +1062,15 @@ fn into_copilot_chat(
                 let mut tool_calls = Vec::new();
                 for content in &message.content {
                     if let MessageContent::ToolUse(tool_use) = content {
+                        let input = tool_use.input.as_json().ok_or_else(|| {
+                            anyhow!("Copilot Chat does not support custom tool calls")
+                        })?;
                         tool_calls.push(ToolCall {
                             id: tool_use.id.to_string(),
                             content: ToolCallContent::Function {
                                 function: FunctionContent {
                                     name: tool_use.name.to_string(),
-                                    arguments: serde_json::to_string(&tool_use.input)?,
+                                    arguments: serde_json::to_string(input)?,
                                     thought_signature: tool_use.thought_signature.clone(),
                                 },
                             },
@@ -1154,14 +1131,21 @@ fn into_copilot_chat(
     let tools = request
         .tools
         .iter()
-        .map(|tool| Tool::Function {
-            function: Function {
-                name: tool.name.clone(),
-                description: tool.description.clone(),
-                parameters: tool.input_schema.clone(),
-            },
+        .map(|tool| match &tool.input {
+            language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
+                Ok(Tool::Function {
+                    function: Function {
+                        name: tool.name.clone(),
+                        description: tool.description.clone(),
+                        parameters: input_schema.clone(),
+                    },
+                })
+            }
+            language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
+                "Copilot Chat does not support custom tools"
+            )),
         })
-        .collect::>();
+        .collect::>>()?;
 
     Ok(CopilotChatRequest {
         n: 1,
@@ -1222,7 +1206,7 @@ fn intent_to_chat_location(intent: Option) -> ChatLocation {
 fn into_copilot_responses(
     model: &CopilotChatModel,
     request: LanguageModelRequest,
-) -> copilot_responses::Request {
+) -> Result {
     use copilot_responses as responses;
 
     let LanguageModelRequest {
@@ -1390,13 +1374,20 @@ fn into_copilot_responses(
 
     let converted_tools: Vec = tools
         .into_iter()
-        .map(|tool| responses::ToolDefinition::Function {
-            name: tool.name,
-            description: Some(tool.description),
-            parameters: Some(tool.input_schema),
-            strict: None,
+        .map(|tool| match tool.input {
+            language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
+                Ok(responses::ToolDefinition::Function {
+                    name: tool.name,
+                    description: Some(tool.description),
+                    parameters: Some(input_schema),
+                    strict: None,
+                })
+            }
+            language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
+                "Copilot Chat does not support custom tools"
+            )),
         })
-        .collect();
+        .collect::>()?;
 
     let mapped_tool_choice = tool_choice.map(|choice| match choice {
         LanguageModelToolChoice::Auto => responses::ToolChoice::Auto,
@@ -1404,7 +1395,7 @@ fn into_copilot_responses(
         LanguageModelToolChoice::None => responses::ToolChoice::None,
     });
 
-    responses::Request {
+    Ok(responses::Request {
         model: model.id().to_string(),
         input: input_items,
         stream: model.uses_streaming(),
@@ -1427,7 +1418,7 @@ fn into_copilot_responses(
             copilot_responses::ResponseIncludable::ReasoningEncryptedContent,
         ]),
         store: false,
-    }
+    })
 }
 
 #[cfg(test)]
@@ -1705,7 +1696,7 @@ mod tests {
             ..Default::default()
         };
 
-        let serialized = serde_json::to_value(into_copilot_responses(&model, request))
+        let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap())
             .expect("serialized request");
         let input = serialized["input"].as_array().expect("input items");
 
diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs
index 943b3861e3c..129ea1bd451 100644
--- a/crates/language_models/src/provider/deepseek.rs
+++ b/crates/language_models/src/provider/deepseek.rs
@@ -5,7 +5,7 @@ use deepseek::DEEPSEEK_API_URL;
 
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
@@ -13,16 +13,14 @@ use language_model::{
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
     LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    RateLimiter, Role, StopReason, TokenUsage, env_var,
+    ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 pub use settings::DeepseekAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::sync::{Arc, LazyLock};
 
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 use language_model::util::{fix_streamed_json, parse_tool_arguments};
 
@@ -197,34 +195,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://platform.deepseek.com/api_keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://platform.deepseek.com/api_keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -355,7 +338,10 @@ impl LanguageModel for DeepSeekLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = into_deepseek(request, &self.model, self.max_output_tokens());
+        let request = match into_deepseek(request, &self.model, self.max_output_tokens()) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let stream = self.stream_completion(request, cx);
 
         async move {
@@ -370,7 +356,11 @@ pub fn into_deepseek(
     request: LanguageModelRequest,
     model: &deepseek::Model,
     max_output_tokens: Option,
-) -> deepseek::Request {
+) -> Result {
+    if request.contains_custom_tool_input() {
+        anyhow::bail!("DeepSeek does not support custom tools");
+    }
+
     let thinking = deepseek_thinking(model, request.thinking_allowed);
     let thinking_enabled = thinking
         .as_ref()
@@ -409,13 +399,16 @@ pub fn into_deepseek(
                 MessageContent::Image(_) => {}
                 MessageContent::Compaction(_) => {}
                 MessageContent::ToolUse(tool_use) => {
+                    let input = tool_use
+                        .input
+                        .as_json()
+                        .ok_or_else(|| anyhow!("DeepSeek does not support custom tool calls"))?;
                     let tool_call = deepseek::ToolCall {
                         id: tool_use.id.to_string(),
                         content: deepseek::ToolCallContent::Function {
                             function: deepseek::FunctionContent {
                                 name: tool_use.name.to_string(),
-                                arguments: serde_json::to_string(&tool_use.input)
-                                    .unwrap_or_default(),
+                                arguments: serde_json::to_string(input).unwrap_or_default(),
                             },
                         },
                     };
@@ -458,7 +451,7 @@ pub fn into_deepseek(
         }
     }
 
-    deepseek::Request {
+    Ok(deepseek::Request {
         model: model.id().to_string(),
         messages,
         stream: true,
@@ -483,15 +476,26 @@ pub fn into_deepseek(
         tools: request
             .tools
             .into_iter()
-            .map(|tool| deepseek::ToolDefinition::Function {
-                function: deepseek::FunctionDefinition {
-                    name: tool.name,
-                    description: Some(tool.description),
-                    parameters: Some(tool.input_schema),
-                },
+            .map(|tool| {
+                let input_schema = match tool.input {
+                    language_model::LanguageModelRequestToolInput::Function {
+                        input_schema,
+                        ..
+                    } => input_schema,
+                    language_model::LanguageModelRequestToolInput::Custom { .. } => {
+                        return Err(anyhow::anyhow!("DeepSeek does not support custom tools"));
+                    }
+                };
+                Ok(deepseek::ToolDefinition::Function {
+                    function: deepseek::FunctionDefinition {
+                        name: tool.name,
+                        description: Some(tool.description),
+                        parameters: Some(input_schema),
+                    },
+                })
             })
-            .collect(),
-    }
+            .collect::>()?,
+    })
 }
 
 fn deepseek_thinking(
@@ -595,7 +599,7 @@ impl DeepSeekEventMapper {
                                 id: entry.id.clone().into(),
                                 name: entry.name.as_str().into(),
                                 is_input_complete: false,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: entry.arguments.clone(),
                                 thought_signature: None,
                             },
@@ -626,7 +630,7 @@ impl DeepSeekEventMapper {
                                 id: tool_call.id.clone().into(),
                                 name: tool_call.name.as_str().into(),
                                 is_input_complete: true,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: tool_call.arguments.clone(),
                                 thought_signature: None,
                             },
@@ -652,129 +656,3 @@ impl DeepSeekEventMapper {
         events
     }
 }
-
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor =
-            cx.new(|cx| InputField::new(window, cx, "sk-00000000000000000000000000000000"));
-
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn({
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    let _ = task.await;
-                }
-
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        let state = self.state.clone();
-        cx.spawn(async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn(async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = DeepSeekLanguageModelProvider::api_url(cx);
-            if api_url == DEEPSEEK_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div()
-                .child(Label::new("Loading credentials..."))
-                .into_any_element()
-        } else if self.should_render_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new("To use DeepSeek in Zed, you need an API key:"))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Get your API key from the"))
-                                .child(ButtonLink::new(
-                                    "DeepSeek console",
-                                    "https://platform.deepseek.com/api_keys",
-                                )),
-                        )
-                        .child(ListBulletItem::new(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(format!(
-                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
-                    ))
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("deepseek-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .into_any_element()
-        }
-    }
-}
diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs
index 8a289fd2e05..e930405c118 100644
--- a/crates/language_models/src/provider/google.rs
+++ b/crates/language_models/src/provider/google.rs
@@ -4,17 +4,17 @@ use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use google_ai::GenerateContentResponse;
 pub use google_ai::completion::{GoogleEventMapper, into_google};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyConfiguration, AuthenticateError, ConfigurationViewTargetAgent, EnvVar,
-    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice,
-    LanguageModelToolSchemaFormat,
+    ApiKeyConfiguration, AuthenticateError, EnvVar, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
 };
 use language_model::{
     GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    ProviderSettingsView, RateLimiter,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -22,9 +22,7 @@ pub use settings::GoogleAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 use language_model::ApiKeyState;
 
@@ -222,34 +220,19 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://aistudio.google.com/app/apikey".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://aistudio.google.com/app/apikey".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -375,11 +358,14 @@ impl LanguageModel for GoogleLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = into_google(
+        let request = match into_google(
             request,
             self.model.request_id().to_string(),
             self.model.mode(),
-        );
+        ) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let request = self.stream_completion(request, cx);
         let future = self.request_limiter.stream(async move {
             let response = request.await.map_err(LanguageModelCompletionError::from)?;
@@ -388,142 +374,3 @@ impl LanguageModel for GoogleLanguageModel {
         async move { Ok(future.await?.boxed()) }.boxed()
     }
 }
-
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    target_agent: language_model::ConfigurationViewTargetAgent,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(
-        state: Entity,
-        target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> Self {
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    // We don't log an error, because "not signed in" is also an error.
-                    let _ = task.await;
-                }
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor: cx.new(|cx| InputField::new(window, cx, "AIzaSy...")),
-            target_agent,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!(
-                "API key set in {} environment variable",
-                API_KEY_ENV_VAR.name
-            )
-        } else {
-            let api_url = GoogleLanguageModelProvider::api_url(cx);
-            if api_url == google_ai::API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div()
-                .child(Label::new("Loading credentials..."))
-                .into_any_element()
-        } else if self.should_render_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
-                    ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(),
-                    ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
-                })))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create one by visiting"))
-                                .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey"))
-                        )
-                        .child(
-                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
-                        )
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(
-                        format!("You can also set the {GEMINI_API_KEY_VAR_NAME} environment variable and restart Zed."),
-                    )
-                    .size(LabelSize::Small).color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("google-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))
-                })
-                .into_any_element()
-        }
-    }
-}
diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs
index ed207d4b47b..297c1e4284c 100644
--- a/crates/language_models/src/provider/llama_cpp.rs
+++ b/crates/language_models/src/provider/llama_cpp.rs
@@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
+use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::util::parse_tool_arguments;
 use language_model::{
@@ -12,8 +12,8 @@ use language_model::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
-    StopReason, TokenUsage, env_var,
+    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView,
+    RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, env_var,
 };
 use llama_cpp::{
     LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion,
@@ -562,12 +562,6 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider {
         None
     }
 
-    fn inline_description(&self, _cx: &App) -> Option {
-        Some(InlineDescription::Text(
-            "Run local models on your machine with LlamaCpp.".into(),
-        ))
-    }
-
     fn provided_models(&self, cx: &App) -> Vec> {
         let settings = LlamaCppLanguageModelProvider::settings(cx);
         let effective = compute_effective_models(&self.state.read(cx).fetched_models, settings);
@@ -603,20 +597,17 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
+    fn settings_view(&self, _cx: &mut App) -> Option {
         let state = self.state.clone();
-        cx.new(|cx| ConfigurationView::new(state, window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+        Some(ProviderSettingsView::SubPage(
+            SubPageProviderSettings::new(move |window, cx| {
+                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
+                    .into()
+            })
+            .description(InlineDescription::Text(
+                "Run local models on your machine with LlamaCpp.".into(),
+            )),
+        ))
     }
 }
 
@@ -659,7 +650,7 @@ impl LlamaCppLanguageModel {
     fn to_llama_cpp_request(
         &self,
         request: LanguageModelRequest,
-    ) -> llama_cpp::ChatCompletionRequest {
+    ) -> Result {
         build_llama_cpp_request(
             &self.name,
             self.supports_images,
@@ -706,7 +697,11 @@ fn build_llama_cpp_request(
     supports_images: bool,
     capabilities: LiveCapabilities,
     request: LanguageModelRequest,
-) -> llama_cpp::ChatCompletionRequest {
+) -> Result {
+    if request.contains_custom_tool_input() {
+        anyhow::bail!("llama.cpp does not support custom tools");
+    }
+
     let supports_tools = capabilities.supports_tools;
     let supports_thinking = capabilities.supports_thinking;
     let mut messages = Vec::new();
@@ -752,13 +747,15 @@ fn build_llama_cpp_request(
                     }
                 }
                 MessageContent::ToolUse(tool_use) => {
+                    let input = tool_use.input.as_json().ok_or_else(|| {
+                        anyhow::anyhow!("llama.cpp does not support custom tool calls")
+                    })?;
                     let tool_call = llama_cpp::ToolCall {
                         id: tool_use.id.to_string(),
                         content: llama_cpp::ToolCallContent::Function {
                             function: llama_cpp::FunctionContent {
                                 name: tool_use.name.to_string(),
-                                arguments: serde_json::to_string(&tool_use.input)
-                                    .unwrap_or_default(),
+                                arguments: serde_json::to_string(input).unwrap_or_default(),
                             },
                         },
                     };
@@ -820,14 +817,25 @@ fn build_llama_cpp_request(
         request
             .tools
             .into_iter()
-            .map(|tool| llama_cpp::ToolDefinition::Function {
-                function: llama_cpp::FunctionDefinition {
-                    name: tool.name,
-                    description: Some(tool.description),
-                    parameters: Some(tool.input_schema),
-                },
+            .map(|tool| {
+                let input_schema = match tool.input {
+                    language_model::LanguageModelRequestToolInput::Function {
+                        input_schema,
+                        ..
+                    } => input_schema,
+                    language_model::LanguageModelRequestToolInput::Custom { .. } => {
+                        return Err(anyhow::anyhow!("llama.cpp does not support custom tools"));
+                    }
+                };
+                Ok(llama_cpp::ToolDefinition::Function {
+                    function: llama_cpp::FunctionDefinition {
+                        name: tool.name,
+                        description: Some(tool.description),
+                        parameters: Some(input_schema),
+                    },
+                })
             })
-            .collect()
+            .collect::>()?
     } else {
         Vec::new()
     };
@@ -843,7 +851,7 @@ fn build_llama_cpp_request(
         })
     };
 
-    llama_cpp::ChatCompletionRequest {
+    Ok(llama_cpp::ChatCompletionRequest {
         model: model_name.to_string(),
         messages,
         stream: true,
@@ -862,7 +870,7 @@ fn build_llama_cpp_request(
         stream_options: Some(llama_cpp::StreamOptions {
             include_usage: true,
         }),
-    }
+    })
 }
 
 impl LanguageModel for LlamaCppLanguageModel {
@@ -928,7 +936,10 @@ impl LanguageModel for LlamaCppLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = self.to_llama_cpp_request(request);
+        let request = match self.to_llama_cpp_request(request) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let completions = self.stream_completion(request, cx);
         async move {
             let mapper = LlamaCppEventMapper::new();
@@ -1028,7 +1039,9 @@ impl LlamaCppEventMapper {
                                         id: tool_call.id.into(),
                                         name: tool_call.name.into(),
                                         is_input_complete: true,
-                                        input,
+                                        input: language_model::LanguageModelToolUseInput::Json(
+                                            input,
+                                        ),
                                         raw_input: tool_call.arguments,
                                         thought_signature: None,
                                     },
@@ -1838,7 +1851,8 @@ mod tests {
                 }],
                 ..Default::default()
             },
-        );
+        )
+        .unwrap();
 
         assert_eq!(request.messages.len(), 1);
         match &request.messages[0] {
@@ -1881,7 +1895,8 @@ mod tests {
                 }],
                 ..Default::default()
             },
-        );
+        )
+        .unwrap();
 
         assert_eq!(request.messages.len(), 1);
         match &request.messages[0] {
@@ -1891,7 +1906,7 @@ mod tests {
                 tool_calls,
             } => {
                 assert_eq!(content, "answer");
-                assert_eq!(reasoning_content, &None);
+                assert!(reasoning_content.is_none());
                 assert!(tool_calls.is_empty());
             }
             message => panic!("unexpected message: {message:?}"),
@@ -1920,7 +1935,9 @@ mod tests {
                             id: "call_1".into(),
                             name: "weather".into(),
                             raw_input: r#"{"city":"Oslo"}"#.to_string(),
-                            input: serde_json::json!({ "city": "Oslo" }),
+                            input: language_model::LanguageModelToolUseInput::Json(
+                                serde_json::json!({ "city": "Oslo" }),
+                            ),
                             is_input_complete: true,
                             thought_signature: None,
                         }),
@@ -1930,7 +1947,8 @@ mod tests {
                 }],
                 ..Default::default()
             },
-        );
+        )
+        .unwrap();
 
         assert_eq!(request.messages.len(), 1);
         match &request.messages[0] {
diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs
index 362db21070e..518b4a26155 100644
--- a/crates/language_models/src/provider/lmstudio.rs
+++ b/crates/language_models/src/provider/lmstudio.rs
@@ -3,7 +3,7 @@ use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, TaskExt};
+use gpui::{App, AsyncApp, Context, Entity, Subscription, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
@@ -13,7 +13,7 @@ use language_model::{
 use language_model::{
     InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, RateLimiter, Role,
+    LanguageModelRequest, ProviderSettingsView, RateLimiter, Role, SubPageProviderSettings,
 };
 use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models};
 
@@ -252,12 +252,6 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
         IconOrSvg::Icon(IconName::AiLmStudio)
     }
 
-    fn inline_description(&self, _cx: &App) -> Option {
-        Some(InlineDescription::Text(
-            "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(),
-        ))
-    }
-
     fn default_model(&self, _: &App) -> Option> {
         // We shouldn't try to select default model, because it might lead to a load call for an unloaded model.
         // In a constrained environment where user might not have enough resources it'll be a bad UX to select something
@@ -318,19 +312,17 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), _window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+    fn settings_view(&self, _cx: &mut App) -> Option {
+        let state = self.state.clone();
+        Some(ProviderSettingsView::SubPage(
+            SubPageProviderSettings::new(move |window, cx| {
+                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
+                    .into()
+            })
+            .description(InlineDescription::Text(
+                "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(),
+            )),
+        ))
     }
 }
 
@@ -346,7 +338,11 @@ impl LmStudioLanguageModel {
     fn to_lmstudio_request(
         &self,
         request: LanguageModelRequest,
-    ) -> lmstudio::ChatCompletionRequest {
+    ) -> Result {
+        if request.contains_custom_tool_input() {
+            anyhow::bail!("LM Studio does not support custom tools");
+        }
+
         let mut messages = Vec::new();
 
         for message in request.messages {
@@ -373,13 +369,15 @@ impl LmStudioLanguageModel {
                         );
                     }
                     MessageContent::ToolUse(tool_use) => {
+                        let input = tool_use.input.as_json().ok_or_else(|| {
+                            anyhow!("LM Studio does not support custom tool calls")
+                        })?;
                         let tool_call = lmstudio::ToolCall {
                             id: tool_use.id.to_string(),
                             content: lmstudio::ToolCallContent::Function {
                                 function: lmstudio::FunctionContent {
                                     name: tool_use.name.to_string(),
-                                    arguments: serde_json::to_string(&tool_use.input)
-                                        .unwrap_or_default(),
+                                    arguments: serde_json::to_string(input).unwrap_or_default(),
                                 },
                             },
                         };
@@ -425,10 +423,13 @@ impl LmStudioLanguageModel {
             }
         }
 
-        lmstudio::ChatCompletionRequest {
+        Ok(lmstudio::ChatCompletionRequest {
             model: self.model.name.clone(),
             messages,
             stream: true,
+            stream_options: Some(lmstudio::StreamOptions {
+                include_usage: true,
+            }),
             max_tokens: Some(-1),
             stop: Some(request.stop),
             // In LM Studio you can configure specific settings you'd like to use for your model.
@@ -438,20 +439,31 @@ impl LmStudioLanguageModel {
             tools: request
                 .tools
                 .into_iter()
-                .map(|tool| lmstudio::ToolDefinition::Function {
-                    function: lmstudio::FunctionDefinition {
-                        name: tool.name,
-                        description: Some(tool.description),
-                        parameters: Some(tool.input_schema),
-                    },
+                .map(|tool| {
+                    let input_schema = match tool.input {
+                        language_model::LanguageModelRequestToolInput::Function {
+                            input_schema,
+                            ..
+                        } => input_schema,
+                        language_model::LanguageModelRequestToolInput::Custom { .. } => {
+                            return Err(anyhow::anyhow!("LM Studio does not support custom tools"));
+                        }
+                    };
+                    Ok(lmstudio::ToolDefinition::Function {
+                        function: lmstudio::FunctionDefinition {
+                            name: tool.name,
+                            description: Some(tool.description),
+                            parameters: Some(input_schema),
+                        },
+                    })
                 })
-                .collect(),
+                .collect::>()?,
             tool_choice: request.tool_choice.map(|choice| match choice {
                 LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto,
                 LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required,
                 LanguageModelToolChoice::None => lmstudio::ToolChoice::None,
             }),
-        }
+        })
     }
 
     fn stream_completion(
@@ -541,7 +553,10 @@ impl LanguageModel for LmStudioLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = self.to_lmstudio_request(request);
+        let request = match self.to_lmstudio_request(request) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let completions = self.stream_completion(request, cx);
         async move {
             let mapper = LmStudioEventMapper::new();
@@ -579,13 +594,23 @@ impl LmStudioEventMapper {
         &mut self,
         event: lmstudio::ResponseStreamEvent,
     ) -> Vec> {
+        let mut events = Vec::new();
+
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
+        // The final usage summary chunk from OpenAI-compatible servers has an empty choices array.
+        // Return accumulated events instead of treating it as an error.
         let Some(choice) = event.choices.into_iter().next() else {
-            return vec![Err(LanguageModelCompletionError::from(anyhow!(
-                "Response contained no choices"
-            )))];
+            return events;
         };
 
-        let mut events = Vec::new();
         if let Some(content) = choice.delta.content {
             events.push(Ok(LanguageModelCompletionEvent::Text(content)));
         }
@@ -624,15 +649,6 @@ impl LmStudioEventMapper {
             }
         }
 
-        if let Some(usage) = event.usage {
-            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
-                input_tokens: usage.prompt_tokens,
-                output_tokens: usage.completion_tokens,
-                cache_creation_input_tokens: 0,
-                cache_read_input_tokens: 0,
-            })));
-        }
-
         match choice.finish_reason.as_deref() {
             Some("stop") => {
                 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
@@ -645,7 +661,7 @@ impl LmStudioEventMapper {
                                 id: tool_call.id.into(),
                                 name: tool_call.name.into(),
                                 is_input_complete: true,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: tool_call.arguments,
                                 thought_signature: None,
                             },
@@ -679,6 +695,142 @@ struct RawToolCall {
     arguments: String,
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage};
+
+    fn make_event(choices: Vec, usage: Option) -> ResponseStreamEvent {
+        ResponseStreamEvent {
+            created: 0,
+            model: "test-model".to_string(),
+            object: "chat.completion.chunk".to_string(),
+            choices,
+            usage,
+        }
+    }
+
+    fn make_content_choice(content: &str) -> ChoiceDelta {
+        ChoiceDelta {
+            index: 0,
+            delta: ResponseMessageDelta {
+                role: None,
+                content: Some(content.to_string()),
+                reasoning_content: None,
+                tool_calls: None,
+            },
+            finish_reason: None,
+        }
+    }
+
+    fn make_stop_choice() -> ChoiceDelta {
+        ChoiceDelta {
+            index: 0,
+            delta: ResponseMessageDelta {
+                role: None,
+                content: None,
+                reasoning_content: None,
+                tool_calls: None,
+            },
+            finish_reason: Some("stop".to_string()),
+        }
+    }
+
+    // OpenAI-compatible servers send a final chunk with usage data and an empty
+    // choices array. Before this fix, the mapper returned an error for empty
+    // choices, discarding usage entirely.
+    #[test]
+    fn test_usage_in_final_empty_choices_chunk() {
+        let mut mapper = LmStudioEventMapper::new();
+        let event = make_event(
+            vec![],
+            Some(Usage {
+                prompt_tokens: 10,
+                completion_tokens: 20,
+                total_tokens: 30,
+            }),
+        );
+
+        let results: Vec<_> = mapper
+            .map_event(event)
+            .into_iter()
+            .map(|r| r.unwrap())
+            .collect();
+
+        assert_eq!(
+            results,
+            vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: 10,
+                output_tokens: 20,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })]
+        );
+    }
+
+    #[test]
+    fn test_empty_choices_without_usage_returns_empty() {
+        let mut mapper = LmStudioEventMapper::new();
+        let event = make_event(vec![], None);
+
+        let results = mapper.map_event(event);
+
+        assert!(results.is_empty());
+    }
+
+    // Usage data can also arrive in a regular chunk that also contains content.
+    // Both events must be emitted, with UsageUpdate first.
+    #[test]
+    fn test_usage_emitted_alongside_content() {
+        let mut mapper = LmStudioEventMapper::new();
+        let event = make_event(
+            vec![make_content_choice("Hello!")],
+            Some(Usage {
+                prompt_tokens: 5,
+                completion_tokens: 3,
+                total_tokens: 8,
+            }),
+        );
+
+        let results: Vec<_> = mapper
+            .map_event(event)
+            .into_iter()
+            .map(|r| r.unwrap())
+            .collect();
+
+        assert_eq!(
+            results[0],
+            LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: 5,
+                output_tokens: 3,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })
+        );
+        assert_eq!(
+            results[1],
+            LanguageModelCompletionEvent::Text("Hello!".to_string())
+        );
+    }
+
+    #[test]
+    fn test_stop_event_emitted_on_finish_reason() {
+        let mut mapper = LmStudioEventMapper::new();
+        let event = make_event(vec![make_stop_choice()], None);
+
+        let results: Vec<_> = mapper
+            .map_event(event)
+            .into_iter()
+            .map(|r| r.unwrap())
+            .collect();
+
+        assert_eq!(
+            results,
+            vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)]
+        );
+    }
+}
+
 fn add_message_content_part(
     new_part: lmstudio::MessagePart,
     role: Role,
diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs
index de9f385c8e5..a45564b191b 100644
--- a/crates/language_models/src/provider/mistral.rs
+++ b/crates/language_models/src/provider/mistral.rs
@@ -3,15 +3,15 @@ use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
 
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
-    StopReason, TokenUsage, env_var,
+    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView,
+    RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 pub use mistral::{MISTRAL_API_URL, StreamResponse};
 pub use settings::MistralAvailableModel as AvailableModel;
@@ -19,9 +19,7 @@ use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 use language_model::util::{fix_streamed_json, parse_tool_arguments};
 
@@ -222,34 +220,19 @@ impl LanguageModelProvider for MistralLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://console.mistral.ai/api-keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://console.mistral.ai/api-keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -360,7 +343,10 @@ impl LanguageModel for MistralLanguageModel {
         >,
     > {
         let (request, affinity) =
-            into_mistral(request, self.model.clone(), self.max_output_tokens());
+            match into_mistral(request, self.model.clone(), self.max_output_tokens()) {
+                Ok(request) => request,
+                Err(error) => return async move { Err(error.into()) }.boxed(),
+            };
         let stream = self.stream_completion(request, affinity, cx);
 
         async move {
@@ -376,7 +362,11 @@ pub fn into_mistral(
     request: LanguageModelRequest,
     model: mistral::Model,
     max_output_tokens: Option,
-) -> (mistral::Request, Option) {
+) -> Result<(mistral::Request, Option)> {
+    if request.contains_custom_tool_input() {
+        anyhow::bail!("Mistral does not support custom tools");
+    }
+
     let stream = true;
 
     let mut messages = Vec::new();
@@ -469,13 +459,15 @@ pub fn into_mistral(
                         MessageContent::Image(_) => {}
                         MessageContent::Compaction(_) => {}
                         MessageContent::ToolUse(tool_use) => {
+                            let input = tool_use.input.as_json().ok_or_else(|| {
+                                anyhow!("Mistral does not support custom tool calls")
+                            })?;
                             let tool_call = mistral::ToolCall {
                                 id: tool_use.id.to_string(),
                                 content: mistral::ToolCallContent::Function {
                                     function: mistral::FunctionContent {
                                         name: tool_use.name.to_string(),
-                                        arguments: serde_json::to_string(&tool_use.input)
-                                            .unwrap_or_default(),
+                                        arguments: serde_json::to_string(input).unwrap_or_default(),
                                     },
                                 },
                             };
@@ -533,7 +525,7 @@ pub fn into_mistral(
         }
     }
 
-    (
+    Ok((
         mistral::Request {
             model: model.id().to_string(),
             messages,
@@ -567,17 +559,28 @@ pub fn into_mistral(
             tools: request
                 .tools
                 .into_iter()
-                .map(|tool| mistral::ToolDefinition::Function {
-                    function: mistral::FunctionDefinition {
-                        name: tool.name,
-                        description: Some(tool.description),
-                        parameters: Some(tool.input_schema),
-                    },
+                .map(|tool| {
+                    let input_schema = match tool.input {
+                        language_model::LanguageModelRequestToolInput::Function {
+                            input_schema,
+                            ..
+                        } => input_schema,
+                        language_model::LanguageModelRequestToolInput::Custom { .. } => {
+                            return Err(anyhow::anyhow!("Mistral does not support custom tools"));
+                        }
+                    };
+                    Ok(mistral::ToolDefinition::Function {
+                        function: mistral::FunctionDefinition {
+                            name: tool.name,
+                            description: Some(tool.description),
+                            parameters: Some(input_schema),
+                        },
+                    })
                 })
-                .collect(),
+                .collect::>()?,
         },
         request.thread_id,
-    )
+    ))
 }
 
 pub struct MistralEventMapper {
@@ -681,7 +684,7 @@ impl MistralEventMapper {
                                 id: entry.id.clone().into(),
                                 name: entry.name.as_str().into(),
                                 is_input_complete: false,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: entry.arguments.clone(),
                                 thought_signature: None,
                             },
@@ -738,7 +741,7 @@ impl MistralEventMapper {
                         id: tool_call.id.into(),
                         name: tool_call.name.into(),
                         is_input_complete: true,
-                        input,
+                        input: language_model::LanguageModelToolUseInput::Json(input),
                         raw_input: tool_call.arguments,
                         thought_signature: None,
                     },
@@ -765,145 +768,6 @@ struct RawToolCall {
     arguments: String,
 }
 
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor =
-            cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
-
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    // We don't log an error, because "not signed in" is also an error.
-                    let _ = task.await;
-                }
-
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_api_key_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = MistralLanguageModelProvider::api_url(cx);
-            if api_url == MISTRAL_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials...")).into_any()
-        } else if self.should_render_api_key_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:"))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create one by visiting"))
-                                .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys"))
-                        )
-                        .child(
-                            ListBulletItem::new("Ensure your Mistral account has credits")
-                        )
-                        .child(
-                            ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
-                        ),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(
-                        format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
-                    )
-                    .size(LabelSize::Small).color(Color::Muted),
-                )
-                .into_any()
-        } else {
-            v_flex()
-                .size_full()
-                .gap_1()
-                .child(
-                    ConfiguredApiCard::new("mistral-reset-key", configured_card_label)
-                        .disabled(env_var_set)
-                        .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                        .when(env_var_set, |this| {
-                            this.tooltip_label(format!(
-                                "To reset your API key, \
-                                unset the {API_KEY_ENV_VAR_NAME} environment variable."
-                            ))
-                        }),
-                )
-                .into_any()
-        }
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -969,7 +833,10 @@ mod tests {
 
         assert_eq!(tool_use.id.to_string(), "real_id_123");
         assert_eq!(tool_use.name.as_ref(), "read_file");
-        assert_eq!(tool_use.input, serde_json::json!({"path": "a.txt"}));
+        assert_eq!(
+            tool_use.input,
+            language_model::LanguageModelToolUseInput::Json(serde_json::json!({"path": "a.txt"}))
+        );
     }
 
     #[test]
@@ -1010,7 +877,7 @@ mod tests {
         };
 
         let (mistral_request, affinity) =
-            into_mistral(request, mistral::Model::MistralSmallLatest, None);
+            into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
 
         assert_eq!(mistral_request.model, "mistral-small-latest");
         assert_eq!(mistral_request.temperature, Some(0.5));
@@ -1046,7 +913,8 @@ mod tests {
             compact_at_tokens: None,
         };
 
-        let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None);
+        let (mistral_request, _) =
+            into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
 
         assert_eq!(mistral_request.messages.len(), 1);
         assert!(matches!(
diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs
index 7bbc2ec18cd..9ef157f9655 100644
--- a/crates/language_models/src/provider/ollama.rs
+++ b/crates/language_models/src/provider/ollama.rs
@@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use futures::{Stream, TryFutureExt, stream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
+use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription,
@@ -12,7 +12,8 @@ use language_model::{
     LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool,
     LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent,
-    RateLimiter, Role, StopReason, TokenUsage, env_var,
+    ProviderSettingsView, RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage,
+    env_var,
 };
 use menu;
 use ollama::{
@@ -276,12 +277,6 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
         IconOrSvg::Icon(IconName::AiOllama)
     }
 
-    fn inline_description(&self, _cx: &App) -> Option {
-        Some(InlineDescription::Text(
-            "Run local models on your machine with Ollama.".into(),
-        ))
-    }
-
     fn default_model(&self, _: &App) -> Option> {
         // We shouldn't try to select default model, because it might lead to a load call for an unloaded model.
         // In a constrained environment where user might not have enough resources it'll be a bad UX to select something
@@ -341,20 +336,17 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
+    fn settings_view(&self, _cx: &mut App) -> Option {
         let state = self.state.clone();
-        cx.new(|cx| ConfigurationView::new(state, window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+        Some(ProviderSettingsView::SubPage(
+            SubPageProviderSettings::new(move |window, cx| {
+                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
+                    .into()
+            })
+            .description(InlineDescription::Text(
+                "Run local models on your machine with Ollama.".into(),
+            )),
+        ))
     }
 }
 
@@ -368,7 +360,11 @@ pub struct OllamaLanguageModel {
 }
 
 impl OllamaLanguageModel {
-    fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest {
+    fn to_ollama_request(&self, request: LanguageModelRequest) -> Result {
+        if request.contains_custom_tool_input() {
+            anyhow::bail!("Ollama does not support custom tools");
+        }
+
         let supports_vision = self.model.supports_vision.unwrap_or(false);
 
         let mut messages = Vec::with_capacity(request.messages.len());
@@ -430,7 +426,16 @@ impl OllamaLanguageModel {
                                     id: tool_use.id.to_string(),
                                     function: OllamaFunctionCall {
                                         name: tool_use.name.to_string(),
-                                        arguments: tool_use.input,
+                                        arguments: match tool_use.input {
+                                            language_model::LanguageModelToolUseInput::Json(
+                                                input,
+                                            ) => input,
+                                            language_model::LanguageModelToolUseInput::Text(_) => {
+                                                return Err(anyhow::anyhow!(
+                                                    "Ollama does not support custom tool calls"
+                                                ));
+                                            }
+                                        },
                                     },
                                 });
                             }
@@ -453,7 +458,7 @@ impl OllamaLanguageModel {
                 }),
             }
         }
-        ChatRequest {
+        Ok(ChatRequest {
             model: self.model.name.clone(),
             messages,
             keep_alive: self.model.keep_alive.clone().unwrap_or_default(),
@@ -476,11 +481,15 @@ impl OllamaLanguageModel {
                 .supports_thinking
                 .map(|supports_thinking| supports_thinking && request.thinking_allowed),
             tools: if self.model.supports_tools.unwrap_or(false) {
-                request.tools.into_iter().map(tool_into_ollama).collect()
+                request
+                    .tools
+                    .into_iter()
+                    .map(tool_into_ollama)
+                    .collect::>()?
             } else {
                 vec![]
             },
-        }
+        })
     }
 }
 
@@ -544,7 +553,10 @@ impl LanguageModel for OllamaLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = self.to_ollama_request(request);
+        let request = match self.to_ollama_request(request) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
 
         let http_client = self.http_client.clone();
         let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| {
@@ -629,7 +641,9 @@ fn map_to_language_model_completion_events(
                             id: LanguageModelToolUseId::from(id),
                             name: Arc::from(function.name),
                             raw_input: function.arguments.to_string(),
-                            input: function.arguments,
+                            input: language_model::LanguageModelToolUseInput::Json(
+                                function.arguments,
+                            ),
                             is_input_complete: true,
                             thought_signature: None,
                         });
@@ -1138,14 +1152,22 @@ fn merge_settings_into_models(
     }
 }
 
-fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
-    ollama::OllamaTool::Function {
+fn tool_into_ollama(tool: LanguageModelRequestTool) -> Result {
+    let input_schema = match tool.input {
+        language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
+            input_schema
+        }
+        language_model::LanguageModelRequestToolInput::Custom { .. } => {
+            anyhow::bail!("Ollama does not support custom tools");
+        }
+    };
+    Ok(ollama::OllamaTool::Function {
         function: OllamaFunctionTool {
             name: tool.name,
             description: Some(tool.description),
-            parameters: Some(tool.input_schema),
+            parameters: Some(input_schema),
         },
-    }
+    })
 }
 
 #[cfg(test)]
diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs
index 87be0e2751b..eabb0ed5032 100644
--- a/crates/language_models/src/provider/open_ai.rs
+++ b/crates/language_models/src/provider/open_ai.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
 use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg,
@@ -10,20 +10,17 @@ use language_model::{
     LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME,
-    RateLimiter, env_var,
+    ProviderSettingsView, RateLimiter, env_var,
 };
-use menu;
 use open_ai::{
-    OPEN_AI_API_URL, ResponseStreamEvent,
+    ResponseStreamEvent,
     responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response},
     stream_completion,
 };
 use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 pub use open_ai::completion::{
     ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai,
@@ -204,34 +201,19 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://platform.openai.com/api-keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://platform.openai.com/api-keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 
     fn fast_mode_confirmation(&self, _cx: &App) -> Option {
@@ -482,6 +464,9 @@ impl LanguageModel for OpenAiLanguageModel {
             | Model::FivePointFourPro
             | Model::FivePointFive
             | Model::FivePointFivePro
+            | Model::FivePointSixSol
+            | Model::FivePointSixTerra
+            | Model::FivePointSixLuna
             | Model::O3 => true,
             Model::Four => false,
             Model::Custom {
@@ -571,7 +556,7 @@ impl LanguageModel for OpenAiLanguageModel {
             }
             .boxed()
         } else {
-            let request = into_open_ai(
+            let request = match into_open_ai(
                 request,
                 self.model.id(),
                 self.model.supports_parallel_tool_calls(),
@@ -580,7 +565,10 @@ impl LanguageModel for OpenAiLanguageModel {
                 ChatCompletionMaxTokensParameter::MaxCompletionTokens,
                 None,
                 false,
-            );
+            ) {
+                Ok(request) => request,
+                Err(error) => return async move { Err(error.into()) }.boxed(),
+            };
             let completions = self.stream_completion(request, cx);
             async move {
                 let mapper = OpenAiEventMapper::new();
@@ -590,183 +578,3 @@ impl LanguageModel for OpenAiLanguageModel {
         }
     }
 }
-
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor = cx.new(|cx| {
-            InputField::new(
-                window,
-                cx,
-                "sk-000000000000000000000000000000000000000000000000",
-            )
-        });
-
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    // We don't log an error, because "not signed in" is also an error.
-                    let _ = task.await;
-                }
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |input, cx| input.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = OpenAiLanguageModelProvider::api_url(cx);
-            if api_url == OPEN_AI_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        let api_key_section = if self.should_render_editor(cx) {
-            v_flex()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:"))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create one by visiting"))
-                                .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
-                        )
-                        .child(
-                            ListBulletItem::new("Ensure your OpenAI account has credits")
-                        )
-                        .child(
-                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
-                        ),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(format!(
-                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
-                    ))
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-                )
-                .child(
-                    Label::new(
-                        "Note that having a subscription for another service like GitHub Copilot won't work.",
-                    )
-                    .size(LabelSize::Small).color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("openai-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
-                })
-                .into_any_element()
-        };
-
-        let compatible_api_section = h_flex()
-            .mt_1p5()
-            .gap_0p5()
-            .flex_wrap()
-            .when(self.should_render_editor(cx), |this| {
-                this.pt_1p5()
-                    .border_t_1()
-                    .border_color(cx.theme().colors().border_variant)
-            })
-            .child(
-                h_flex()
-                    .gap_2()
-                    .child(
-                        Icon::new(IconName::Info)
-                            .size(IconSize::XSmall)
-                            .color(Color::Muted),
-                    )
-                    .child(Label::new("Zed also supports OpenAI-compatible models.")),
-            )
-            .child(
-                Button::new("docs", "Learn More")
-                    .end_icon(
-                        Icon::new(IconName::ArrowUpRight)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .on_click(move |_, _window, cx| {
-                        cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible")
-                    }),
-            );
-
-        if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials…")).into_any()
-        } else {
-            v_flex()
-                .size_full()
-                .child(api_key_section)
-                .child(compatible_api_section)
-                .into_any()
-        }
-    }
-}
diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs
index e0c6f1f16ea..ff67344ac5a 100644
--- a/crates/language_models/src/provider/open_ai_compatible.rs
+++ b/crates/language_models/src/provider/open_ai_compatible.rs
@@ -1,14 +1,14 @@
 use anyhow::Result;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window};
+use gpui::{App, AppContext, AsyncApp, Entity, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolSchemaFormat, RateLimiter,
+    LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, SubPageProviderSettings,
 };
 use open_ai::{
     ResponseStreamEvent,
@@ -144,32 +144,27 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| {
-            ApiCompatibleProviderConfigurationView::new(
-                self.state.clone(),
-                "OpenAI",
-                API_KEY_PLACEHOLDER,
-                window,
-                cx,
-            )
-        })
-        .into()
+    fn settings_view(&self, _cx: &mut App) -> Option {
+        let state = self.state.clone();
+        Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new(
+            move |window, cx| {
+                cx.new(|cx| {
+                    ApiCompatibleProviderConfigurationView::new(
+                        state.clone(),
+                        "OpenAI",
+                        API_KEY_PLACEHOLDER,
+                        window,
+                        cx,
+                    )
+                })
+                .into()
+            },
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -429,7 +424,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
 
         if self.model.capabilities.chat_completions {
             let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model);
-            let request = into_open_ai(
+            let request = match into_open_ai(
                 request,
                 &self.model.name,
                 self.model.capabilities.parallel_tool_calls,
@@ -438,7 +433,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
                 chat_completion_max_tokens_parameter(&self.model),
                 reasoning_effort,
                 self.model.capabilities.interleaved_reasoning,
-            );
+            ) {
+                Ok(request) => request,
+                Err(error) => return async move { Err(error.into()) }.boxed(),
+            };
             let completions = self.stream_completion(request, cx);
             async move {
                 let mapper = OpenAiEventMapper::new();
@@ -693,7 +691,8 @@ mod tests {
             chat_completion_max_tokens_parameter(&model),
             reasoning_effort,
             model.capabilities.interleaved_reasoning,
-        );
+        )
+        .unwrap();
         let serialized = serde_json::to_value(request).unwrap();
 
         assert_eq!(serialized["reasoning_effort"], json!("high"));
diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs
index f41e04972d0..59d4aac3fb7 100644
--- a/crates/language_models/src/provider/open_router.rs
+++ b/crates/language_models/src/provider/open_router.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
 use collections::HashMap;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
@@ -10,7 +10,7 @@ use language_model::{
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
     LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
-    MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
+    MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 use open_router::{
     Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
@@ -18,9 +18,7 @@ use open_router::{
 use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore};
 use std::pin::Pin;
 use std::sync::{Arc, LazyLock};
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 use language_model::util::{fix_streamed_json, parse_tool_arguments};
 
@@ -259,34 +257,19 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://openrouter.ai/keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://openrouter.ai/keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -417,7 +400,11 @@ impl LanguageModel for OpenRouterLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens());
+        let openrouter_request =
+            match into_open_router(request, &self.model, self.max_output_tokens()) {
+                Ok(request) => request,
+                Err(error) => return async move { Err(error.into()) }.boxed(),
+            };
         let request = self.stream_completion(openrouter_request, cx);
         let future = self.request_limiter.stream(async move {
             let response = request.await?;
@@ -431,7 +418,11 @@ pub fn into_open_router(
     request: LanguageModelRequest,
     model: &Model,
     max_output_tokens: Option,
-) -> open_router::Request {
+) -> Result {
+    if request.contains_custom_tool_input() {
+        anyhow::bail!("OpenRouter does not support custom tools");
+    }
+
     // Anthropic models via OpenRouter don't accept reasoning_details being echoed back
     // in requests - it's an output-only field for them. However, Gemini models require
     // the thought signatures to be echoed back for proper reasoning chain continuity.
@@ -489,13 +480,15 @@ pub fn into_open_router(
                     message_added_content = true;
                 }
                 MessageContent::ToolUse(tool_use) => {
+                    let input = tool_use.input.as_json().ok_or_else(|| {
+                        anyhow::anyhow!("OpenRouter does not support custom tool calls")
+                    })?;
                     let tool_call = open_router::ToolCall {
                         id: tool_use.id.to_string(),
                         content: open_router::ToolCallContent::Function {
                             function: open_router::FunctionContent {
                                 name: tool_use.name.to_string(),
-                                arguments: serde_json::to_string(&tool_use.input)
-                                    .unwrap_or_default(),
+                                arguments: serde_json::to_string(input).unwrap_or_default(),
                                 thought_signature: tool_use.thought_signature.clone(),
                             },
                         },
@@ -568,7 +561,7 @@ pub fn into_open_router(
         }
     }
 
-    open_router::Request {
+    Ok(open_router::Request {
         model: model.id().into(),
         messages,
         stream: true,
@@ -597,21 +590,32 @@ pub fn into_open_router(
         tools: request
             .tools
             .into_iter()
-            .map(|tool| open_router::ToolDefinition::Function {
-                function: open_router::FunctionDefinition {
-                    name: tool.name,
-                    description: Some(tool.description),
-                    parameters: Some(tool.input_schema),
-                },
+            .map(|tool| {
+                let input_schema = match tool.input {
+                    language_model::LanguageModelRequestToolInput::Function {
+                        input_schema,
+                        ..
+                    } => input_schema,
+                    language_model::LanguageModelRequestToolInput::Custom { .. } => {
+                        return Err(anyhow::anyhow!("OpenRouter does not support custom tools"));
+                    }
+                };
+                Ok(open_router::ToolDefinition::Function {
+                    function: open_router::FunctionDefinition {
+                        name: tool.name,
+                        description: Some(tool.description),
+                        parameters: Some(input_schema),
+                    },
+                })
             })
-            .collect(),
+            .collect::>()?,
         tool_choice: request.tool_choice.map(|choice| match choice {
             LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
             LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
             LanguageModelToolChoice::None => open_router::ToolChoice::None,
         }),
         provider: model.provider.clone(),
-    }
+    })
 }
 
 fn open_router_session_id(thread_id: Option) -> Option {
@@ -826,7 +830,7 @@ impl OpenRouterEventMapper {
                                 id: entry.id.clone().into(),
                                 name: entry.name.as_str().into(),
                                 is_input_complete: false,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: entry.arguments.clone(),
                                 thought_signature: entry.thought_signature.clone(),
                             },
@@ -849,7 +853,7 @@ impl OpenRouterEventMapper {
                                 id: tool_call.id.clone().into(),
                                 name: tool_call.name.as_str().into(),
                                 is_input_complete: true,
-                                input,
+                                input: language_model::LanguageModelToolUseInput::Json(input),
                                 raw_input: tool_call.arguments.clone(),
                                 thought_signature: tool_call.thought_signature.clone(),
                             },
@@ -886,141 +890,6 @@ struct RawToolCall {
     thought_signature: Option,
 }
 
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor = cx.new(|cx| {
-            InputField::new(
-                window,
-                cx,
-                "sk_or_000000000000000000000000000000000000000000000000",
-            )
-        });
-
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    let _ = task.await;
-                }
-
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = OpenRouterLanguageModelProvider::api_url(cx);
-            if api_url == OPEN_ROUTER_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div()
-                .child(Label::new("Loading credentials..."))
-                .into_any_element()
-        } else if self.should_render_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:"))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create an API key by visiting"))
-                                .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys"))
-                        )
-                        .child(ListBulletItem::new("Ensure your OpenRouter account has credits")
-                        )
-                        .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
-                        ),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(
-                        format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
-                    )
-                    .size(LabelSize::Small).color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("openrouter-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
-                })
-                .into_any_element()
-        }
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1266,7 +1135,7 @@ mod tests {
             ..Default::default()
         };
 
-        let result = into_open_router(request, &model, None);
+        let result = into_open_router(request, &model, None).unwrap();
 
         assert_eq!(
             result.session_id.as_deref(),
@@ -1368,7 +1237,7 @@ mod tests {
             compact_at_tokens: None,
         };
 
-        let result = into_open_router(request, &model, None);
+        let result = into_open_router(request, &model, None).unwrap();
 
         let system_cache = result.messages.iter().find_map(|m| {
             if let open_router::RequestMessage::System { content } = m {
@@ -1507,7 +1376,7 @@ mod tests {
             compact_at_tokens: None,
         };
 
-        let result = into_open_router(request, &model, None);
+        let result = into_open_router(request, &model, None).unwrap();
 
         for message in &result.messages {
             let content = match message {
@@ -1570,7 +1439,7 @@ mod tests {
             compact_at_tokens: None,
         };
 
-        let result = into_open_router(request, &model, None);
+        let result = into_open_router(request, &model, None).unwrap();
 
         for message in &result.messages {
             let content = match message {
diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs
index 2b09032ca51..fca352d8689 100644
--- a/crates/language_models/src/provider/openai_subscribed.rs
+++ b/crates/language_models/src/provider/openai_subscribed.rs
@@ -3,7 +3,7 @@ use base64::Engine as _;
 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
+use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::{
     AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest,
     http::{HeaderName, HeaderValue},
@@ -13,7 +13,7 @@ use language_model::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, ProviderConfigurationView, RateLimiter,
+    LanguageModelToolChoice, ProviderSettingsView, RateLimiter,
 };
 use open_ai::{ReasoningEffort, responses::stream_response};
 use rand::RngCore as _;
@@ -157,10 +157,6 @@ impl OpenAiSubscribedProvider {
         });
     }
 
-    fn sign_out(&self, cx: &mut App) -> Task> {
-        do_sign_out(&self.state.downgrade(), cx)
-    }
-
     fn create_language_model(&self, model: ChatGptModel) -> Arc {
         Arc::new(OpenAiSubscribedLanguageModel {
             id: LanguageModelId::from(model.id().to_string()),
@@ -243,71 +239,48 @@ impl LanguageModelProvider for OpenAiSubscribedProvider {
         }
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        let state = self.state.clone();
-        let http_client = self.http_client.clone();
-        cx.new(|_cx| ConfigurationView {
-            state,
-            http_client,
-            compact: false,
-        })
-        .into()
-    }
-
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        let http_client = self.http_client.clone();
-
-        ProviderConfigurationView::Inline {
-            view: cx
-                .new(|_cx| ConfigurationView {
-                    state,
-                    http_client,
-                    compact: true,
-                })
-                .into(),
-        }
-    }
-
-    fn inline_title(&self, cx: &App) -> Option {
-        if self.state.read(cx).is_authenticated() {
+    fn settings_view(&self, cx: &mut App) -> Option {
+        let is_authenticated = self.state.read(cx).is_authenticated();
+        let title = if is_authenticated {
             None
         } else {
             Some("Configure ChatGPT".into())
-        }
-    }
-
-    fn inline_description(&self, cx: &App) -> Option {
-        if self.state.read(cx).is_authenticated() {
+        };
+        let description = if is_authenticated {
             None
         } else {
             Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into()))
-        }
-    }
+        };
 
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.sign_out(cx)
+        Some(ProviderSettingsView::Inline(
+            language_model::InlineProviderSettings {
+                title,
+                description,
+                create_view: Arc::new({
+                    let state = self.state.clone();
+                    let http_client = self.http_client.clone();
+                    move |_window, cx| {
+                        cx.new(|_cx| ConfigurationView {
+                            state: state.clone(),
+                            http_client: http_client.clone(),
+                            compact: true,
+                        })
+                        .into()
+                    }
+                }),
+            },
+        ))
     }
 
     fn authentication_error_message(&self) -> SharedString {
         "Your ChatGPT subscription session is invalid or has expired. \
-        Sign in again via the Agent Panel settings to continue."
+        Sign in again via Settings > AI > LLM Providers to continue."
             .into()
     }
 
     fn missing_credentials_error_message(&self) -> SharedString {
         "You are not signed in to your ChatGPT account. \
-        Sign in via the Agent Panel settings to continue."
+        Sign in via Settings > AI > LLM Providers to continue."
             .into()
     }
 
@@ -338,6 +311,8 @@ impl LanguageModelProvider for OpenAiSubscribedProvider {
 // approximation; the entries below mirror that file's picker-visible models.
 #[derive(Clone, Debug, PartialEq)]
 enum ChatGptModel {
+    Gpt56Sol,
+    Gpt56Terra,
     Gpt55,
     Gpt54,
     Gpt54Mini,
@@ -345,11 +320,19 @@ enum ChatGptModel {
 
 impl ChatGptModel {
     fn all() -> Vec {
-        vec![Self::Gpt55, Self::Gpt54, Self::Gpt54Mini]
+        vec![
+            Self::Gpt56Sol,
+            Self::Gpt56Terra,
+            Self::Gpt55,
+            Self::Gpt54,
+            Self::Gpt54Mini,
+        ]
     }
 
     fn id(&self) -> &str {
         match self {
+            Self::Gpt56Sol => "gpt-5.6-sol",
+            Self::Gpt56Terra => "gpt-5.6-terra",
             Self::Gpt55 => "gpt-5.5",
             Self::Gpt54 => "gpt-5.4",
             Self::Gpt54Mini => "gpt-5.4-mini",
@@ -358,6 +341,8 @@ impl ChatGptModel {
 
     fn display_name(&self) -> &str {
         match self {
+            Self::Gpt56Sol => "GPT-5.6 Sol",
+            Self::Gpt56Terra => "GPT-5.6 Terra",
             Self::Gpt55 => "GPT-5.5",
             Self::Gpt54 => "GPT-5.4",
             Self::Gpt54Mini => "GPT-5.4 Mini",
@@ -365,11 +350,10 @@ impl ChatGptModel {
     }
 
     fn max_token_count(&self) -> u64 {
-        // All Codex-supported models use a 272K context window in the Codex
-        // backend, even when the raw model exposes a larger context window via the
-        // public API (e.g. gpt-5.4 has max_context_window 1M, but Codex uses
-        // context_window 272K). Source: openai/codex models-manager/models.json.
-        272_000
+        match self {
+            Self::Gpt56Sol | Self::Gpt56Terra => 372_000,
+            Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => 272_000,
+        }
     }
 
     fn max_output_tokens(&self) -> Option {
@@ -383,18 +367,30 @@ impl ChatGptModel {
     }
 
     fn default_reasoning_effort(&self) -> Option {
-        // Codex bundled models all default to Medium reasoning effort.
-        Some(ReasoningEffort::Medium)
+        match self {
+            Self::Gpt56Sol => Some(ReasoningEffort::Low),
+            Self::Gpt56Terra | Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => {
+                Some(ReasoningEffort::Medium)
+            }
+        }
     }
 
     fn supported_reasoning_efforts(&self) -> &'static [ReasoningEffort] {
-        // The Codex backend's supported_reasoning_levels for every model in this list is low/medium/high/xhigh
-        &[
-            ReasoningEffort::Low,
-            ReasoningEffort::Medium,
-            ReasoningEffort::High,
-            ReasoningEffort::XHigh,
-        ]
+        match self {
+            Self::Gpt56Sol | Self::Gpt56Terra => &[
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+                ReasoningEffort::Max,
+            ],
+            Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => &[
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+            ],
+        }
     }
 
     fn supports_parallel_tool_calls(&self) -> bool {
@@ -407,7 +403,7 @@ impl ChatGptModel {
 
     fn supports_priority(&self) -> bool {
         match self {
-            Self::Gpt55 | Self::Gpt54 => true,
+            Self::Gpt56Sol | Self::Gpt56Terra | Self::Gpt55 | Self::Gpt54 => true,
             Self::Gpt54Mini => false,
         }
     }
@@ -476,7 +472,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel {
                     ReasoningEffort::Medium => ("Medium", "medium"),
                     ReasoningEffort::High => ("High", "high"),
                     ReasoningEffort::XHigh => ("Extra High", "xhigh"),
-                    ReasoningEffort::Max => return None, // Not supported by any OpenAI models
+                    ReasoningEffort::Max => ("Max", "max"),
                 };
 
                 Some(LanguageModelEffortLevel {
@@ -787,10 +783,12 @@ async fn do_oauth_flow(
         .query_pairs_mut()
         .append_pair("client_id", CLIENT_ID)
         .append_pair("redirect_uri", &redirect_uri)
-        .append_pair(
-            "scope",
-            "openid profile email offline_access api.connectors.read api.connectors.invoke",
-        )
+        // Deliberately excludes `api.connectors.read api.connectors.invoke`
+        // (which Codex CLI requests): extra scopes inflate the
+        // access-token JWT, and the serialized credentials must fit within
+        // Windows Credential Manager's 2560-byte blob limit
+        // (CRED_MAX_CREDENTIAL_BLOB_SIZE). See #58541.
+        .append_pair("scope", "openid profile email offline_access")
         .append_pair("response_type", "code")
         .append_pair("code_challenge", &challenge)
         .append_pair("code_challenge_method", "S256")
diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs
index f75c71e425b..6604995ffee 100644
--- a/crates/language_models/src/provider/opencode.rs
+++ b/crates/language_models/src/provider/opencode.rs
@@ -3,16 +3,18 @@ use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
+use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
 use http_client::{AsyncBody, CustomHeaders, HttpClient, http};
 use language_model::{
     ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, RateLimiter, ReasoningEffort, env_var,
+    LanguageModelToolChoice, ProviderSettingsView, RateLimiter, ReasoningEffort,
+    SubPageProviderSettings, env_var,
 };
 use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription};
+pub use settings::OpenCodeApiProtocol;
 pub use settings::OpenCodeAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore, update_settings_file};
 use std::sync::{Arc, LazyLock};
@@ -200,12 +202,6 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider {
         IconOrSvg::Icon(IconName::AiOpenCode)
     }
 
-    fn inline_description(&self, _cx: &App) -> Option {
-        Some(InlineDescription::Text(
-            "To use OpenCode models in Zed, you need an API key.".into(),
-        ))
-    }
-
     fn default_model(&self, cx: &App) -> Option> {
         if Self::subscription_enabled(OpenCodeSubscription::Go, cx) {
             // If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go
@@ -268,12 +264,12 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider {
         }
 
         for model in &settings.available_models {
-            let protocol = match model.protocol.as_str() {
-                "anthropic" => ApiProtocol::Anthropic,
-                "openai_responses" => ApiProtocol::OpenAiResponses,
-                "openai_chat" => ApiProtocol::OpenAiChat,
-                "google" => ApiProtocol::Google,
-                _ => ApiProtocol::OpenAiChat, // default fallback
+            let protocol = match model.protocol {
+                Some(OpenCodeApiProtocol::Anthropic) => ApiProtocol::Anthropic,
+                Some(OpenCodeApiProtocol::OpenAiResponses) => ApiProtocol::OpenAiResponses,
+                Some(OpenCodeApiProtocol::OpenAiChat) => ApiProtocol::OpenAiChat,
+                Some(OpenCodeApiProtocol::Google) => ApiProtocol::Google,
+                None => ApiProtocol::OpenAiChat, // default fallback
             };
             let subscription = match model.subscription {
                 Some(settings::OpenCodeModelSubscription::Go) => OpenCodeSubscription::Go,
@@ -311,19 +307,17 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
+    fn settings_view(&self, _cx: &mut App) -> Option {
+        let state = self.state.clone();
+        Some(ProviderSettingsView::SubPage(
+            SubPageProviderSettings::new(move |window, cx| {
+                cx.new(|cx| ConfigurationView::new(state.clone(), window, cx))
+                    .into()
+            })
+            .description(InlineDescription::Text(
+                "To use OpenCode models in Zed, you need an API key.".into(),
+            )),
+        ))
     }
 }
 
@@ -581,6 +575,12 @@ impl LanguageModel for OpenCodeLanguageModel {
             .is_some_and(|levels| levels.iter().any(|effort| *effort != ReasoningEffort::None))
     }
 
+    fn supports_disabling_thinking(&self) -> bool {
+        self.model
+            .supported_reasoning_effort_levels()
+            .is_some_and(|levels| levels.contains(&ReasoningEffort::None))
+    }
+
     fn supported_effort_levels(&self) -> Vec {
         self.model
             .supported_reasoning_effort_levels()
@@ -669,7 +669,7 @@ impl LanguageModel for OpenCodeLanguageModel {
                 } else {
                     anthropic::AnthropicModelMode::Default
                 };
-                let anthropic_request = into_anthropic(
+                let anthropic_request = match into_anthropic(
                     request,
                     self.model.id().to_string(),
                     1.0,
@@ -678,7 +678,10 @@ impl LanguageModel for OpenCodeLanguageModel {
                         .unwrap_or(8192),
                     mode,
                     anthropic::completion::AnthropicPromptCacheMode::Automatic,
-                );
+                ) {
+                    Ok(request) => request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
                 let stream =
                     self.stream_anthropic(anthropic_request, http_client, extra_headers, cx);
                 async move {
@@ -696,16 +699,19 @@ impl LanguageModel for OpenCodeLanguageModel {
                 } else {
                     None
                 };
-                let openai_request = into_open_ai(
+                let openai_request = match into_open_ai(
                     request,
                     self.model.id(),
-                    false,
+                    true,
                     false,
                     self.model.max_output_tokens(self.subscription),
                     ChatCompletionMaxTokensParameter::MaxCompletionTokens,
                     reasoning_effort,
                     self.model.interleaved_reasoning(),
-                );
+                ) {
+                    Ok(request) => request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
                 let stream =
                     self.stream_openai_chat(openai_request, http_client, extra_headers, cx);
                 async move {
@@ -722,7 +728,7 @@ impl LanguageModel for OpenCodeLanguageModel {
                 let response_request = into_open_ai_response(
                     request,
                     self.model.id(),
-                    false,
+                    true,
                     false,
                     self.model.max_output_tokens(self.subscription),
                     None,
@@ -737,11 +743,17 @@ impl LanguageModel for OpenCodeLanguageModel {
                 .boxed()
             }
             ApiProtocol::Google => {
-                let google_request = into_google(
-                    request,
-                    self.model.id().to_string(),
-                    google_ai::GoogleModelMode::Default,
-                );
+                let mode = if self.supports_thinking() && request.thinking_allowed {
+                    google_ai::GoogleModelMode::Thinking {
+                        budget_tokens: None,
+                    }
+                } else {
+                    google_ai::GoogleModelMode::Default
+                };
+                let google_request = match into_google(request, self.model.id().to_string(), mode) {
+                    Ok(request) => request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
                 let stream = self.stream_google(google_request, http_client, extra_headers, cx);
                 async move {
                     let mapper = GoogleEventMapper::new();
diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs
index dcceffe22e7..23868b93747 100644
--- a/crates/language_models/src/provider/vercel_ai_gateway.rs
+++ b/crates/language_models/src/provider/vercel_ai_gateway.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
 use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{
     AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
 };
@@ -11,7 +11,7 @@ use language_model::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolSchemaFormat, RateLimiter, env_var,
+    LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, env_var,
 };
 use open_ai::ResponseStreamEvent;
 use serde::Deserialize;
@@ -19,9 +19,7 @@ pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
 pub use settings::VercelAiGatewayAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel_ai_gateway");
 const PROVIDER_NAME: LanguageModelProviderName =
@@ -246,36 +244,20 @@ impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url:
-                "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway"
-                    .into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway"
+                .into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -469,7 +451,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = crate::provider::open_ai::into_open_ai(
+        let request = match crate::provider::open_ai::into_open_ai(
             request,
             &self.model.name,
             self.model.capabilities.parallel_tool_calls,
@@ -478,7 +460,10 @@ impl LanguageModel for VercelAiGatewayLanguageModel {
             crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens,
             None,
             false,
-        );
+        ) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let completions = self.stream_open_ai(request, cx);
         async move {
             let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
@@ -618,131 +603,3 @@ async fn list_models(
 
     Ok(models)
 }
-
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor =
-            cx.new(|cx| InputField::new(window, cx, "vck_000000000000000000000000000"));
-
-        cx.observe(&state, |_, _, cx| cx.notify()).detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    let _ = task.await;
-                }
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
-            if api_url == API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials...")).into_any()
-        } else if self.should_render_editor(cx) {
-            v_flex()
-                .size_full()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new(
-                    "To use Zed's agent with Vercel AI Gateway, you need to add an API key. Follow these steps:",
-                ))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create an API key in"))
-                                .child(ButtonLink::new(
-                                    "Vercel AI Gateway's console",
-                                    "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway",
-                                )),
-                        )
-                        .child(ListBulletItem::new(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(format!(
-                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.",
-                    ))
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("vercel-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
-                })
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .into_any_element()
-        }
-    }
-}
diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs
index 92121f2e5ff..834b97eb8be 100644
--- a/crates/language_models/src/provider/x_ai.rs
+++ b/crates/language_models/src/provider/x_ai.rs
@@ -2,23 +2,22 @@ use anyhow::Result;
 use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, env_var,
+    LanguageModelToolChoice, LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter,
+    env_var,
 };
 use open_ai::ResponseStreamEvent;
 pub use settings::XaiAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
-use ui_input::InputField;
-use util::ResultExt;
+use ui::IconName;
 use x_ai::XAI_API_URL;
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai");
@@ -193,34 +192,19 @@ impl LanguageModelProvider for XAiLanguageModelProvider {
         self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyView {
-        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
-            .into()
-    }
-
-    fn reset_credentials(&self, cx: &mut App) -> Task> {
-        self.state
-            .update(cx, |state, cx| state.set_api_key(None, cx))
-    }
-
-    fn api_key_configuration(&self, cx: &App) -> Option {
+    fn settings_view(&self, cx: &mut App) -> Option {
         let state = self.state.read(cx);
-        Some(ApiKeyConfiguration {
-            has_key: state.api_key_state.has_key(),
-            is_from_env_var: state.api_key_state.is_from_env_var(),
-            env_var_name: state.api_key_state.env_var_name().clone(),
-            api_key_url: "https://console.x.ai/team/default/api-keys".into(),
-        })
+        Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new(
+            state.api_key_state.has_key(),
+            state.api_key_state.is_from_env_var(),
+            state.api_key_state.env_var_name().clone(),
+            "https://console.x.ai/team/default/api-keys".into(),
+        )))
     }
 
-    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+    fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> {
         self.state
-            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
+            .update(cx, |state, cx| state.set_api_key(api_key, cx))
     }
 }
 
@@ -429,7 +413,7 @@ impl LanguageModel for XAiLanguageModel {
         >,
     > {
         let reasoning_effort = reasoning_effort_for_request(&request, &self.model);
-        let request = crate::provider::open_ai::into_open_ai(
+        let request = match crate::provider::open_ai::into_open_ai(
             request,
             self.model.id(),
             self.model.supports_parallel_tool_calls(),
@@ -438,7 +422,10 @@ impl LanguageModel for XAiLanguageModel {
             crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens,
             reasoning_effort,
             false,
-        );
+        ) {
+            Ok(request) => request,
+            Err(error) => return async move { Err(error.into()) }.boxed(),
+        };
         let completions = self.stream_completion(request, cx);
         async move {
             let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
@@ -448,87 +435,6 @@ impl LanguageModel for XAiLanguageModel {
     }
 }
 
-struct ConfigurationView {
-    api_key_editor: Entity,
-    state: Entity,
-    load_credentials_task: Option>,
-}
-
-impl ConfigurationView {
-    fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self {
-        let api_key_editor = cx.new(|cx| {
-            InputField::new(
-                window,
-                cx,
-                "xai-0000000000000000000000000000000000000000000000000",
-            )
-            .label("API key")
-        });
-
-        cx.observe(&state, |_, _, cx| {
-            cx.notify();
-        })
-        .detach();
-
-        let load_credentials_task = Some(cx.spawn_in(window, {
-            let state = state.clone();
-            async move |this, cx| {
-                if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
-                    // We don't log an error, because "not signed in" is also an error.
-                    let _ = task.await;
-                }
-                this.update(cx, |this, cx| {
-                    this.load_credentials_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
-
-        Self {
-            api_key_editor,
-            state,
-            load_credentials_task,
-        }
-    }
-
-    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) {
-        self.api_key_editor
-            .update(cx, |input, cx| input.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_api_key(None, cx))
-                .await
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn should_render_editor(&self, cx: &mut Context) -> bool {
-        !self.state.read(cx).is_authenticated()
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -578,64 +484,3 @@ mod tests {
         );
     }
 }
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            let api_url = XAiLanguageModelProvider::api_url(cx);
-            if api_url == XAI_API_URL {
-                "API key configured".to_string()
-            } else {
-                format!("API key configured for {}", api_url)
-            }
-        };
-
-        let api_key_section = if self.should_render_editor(cx) {
-            v_flex()
-                .on_action(cx.listener(Self::save_api_key))
-                .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:"))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Create one by visiting"))
-                                .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys"))
-                        )
-                        .child(
-                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
-                        ),
-                )
-                .child(self.api_key_editor.clone())
-                .child(
-                    Label::new(format!(
-                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
-                    ))
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-                )
-                .child(
-                    Label::new("Note that xAI is a custom OpenAI-compatible provider.")
-                        .size(LabelSize::Small)
-                        .color(Color::Muted),
-                )
-                .into_any_element()
-        } else {
-            ConfiguredApiCard::new("xai-reset-key", configured_card_label)
-                .disabled(env_var_set)
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
-                })
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .into_any_element()
-        };
-
-        if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials…")).into_any()
-        } else {
-            v_flex().size_full().child(api_key_section).into_any()
-        }
-    }
-}
diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs
index 1ce7caa3a67..4f360eea95c 100644
--- a/crates/language_models/src/settings.rs
+++ b/crates/language_models/src/settings.rs
@@ -95,6 +95,7 @@ impl settings::Settings for AllLanguageModelSettings {
                 .collect(),
             bedrock: AmazonBedrockSettings {
                 available_models: bedrock.available_models.unwrap_or_default(),
+                mantle_available_models: bedrock.mantle_available_models.unwrap_or_default(),
                 custom_headers: custom_headers_from(
                     "Amazon Bedrock",
                     bedrock.custom_headers,
diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs
index 07be3150c0c..46060e92c7b 100644
--- a/crates/language_models_cloud/src/language_models_cloud.rs
+++ b/crates/language_models_cloud/src/language_models_cloud.rs
@@ -462,7 +462,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
 
                 if enable_thinking && effort.is_some() {
                     request.thinking = Some(anthropic::Thinking::Adaptive {
@@ -592,7 +595,7 @@ impl LanguageModel for CloudLanguageModel {
                 let http_client = self.http_client.clone();
                 let token_provider = self.token_provider.clone();
-                let request = into_open_ai(
+                let request = match into_open_ai(
                     request,
                     &self.model.id.0,
                     self.model.supports_parallel_tool_calls,
@@ -601,7 +604,10 @@ impl LanguageModel for CloudLanguageModel request,
+                    Err(error) => return async move { Err(error.into()) }.boxed(),
+                };
                 let auth_context = token_provider.auth_context(cx);
                 let future = self.request_limiter.stream(async move {
                     let PerformLlmCompletionResponse {
@@ -640,7 +646,11 @@ impl LanguageModel for CloudLanguageModel request,
+                        Err(error) => return async move { Err(error.into()) }.boxed(),
+                    };
                 let auth_context = token_provider.auth_context(cx);
                 let future = self.request_limiter.stream(async move {
                     let PerformLlmCompletionResponse {
diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs
index 22b2795145f..625e48b0877 100644
--- a/crates/language_tools/src/lsp_button.rs
+++ b/crates/language_tools/src/lsp_button.rs
@@ -724,6 +724,19 @@ impl LanguageServers {
     fn is_empty(&self) -> bool {
         self.binary_statuses.is_empty() && self.health_statuses.is_empty()
     }
+
+    /// Drop all id-keyed state for a server that has been removed (stopped or
+    /// reaching end-of-life via restart). `binary_statuses` is intentionally
+    /// preserved — it is keyed by name and shared across restart cycles to
+    /// drive the "Downloading… → Starting…" status UX.
+    fn remove_server(&mut self, server_id: LanguageServerId) {
+        self.health_statuses.remove(&server_id);
+        self.servers_per_buffer_abs_path
+            .retain(|_, servers_for_path| {
+                servers_for_path.servers.remove(&server_id);
+                !servers_for_path.servers.is_empty()
+            });
+    }
 }
 
 #[derive(Debug)]
@@ -903,7 +916,6 @@ impl LspButton {
         let mut updated = false;
 
         // TODO `LspStore` is global and reports status from all language servers, even from the other windows.
-        // Also, we do not get "LSP removed" events so LSPs are never removed.
         match e {
             LspStoreEvent::LanguageServerUpdate {
                 language_server_id,
@@ -993,6 +1005,12 @@ impl LspButton {
                 });
                 updated = true;
             }
+            LspStoreEvent::LanguageServerRemoved(server_id) => {
+                self.server_state.update(cx, |state, _| {
+                    state.language_servers.remove_server(*server_id);
+                });
+                updated = true;
+            }
             _ => {}
         };
 
@@ -1406,3 +1424,163 @@ impl Render for LspButton {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn server_id(n: usize) -> LanguageServerId {
+        LanguageServerId(n)
+    }
+
+    fn server_name(s: &str) -> LanguageServerName {
+        LanguageServerName(s.into())
+    }
+
+    fn health_status(name: &str) -> LanguageServerHealthStatus {
+        LanguageServerHealthStatus {
+            name: server_name(name),
+            health: Some((None, ServerHealth::Ok)),
+        }
+    }
+
+    fn servers_for_path(servers: &[(LanguageServerId, &str)]) -> ServersForPath {
+        ServersForPath {
+            servers: servers
+                .iter()
+                .map(|(id, name)| (*id, Some(server_name(name))))
+                .collect(),
+            worktree: None,
+        }
+    }
+
+    /// `remove_server` evicts the id from `health_statuses` so a restarted
+    /// server's new id renders without inheriting the old one's stale entry.
+    /// This is the regression test for #53627.
+    #[test]
+    fn remove_server_drops_health_entry_for_id() {
+        let mut state = LanguageServers::default();
+        state
+            .health_statuses
+            .insert(server_id(1), health_status("rust-analyzer"));
+        state
+            .health_statuses
+            .insert(server_id(2), health_status("typescript-language-server"));
+
+        state.remove_server(server_id(1));
+
+        assert!(!state.health_statuses.contains_key(&server_id(1)));
+        assert!(state.health_statuses.contains_key(&server_id(2)));
+    }
+
+    /// `remove_server` evicts the id from each per-buffer entry; entries that
+    /// become empty are dropped so the map does not grow unbounded across
+    /// many buffer opens/closes.
+    #[test]
+    fn remove_server_evicts_id_from_per_buffer_entries_and_drops_empty_entries() {
+        let mut state = LanguageServers::default();
+        let buffer_a = PathBuf::from("/project/a.rs");
+        let buffer_b = PathBuf::from("/project/b.rs");
+
+        state.servers_per_buffer_abs_path.insert(
+            buffer_a.clone(),
+            servers_for_path(&[(server_id(1), "rust-analyzer")]),
+        );
+        state.servers_per_buffer_abs_path.insert(
+            buffer_b.clone(),
+            servers_for_path(&[(server_id(1), "rust-analyzer"), (server_id(2), "typos-lsp")]),
+        );
+
+        state.remove_server(server_id(1));
+
+        assert!(
+            !state.servers_per_buffer_abs_path.contains_key(&buffer_a),
+            "buffer_a's entry held only the removed server, so the entry itself should be dropped",
+        );
+        let buffer_b_entry = state
+            .servers_per_buffer_abs_path
+            .get(&buffer_b)
+            .expect("buffer_b's entry has another server, so it must be retained");
+        assert!(!buffer_b_entry.servers.contains_key(&server_id(1)));
+        assert!(buffer_b_entry.servers.contains_key(&server_id(2)));
+    }
+
+    /// `binary_statuses` is keyed by name and intentionally shared across
+    /// restart cycles to drive the "Downloading… → Starting…" UX. Removing a
+    /// single server's id must not touch it.
+    #[test]
+    fn remove_server_does_not_touch_binary_statuses() {
+        let mut state = LanguageServers::default();
+        state.binary_statuses.insert(
+            server_name("rust-analyzer"),
+            LanguageServerBinaryStatus {
+                status: BinaryStatus::Starting,
+                message: None,
+            },
+        );
+
+        state.remove_server(server_id(1));
+
+        assert!(
+            state
+                .binary_statuses
+                .contains_key(&server_name("rust-analyzer")),
+            "binary_statuses is name-keyed and shared across restart cycles",
+        );
+    }
+
+    /// Simulates the full restart event sequence: remove old id, register
+    /// new id with same name, write health for the new id. After restart
+    /// only the new id should be visible — no leftover entry from the old
+    /// incarnation.
+    #[test]
+    fn restart_sequence_leaves_only_new_server_id() {
+        let mut state = LanguageServers::default();
+        let buffer = PathBuf::from("/project/main.rs");
+        let name = "rust-analyzer";
+
+        // Pre-restart: server v1 is registered for the buffer with health.
+        state
+            .servers_per_buffer_abs_path
+            .insert(buffer.clone(), servers_for_path(&[(server_id(1), name)]));
+        state
+            .health_statuses
+            .insert(server_id(1), health_status(name));
+
+        // Restart: old id is removed.
+        state.remove_server(server_id(1));
+
+        // New id registers for the same buffer.
+        let entry = state
+            .servers_per_buffer_abs_path
+            .entry(buffer.clone())
+            .or_insert_with(|| ServersForPath {
+                servers: HashMap::default(),
+                worktree: None,
+            });
+        entry.servers.insert(server_id(2), Some(server_name(name)));
+
+        // Health update for the new id arrives.
+        state
+            .health_statuses
+            .insert(server_id(2), health_status(name));
+
+        let entry = state
+            .servers_per_buffer_abs_path
+            .get(&buffer)
+            .expect("buffer must still be tracked");
+        assert_eq!(
+            entry.servers.keys().copied().collect::>(),
+            vec![server_id(2)],
+            "exactly one server for this buffer — the new incarnation",
+        );
+        assert!(
+            !state.health_statuses.contains_key(&server_id(1)),
+            "the dead server's health entry must not linger",
+        );
+        assert!(
+            state.health_statuses.contains_key(&server_id(2)),
+            "the new server's health entry is present",
+        );
+    }
+}
diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs
index 13a4fcc9bdf..87efea16c85 100644
--- a/crates/languages/src/go.rs
+++ b/crates/languages/src/go.rs
@@ -945,6 +945,7 @@ mod tests {
     use gpui::{AppContext, Hsla, TestAppContext};
     use task::TaskContext;
     use theme::SyntaxTheme;
+    use unindent::Unindent as _;
 
     fn go_language() -> Arc {
         let language = language("go", tree_sitter_go::LANGUAGE.into());
@@ -2017,6 +2018,89 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    fn test_go_outline_includes_methods_with_receiver_forms(cx: &mut TestAppContext) {
+        let language = go_language();
+
+        let source = r#"
+        package main
+
+        type v2 struct{}
+
+        func (v2) BrokenMethod() {
+            println("start")
+        }
+
+        func (_ v2) UnderscoreReceiverMethod() {
+            println("start")
+        }
+
+        func (v v2) NamedReceiverMethod() {
+            println("start")
+        }
+
+        func (v *v2) PointerReceiverMethod() {
+            println("start")
+        }
+
+        func WorkingFunction() {
+            println("start")
+        }
+        "#
+        .unindent();
+
+        let buffer =
+            cx.new(|cx| crate::Buffer::local(source.clone(), cx).with_language(language, cx));
+        let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+        let outline = snapshot.outline(None);
+
+        assert_eq!(
+            outline
+                .items
+                .iter()
+                .map(|item| item.text.as_str())
+                .collect::>(),
+            &[
+                "type v2",
+                "func (v2) BrokenMethod",
+                "func (_ v2) UnderscoreReceiverMethod",
+                "func (v v2) NamedReceiverMethod",
+                "func (v *v2) PointerReceiverMethod",
+                "func WorkingFunction",
+            ]
+        );
+
+        for (method_name, expected_symbol) in [
+            ("BrokenMethod", "func (v2) BrokenMethod"),
+            (
+                "UnderscoreReceiverMethod",
+                "func (_ v2) UnderscoreReceiverMethod",
+            ),
+            ("NamedReceiverMethod", "func (v v2) NamedReceiverMethod"),
+            (
+                "PointerReceiverMethod",
+                "func (v *v2) PointerReceiverMethod",
+            ),
+            ("WorkingFunction", "func WorkingFunction"),
+        ] {
+            let method_position = source
+                .find(&format!("{method_name}()"))
+                .expect("method should exist in source");
+            let body_position = source[method_position..]
+                .find("println")
+                .map(|body_offset| method_position + body_offset)
+                .expect("method should contain a body");
+            let symbols = snapshot.symbols_containing(body_position, None);
+            assert_eq!(
+                symbols
+                    .last()
+                    .map(|item| item.text.as_str())
+                    .expect("method should have an outline symbol"),
+                expected_symbol
+            );
+        }
+    }
+
     #[test]
     fn test_extract_subtest_name() {
         // Interpreted string literal
diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs
index 5572e43f049..e8d18840e0f 100644
--- a/crates/languages/src/typescript.rs
+++ b/crates/languages/src/typescript.rs
@@ -1433,6 +1433,184 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_conditional_test_wrappers(cx: &mut TestAppContext) {
+        for language in [
+            crate::language(
+                "typescript",
+                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
+            ),
+            crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
+            crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()),
+        ] {
+            let text = r#"
+                it.runIf(true)("runIf test", () => {
+                    true;
+                });
+
+                it.skipIf(false)("skipIf test", () => {
+                    true;
+                });
+
+                test.runIf(true)("runIf test 2", () => {
+                    true;
+                });
+
+                test.skipIf(false)("skipIf test 2", () => {
+                    true;
+                });
+
+                describe.runIf(true)("runIf describe", () => {
+                    it("inner test", () => {
+                        true;
+                    });
+                });
+
+                describe.skipIf(false)("skipIf describe", () => {
+                    it("inner test 2", () => {
+                        true;
+                    });
+                });
+
+                it.todoIf(false)("todoIf test", () => {
+                    true;
+                });
+
+                it.if(true)("if test", () => {
+                    true;
+                });
+
+                test.todoIf(false)("todoIf test 2", () => {
+                    true;
+                });
+
+                test.if(true)("if test 2", () => {
+                    true;
+                });
+
+                describe.todoIf(false)("todoIf describe", () => {
+                    it("inner todoIf", () => {
+                        true;
+                    });
+                });
+
+                describe.if(true)("if describe", () => {
+                    it("inner if", () => {
+                        true;
+                    });
+                });
+
+                test.failing("failing test", () => {
+                    true;
+                });
+
+                it.failing("failing it", () => {
+                    true;
+                });
+
+                it.each([1, 2, 3])("each test", () => {
+                    true;
+                });
+
+                describe.each([1, 2])("each describe", () => {
+                    it("inner each", () => {
+                        true;
+                    });
+                });
+
+                it.skip("skip test", () => {
+                    true;
+                });
+
+                it.only("only test", () => {
+                    true;
+                });
+
+                it.todo("todo test");
+            "#
+            .unindent();
+
+            let text_len = text.len();
+            let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
+            cx.executor().run_until_parked();
+
+            let outline = buffer.update(cx, |buffer, _cx| buffer.snapshot().outline(None));
+            let outline_names = outline
+                .items
+                .iter()
+                .map(|item| item.text.as_str())
+                .collect::>();
+            assert_eq!(
+                outline_names,
+                [
+                    "runIf test",
+                    "skipIf test",
+                    "runIf test 2",
+                    "skipIf test 2",
+                    "runIf describe",
+                    "it inner test",
+                    "skipIf describe",
+                    "it inner test 2",
+                    "todoIf test",
+                    "if test",
+                    "todoIf test 2",
+                    "if test 2",
+                    "todoIf describe",
+                    "it inner todoIf",
+                    "if describe",
+                    "it inner if",
+                    "test.failing failing test",
+                    "it.failing failing it",
+                    "each test",
+                    "each describe",
+                    "it inner each",
+                    "it.skip skip test",
+                    "it.only only test",
+                    "it.todo todo test",
+                ]
+            );
+
+            let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
+            let runnable_names = snapshot
+                .runnable_ranges(0..text_len)
+                .map(|runnable| {
+                    snapshot
+                        .text_for_range(runnable.run_range)
+                        .collect::()
+                })
+                .collect::>();
+            assert_eq!(
+                runnable_names,
+                [
+                    "runIf test",
+                    "skipIf test",
+                    "runIf test 2",
+                    "skipIf test 2",
+                    "runIf describe",
+                    "inner test",
+                    "skipIf describe",
+                    "inner test 2",
+                    "todoIf test",
+                    "if test",
+                    "todoIf test 2",
+                    "if test 2",
+                    "todoIf describe",
+                    "inner todoIf",
+                    "if describe",
+                    "inner if",
+                    "failing test",
+                    "failing it",
+                    "each test",
+                    "each describe",
+                    "inner each",
+                    "skip test",
+                    "only test",
+                    "todo test",
+                ]
+            );
+        }
+    }
+
     #[gpui::test]
     async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
         cx.update(|cx| {
diff --git a/crates/livekit_api/Cargo.toml b/crates/livekit_api/Cargo.toml
index 2b2438c25e6..80ba67d9941 100644
--- a/crates/livekit_api/Cargo.toml
+++ b/crates/livekit_api/Cargo.toml
@@ -13,6 +13,9 @@ workspace = true
 path = "src/livekit_api.rs"
 doctest = false
 
+[features]
+test-support = []
+
 [dependencies]
 anyhow.workspace = true
 async-trait.workspace = true
diff --git a/crates/livekit_api/src/livekit_api.rs b/crates/livekit_api/src/livekit_api.rs
index 745f511b12e..250f56b3fd9 100644
--- a/crates/livekit_api/src/livekit_api.rs
+++ b/crates/livekit_api/src/livekit_api.rs
@@ -31,10 +31,25 @@ pub struct LiveKitClient {
     url: Arc,
     key: Arc,
     secret: Arc,
+    timestamp_source: Arc,
 }
 
 impl LiveKitClient {
-    pub fn new(mut url: String, key: String, secret: String) -> Self {
+    pub fn new(url: String, key: String, secret: String) -> Self {
+        Self::new_with_timestamp_source(
+            url,
+            key,
+            secret,
+            Arc::new(token::SystemUnixTimestampSource),
+        )
+    }
+
+    pub(crate) fn new_with_timestamp_source(
+        mut url: String,
+        key: String,
+        secret: String,
+        timestamp_source: Arc,
+    ) -> Self {
         if url.ends_with('/') {
             url.pop();
         }
@@ -47,6 +62,7 @@ impl LiveKitClient {
             url: url.into(),
             key: key.into(),
             secret: secret.into(),
+            timestamp_source,
         }
     }
 
@@ -61,7 +77,13 @@ impl LiveKitClient {
         Res: Default + Message,
     {
         let client = self.http.clone();
-        let token = token::create(&self.key, &self.secret, None, grant);
+        let token = token::create_with_timestamp_source(
+            &self.key,
+            &self.secret,
+            None,
+            grant,
+            self.timestamp_source.as_ref(),
+        );
         let url = format!("{}/{}", self.url, path);
         log::info!("Request {}: {:?}", url, body);
         async move {
@@ -163,20 +185,22 @@ impl Client for LiveKitClient {
     }
 
     fn room_token(&self, room: &str, identity: &str) -> Result {
-        token::create(
+        token::create_with_timestamp_source(
             &self.key,
             &self.secret,
             Some(identity),
             token::VideoGrant::to_join(room),
+            self.timestamp_source.as_ref(),
         )
     }
 
     fn guest_token(&self, room: &str, identity: &str) -> Result {
-        token::create(
+        token::create_with_timestamp_source(
             &self.key,
             &self.secret,
             Some(identity),
             token::VideoGrant::for_guest(room),
+            self.timestamp_source.as_ref(),
         )
     }
 }
diff --git a/crates/livekit_api/src/token.rs b/crates/livekit_api/src/token.rs
index 6f12d788551..5229a83bb6e 100644
--- a/crates/livekit_api/src/token.rs
+++ b/crates/livekit_api/src/token.rs
@@ -1,14 +1,25 @@
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
 use serde::{Deserialize, Serialize};
 use std::{
     borrow::Cow,
-    ops::Add,
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
 
 const DEFAULT_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours
 
+pub trait UnixTimestampSource: Send + Sync {
+    fn unix_timestamp(&self) -> Result;
+}
+
+pub struct SystemUnixTimestampSource;
+
+impl UnixTimestampSource for SystemUnixTimestampSource {
+    fn unix_timestamp(&self) -> Result {
+        Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
+    }
+}
+
 #[derive(Default, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ClaimGrants<'a> {
@@ -73,22 +84,55 @@ pub fn create(
     identity: Option<&str>,
     video_grant: VideoGrant,
 ) -> Result {
-    if video_grant.room_join.is_some() && identity.is_none() {
+    create_with_timestamp_source(
+        api_key,
+        secret_key,
+        identity,
+        video_grant,
+        &SystemUnixTimestampSource,
+    )
+}
+
+pub fn create_with_timestamp_source(
+    api_key: &str,
+    secret_key: &str,
+    identity: Option<&str>,
+    video_grant: VideoGrant,
+    timestamp_source: &dyn UnixTimestampSource,
+) -> Result {
+    let issued_at = timestamp_source.unix_timestamp()?;
+    create_with_issued_at(api_key, secret_key, identity, video_grant, issued_at)
+}
+
+fn create_with_issued_at(
+    api_key: &str,
+    secret_key: &str,
+    identity: Option<&str>,
+    video_grant: VideoGrant,
+    issued_at: u64,
+) -> Result {
+    let room_join = video_grant.room_join.unwrap_or(false);
+    if room_join && identity.is_none() {
         anyhow::bail!("identity is required for room_join grant, but it is none");
     }
 
-    let now = SystemTime::now();
+    let expires_at = issued_at
+        .checked_add(DEFAULT_TTL.as_secs())
+        .context("token expiration overflow")?;
+    let not_before = if room_join {
+        // LiveKit Cloud applies participant revocations by comparing the
+        // revocation timestamp to room-join token `nbf`.
+        issued_at
+    } else {
+        0
+    };
 
     let claims = ClaimGrants {
         iss: Cow::Borrowed(api_key),
         sub: identity.map(Cow::Borrowed),
-        iat: now.duration_since(UNIX_EPOCH).unwrap().as_secs(),
-        exp: now
-            .add(DEFAULT_TTL)
-            .duration_since(UNIX_EPOCH)
-            .unwrap()
-            .as_secs(),
-        nbf: 0,
+        iat: issued_at,
+        exp: expires_at,
+        nbf: not_before,
         jwtid: identity.map(Cow::Borrowed),
         video: video_grant,
     };
@@ -108,3 +152,127 @@ pub fn validate<'a>(token: &'a str, secret_key: &str) -> Result>
 
     Ok(token.claims)
 }
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn validate_with_timestamp_source<'a>(
+    token: &'a str,
+    secret_key: &str,
+    timestamp_source: &dyn UnixTimestampSource,
+) -> Result> {
+    let mut validation = Validation::default();
+    validation.validate_exp = false;
+    validation.validate_nbf = false;
+    let token: jsonwebtoken::TokenData> = jsonwebtoken::decode(
+        token,
+        &DecodingKey::from_secret(secret_key.as_ref()),
+        &validation,
+    )?;
+    let claims = token.claims;
+    let timestamp = timestamp_source.unix_timestamp()?;
+
+    anyhow::ensure!(claims.nbf <= timestamp, "token is not yet valid");
+    anyhow::ensure!(claims.exp > timestamp, "token has expired");
+
+    Ok(claims)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{Client as _, LiveKitClient};
+    use std::sync::Arc;
+
+    const ISSUED_AT: u64 = 1_234_567;
+
+    struct FixedUnixTimestampSource(u64);
+
+    impl UnixTimestampSource for FixedUnixTimestampSource {
+        fn unix_timestamp(&self) -> Result {
+            Ok(self.0)
+        }
+    }
+
+    #[test]
+    fn token_not_before_matches_issue_time() -> Result<()> {
+        let token = create_with_timestamp_source(
+            "api-key",
+            "secret-key",
+            Some("participant"),
+            VideoGrant::to_join("room"),
+            &FixedUnixTimestampSource(ISSUED_AT),
+        )?;
+
+        assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
+        Ok(())
+    }
+
+    #[test]
+    fn room_token_not_before_matches_issue_time() -> Result<()> {
+        let client = LiveKitClient::new_with_timestamp_source(
+            "http://livekit.test".into(),
+            "api-key".into(),
+            "secret-key".into(),
+            Arc::new(FixedUnixTimestampSource(ISSUED_AT)),
+        );
+        let token = client.room_token("room", "participant")?;
+
+        let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
+        assert_eq!(claims.video.room_join, Some(true));
+        assert_eq!(claims.video.can_publish, Some(true));
+        assert_eq!(claims.video.can_subscribe, Some(true));
+
+        Ok(())
+    }
+
+    #[test]
+    fn guest_token_not_before_matches_issue_time() -> Result<()> {
+        let client = LiveKitClient::new_with_timestamp_source(
+            "http://livekit.test".into(),
+            "api-key".into(),
+            "secret-key".into(),
+            Arc::new(FixedUnixTimestampSource(ISSUED_AT)),
+        );
+        let token = client.guest_token("room", "participant")?;
+
+        let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
+        assert_eq!(claims.video.room_join, Some(true));
+        assert_eq!(claims.video.can_publish, Some(false));
+        assert_eq!(claims.video.can_subscribe, Some(true));
+
+        Ok(())
+    }
+
+    #[test]
+    fn admin_token_not_before_remains_unset() -> Result<()> {
+        let token = create_with_timestamp_source(
+            "api-key",
+            "secret-key",
+            None,
+            VideoGrant::to_admin("room"),
+            &FixedUnixTimestampSource(ISSUED_AT),
+        )?;
+
+        let claims = assert_claims_timestamp(&token, ISSUED_AT, 0)?;
+        assert_eq!(claims.video.room_admin, Some(true));
+
+        Ok(())
+    }
+
+    fn assert_claims_timestamp(
+        token: &str,
+        issued_at: u64,
+        expected_not_before: u64,
+    ) -> Result> {
+        let claims = validate_with_timestamp_source(
+            token,
+            "secret-key",
+            &FixedUnixTimestampSource(issued_at),
+        )?;
+
+        assert_eq!(claims.iat, issued_at);
+        assert_eq!(claims.nbf, expected_not_before);
+        assert_eq!(claims.exp, issued_at + DEFAULT_TTL.as_secs());
+
+        Ok(claims)
+    }
+}
diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml
index 42c13f094c1..a229d39ca8c 100644
--- a/crates/livekit_client/Cargo.toml
+++ b/crates/livekit_client/Cargo.toml
@@ -17,7 +17,7 @@ doctest = false
 name = "test_app"
 
 [features]
-test-support = ["collections/test-support", "gpui/test-support"]
+test-support = ["collections/test-support", "gpui/test-support", "livekit_api/test-support"]
 
 [dependencies]
 anyhow.workspace = true
@@ -65,6 +65,7 @@ objc.workspace = true
 collections = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 gpui_platform.workspace = true
+livekit_api = { workspace = true, features = ["test-support"] }
 simplelog.workspace = true
 
 [build-dependencies]
diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs
index 955f92dc19d..d742dd06617 100644
--- a/crates/livekit_client/src/test.rs
+++ b/crates/livekit_client/src/test.rs
@@ -57,6 +57,25 @@ pub struct TestServer {
     pub secret_key: String,
     rooms: Mutex>,
     executor: BackgroundExecutor,
+    timestamp_source: Arc,
+}
+
+pub struct ManualUnixTimestampSource(AtomicU64);
+
+impl ManualUnixTimestampSource {
+    pub fn new(timestamp: u64) -> Self {
+        Self(AtomicU64::new(timestamp))
+    }
+
+    pub fn advance(&self) {
+        self.0.fetch_add(1, SeqCst);
+    }
+}
+
+impl token::UnixTimestampSource for ManualUnixTimestampSource {
+    fn unix_timestamp(&self) -> Result {
+        Ok(self.0.load(SeqCst))
+    }
 }
 
 impl TestServer {
@@ -65,6 +84,22 @@ impl TestServer {
         api_key: String,
         secret_key: String,
         executor: BackgroundExecutor,
+    ) -> Result> {
+        Self::create_with_timestamp_source(
+            url,
+            api_key,
+            secret_key,
+            executor,
+            Arc::new(token::SystemUnixTimestampSource),
+        )
+    }
+
+    pub fn create_with_timestamp_source(
+        url: String,
+        api_key: String,
+        secret_key: String,
+        executor: BackgroundExecutor,
+        timestamp_source: Arc,
     ) -> Result> {
         let mut servers = SERVERS.lock();
         if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) {
@@ -74,6 +109,7 @@ impl TestServer {
                 secret_key,
                 rooms: Default::default(),
                 executor,
+                timestamp_source,
             });
             e.insert(server.clone());
             Ok(server)
@@ -104,6 +140,20 @@ impl TestServer {
         }
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    fn validate_token<'a>(&self, token: &'a str) -> Result> {
+        token::validate_with_timestamp_source(
+            token,
+            &self.secret_key,
+            self.timestamp_source.as_ref(),
+        )
+    }
+
+    #[cfg(not(any(test, feature = "test-support")))]
+    fn validate_token<'a>(&self, token: &'a str) -> Result> {
+        token::validate(token, &self.secret_key)
+    }
+
     pub async fn create_room(&self, room: String) -> Result<()> {
         self.simulate_random_delay().await;
 
@@ -129,11 +179,19 @@ impl TestServer {
     async fn join_room(&self, token: String, client_room: Room) -> Result {
         self.simulate_random_delay().await;
 
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
-        let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
-        let room_name = claims.video.room.unwrap();
+        let claims = self.validate_token(&token)?;
+        let identity = ParticipantIdentity(
+            claims
+                .sub
+                .context("missing participant identity")?
+                .to_string(),
+        );
+        let room_name = claims.video.room.context("missing room name")?.to_string();
         let mut server_rooms = self.rooms.lock();
-        let room = (*server_rooms).entry(room_name.to_string()).or_default();
+        let room = (*server_rooms).entry(room_name.clone()).or_default();
+        if let Some(revoked_before) = room.token_revocations.get(&identity) {
+            anyhow::ensure!(claims.nbf >= *revoked_before, "invalid token: revoked");
+        }
 
         if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) {
             for server_track in &room.video_tracks {
@@ -192,7 +250,7 @@ impl TestServer {
     async fn leave_room(&self, token: String) -> Result<()> {
         self.simulate_random_delay().await;
 
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let room_name = claims.video.room.unwrap();
         let mut server_rooms = self.rooms.lock();
@@ -209,7 +267,7 @@ impl TestServer {
         &self,
         token: String,
     ) -> Result> {
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let room_name = claims.video.room.unwrap().to_string();
 
@@ -244,14 +302,25 @@ impl TestServer {
         identity: ParticipantIdentity,
     ) -> Result<()> {
         self.simulate_random_delay().await;
+        let revoked_before = self.timestamp_source.unix_timestamp()?;
 
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&room_name)
             .with_context(|| format!("room {room_name} does not exist"))?;
-        room.client_rooms
+        let removed_room = room
+            .client_rooms
             .remove(&identity)
             .with_context(|| format!("participant {identity:?} did not join room {room_name:?}"))?;
+        room.token_revocations.insert(identity, revoked_before);
+        let mut removed_room = removed_room.0.lock();
+        removed_room.connection_state = ConnectionState::Disconnected;
+        removed_room
+            .updates_tx
+            .blocking_send(RoomEvent::Disconnected {
+                reason: "PARTICIPANT_REMOVED",
+            })
+            .ok();
         Ok(())
     }
 
@@ -262,13 +331,18 @@ impl TestServer {
         permission: proto::ParticipantPermission,
     ) -> Result<()> {
         self.simulate_random_delay().await;
+        let revoked_before = self.timestamp_source.unix_timestamp()?;
 
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&room_name)
             .with_context(|| format!("room {room_name} does not exist"))?;
+        let identity = ParticipantIdentity(identity);
         room.participant_permissions
-            .insert(ParticipantIdentity(identity), permission);
+            .insert(identity.clone(), permission);
+        // Permission changes in LiveKit Cloud invalidate existing participant
+        // tokens, so the mock needs to reject tokens minted before the update.
+        room.token_revocations.insert(identity, revoked_before);
         Ok(())
     }
 
@@ -298,7 +372,7 @@ impl TestServer {
     ) -> Result {
         self.simulate_random_delay().await;
 
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let room_name = claims.video.room.unwrap();
 
@@ -362,7 +436,7 @@ impl TestServer {
     ) -> Result {
         self.simulate_random_delay().await;
 
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let room_name = claims.video.room.unwrap();
 
@@ -421,7 +495,7 @@ impl TestServer {
     }
 
     pub(crate) async fn unpublish_track(&self, token: String, track_sid: &TrackSid) -> Result<()> {
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let room_name = claims.video.room.unwrap();
 
@@ -503,7 +577,7 @@ impl TestServer {
         track_sid: &TrackSid,
         muted: bool,
     ) -> Result<()> {
-        let claims = livekit_api::token::validate(token, &self.secret_key)?;
+        let claims = self.validate_token(token)?;
         let room_name = claims.video.room.unwrap();
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
         let mut server_rooms = self.rooms.lock();
@@ -557,7 +631,7 @@ impl TestServer {
     }
 
     pub(crate) fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option {
-        let claims = livekit_api::token::validate(token, &self.secret_key).ok()?;
+        let claims = self.validate_token(token).ok()?;
         let room_name = claims.video.room.unwrap();
 
         let mut server_rooms = self.rooms.lock();
@@ -572,7 +646,7 @@ impl TestServer {
     }
 
     pub(crate) fn video_tracks(&self, token: String) -> Result> {
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let room_name = claims.video.room.unwrap();
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
 
@@ -595,7 +669,7 @@ impl TestServer {
     }
 
     pub(crate) fn audio_tracks(&self, token: String) -> Result> {
-        let claims = livekit_api::token::validate(&token, &self.secret_key)?;
+        let claims = self.validate_token(&token)?;
         let room_name = claims.video.room.unwrap();
         let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
 
@@ -629,6 +703,7 @@ struct TestServerRoom {
     video_tracks: Vec>,
     audio_tracks: Vec>,
     participant_permissions: HashMap,
+    token_revocations: HashMap,
 }
 
 #[derive(Debug)]
@@ -690,21 +765,23 @@ impl livekit_api::Client for TestApiClient {
 
     fn room_token(&self, room: &str, identity: &str) -> Result {
         let server = TestServer::get(&self.url)?;
-        token::create(
+        token::create_with_timestamp_source(
             &server.api_key,
             &server.secret_key,
             Some(identity),
             token::VideoGrant::to_join(room),
+            server.timestamp_source.as_ref(),
         )
     }
 
     fn guest_token(&self, room: &str, identity: &str) -> Result {
         let server = TestServer::get(&self.url)?;
-        token::create(
+        token::create_with_timestamp_source(
             &server.api_key,
             &server.secret_key,
             Some(identity),
             token::VideoGrant::for_guest(room),
+            server.timestamp_source.as_ref(),
         )
     }
 }
@@ -853,3 +930,168 @@ impl WeakRoom {
         self.0.upgrade().map(Room)
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::TestAppContext;
+    use livekit_api::Client as _;
+    use std::{ops::Deref, sync::atomic::AtomicUsize};
+
+    struct TestServerGuard {
+        server: Arc,
+        timestamp_source: Arc,
+    }
+
+    impl TestServerGuard {
+        fn advance_timestamp(&self) {
+            self.timestamp_source.advance();
+        }
+    }
+
+    impl Deref for TestServerGuard {
+        type Target = TestServer;
+
+        fn deref(&self) -> &Self::Target {
+            self.server.as_ref()
+        }
+    }
+
+    impl Drop for TestServerGuard {
+        fn drop(&mut self) {
+            self.server.teardown().ok();
+        }
+    }
+
+    fn create_test_server(name: &str, executor: BackgroundExecutor) -> TestServerGuard {
+        static NEXT_SERVER_ID: AtomicUsize = AtomicUsize::new(0);
+        let server_id = NEXT_SERVER_ID.fetch_add(1, SeqCst);
+        let timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567));
+        let server = TestServer::create_with_timestamp_source(
+            format!("http://livekit-{name}-{server_id}.test"),
+            format!("api-key-{server_id}"),
+            format!("secret-key-{server_id}"),
+            executor,
+            timestamp_source.clone(),
+        )
+        .expect("create LiveKit test server");
+        TestServerGuard {
+            server,
+            timestamp_source,
+        }
+    }
+
+    async fn assert_token_was_revoked(server: &TestServer, token: String, cx: &mut TestAppContext) {
+        match Room::connect(server.url.clone(), token, &mut cx.to_async()).await {
+            Ok(_) => panic!("revoked token unexpectedly connected"),
+            Err(error) => {
+                let error = format!("{error:#}");
+                assert!(
+                    error.contains("invalid token: revoked"),
+                    "expected revoked token error, got {error}"
+                );
+            }
+        }
+    }
+
+    #[gpui::test]
+    async fn token_created_after_participant_removal_can_join(
+        executor: BackgroundExecutor,
+        cx: &mut TestAppContext,
+    ) {
+        let server = create_test_server("room-token", executor);
+        server
+            .create_room("room".into())
+            .await
+            .expect("create LiveKit test room");
+        let api_client = server.create_api_client();
+
+        let initial_token = api_client
+            .room_token("room", "participant")
+            .expect("create initial room token");
+        let (initial_room, _) = Room::connect(
+            server.url.clone(),
+            initial_token.clone(),
+            &mut cx.to_async(),
+        )
+        .await
+        .expect("connect with initial room token");
+
+        server.advance_timestamp();
+        api_client
+            .remove_participant("room".into(), "participant".into())
+            .await
+            .expect("remove participant");
+
+        assert_eq!(
+            initial_room.connection_state(),
+            ConnectionState::Disconnected
+        );
+        assert_token_was_revoked(&server, initial_token, cx).await;
+
+        let fresh_token = api_client
+            .room_token("room", "participant")
+            .expect("create fresh room token");
+        let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async())
+            .await
+            .expect("connect with fresh room token");
+
+        assert_eq!(fresh_room.connection_state(), ConnectionState::Connected);
+    }
+
+    #[gpui::test]
+    async fn guest_token_created_after_permission_update_can_join(
+        executor: BackgroundExecutor,
+        cx: &mut TestAppContext,
+    ) {
+        let server = create_test_server("guest-token", executor);
+        server
+            .create_room("room".into())
+            .await
+            .expect("create LiveKit test room");
+        let api_client = server.create_api_client();
+
+        let initial_token = api_client
+            .guest_token("room", "participant")
+            .expect("create initial guest token");
+        let (initial_room, _) = Room::connect(
+            server.url.clone(),
+            initial_token.clone(),
+            &mut cx.to_async(),
+        )
+        .await
+        .expect("connect with initial guest token");
+
+        server.advance_timestamp();
+        api_client
+            .update_participant(
+                "room".into(),
+                "participant".into(),
+                proto::ParticipantPermission {
+                    can_subscribe: true,
+                    can_publish: true,
+                    can_publish_data: true,
+                    hidden: false,
+                    recorder: false,
+                },
+            )
+            .await
+            .expect("update participant permissions");
+        assert_token_was_revoked(&server, initial_token, cx).await;
+
+        server.disconnect_client("participant".into()).await;
+        assert_eq!(
+            initial_room.connection_state(),
+            ConnectionState::Disconnected
+        );
+
+        let fresh_token = api_client
+            .guest_token("room", "participant")
+            .expect("create fresh guest token");
+        let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async())
+            .await
+            .expect("connect with fresh guest token");
+
+        assert_eq!(fresh_room.connection_state(), ConnectionState::Connected);
+    }
+}
diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs
index 57963bbb040..4f5c977f129 100644
--- a/crates/lmstudio/src/lmstudio.rs
+++ b/crates/lmstudio/src/lmstudio.rs
@@ -207,12 +207,19 @@ pub struct FunctionContent {
     pub arguments: String,
 }
 
+#[derive(Serialize, Debug)]
+pub struct StreamOptions {
+    pub include_usage: bool,
+}
+
 #[derive(Serialize, Debug)]
 pub struct ChatCompletionRequest {
     pub model: String,
     pub messages: Vec,
     pub stream: bool,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub stream_options: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub max_tokens: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub stop: Option>,
diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml
index 7443803346e..3db8d84b733 100644
--- a/crates/markdown/Cargo.toml
+++ b/crates/markdown/Cargo.toml
@@ -46,6 +46,7 @@ env_logger.workspace = true
 fs = {workspace = true, features = ["test-support"]}
 gpui = { workspace = true, features = ["test-support"] }
 gpui_platform = { workspace = true, features = ["wayland", "x11"] }
+image.workspace = true
 language = { workspace = true, features = ["test-support"] }
 languages = { workspace = true, features = ["load-grammars"] }
 node_runtime.workspace = true
diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs
index 25e869625fb..00b73f1e44e 100644
--- a/crates/markdown/src/html/html_rendering.rs
+++ b/crates/markdown/src/html/html_rendering.rs
@@ -391,9 +391,7 @@ impl MarkdownElement {
         boundaries.sort_unstable();
         boundaries.dedup();
 
-        for segment in boundaries.windows(2) {
-            let start = segment[0];
-            let end = segment[1];
+        for &[start, end] in boundaries.array_windows::<2>() {
             if start >= end {
                 continue;
             }
diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs
index 89266065a1c..a4651e5d7e5 100644
--- a/crates/markdown/src/markdown.rs
+++ b/crates/markdown/src/markdown.rs
@@ -2,6 +2,7 @@ pub mod html;
 mod mermaid;
 pub mod parser;
 mod path_range;
+mod selection;
 
 use base64::Engine as _;
 use futures::FutureExt as _;
@@ -808,12 +809,15 @@ impl Markdown {
         output.into()
     }
 
-    pub fn selected_text(&self) -> Option {
+    pub fn has_selection(&self) -> bool {
+        self.selection.end > self.selection.start
+    }
+
+    pub fn selected_source(&self) -> Option<&str> {
         if self.selection.end <= self.selection.start {
-            None
-        } else {
-            Some(self.source[self.selection.start..self.selection.end].to_string())
+            return None;
         }
+        self.source.get(self.selection.start..self.selection.end)
     }
 
     pub fn set_search_highlights(
@@ -873,7 +877,9 @@ impl Markdown {
         if self.selection.end <= self.selection.start {
             return;
         }
-        let text = self.source[self.selection.start..self.selection.end].to_string();
+        let text = self
+            .parsed_markdown
+            .rebalanced_markdown_for_selection(self.selection.start..self.selection.end);
         cx.write_to_clipboard(ClipboardItem::new_string(text));
     }
 
@@ -884,8 +890,10 @@ impl Markdown {
     ) {
         let range = self.selection.start..self.selection.end;
         if range.end > range.start {
-            self.context_menu_selected_markdown =
-                Some(SharedString::new(&self.source[range.clone()]));
+            self.context_menu_selected_markdown = Some(SharedString::new(
+                self.parsed_markdown
+                    .rebalanced_markdown_for_selection(range.clone()),
+            ));
             self.context_menu_selected_text = rendered_text
                 .map(|text| text.text_for_range(range))
                 .map(SharedString::new)
@@ -910,8 +918,9 @@ impl Markdown {
         self.context_menu_selected_text.as_ref()
     }
 
-    /// Returns the raw markdown source that was selected when the most recent
-    /// context menu invocation happened.
+    /// Returns the markdown that was selected when the most recent context
+    /// menu invocation happened, rebalanced via
+    /// [`ParsedMarkdown::rebalanced_markdown_for_selection`].
     pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> {
         self.context_menu_selected_markdown.as_ref()
     }
@@ -1207,6 +1216,21 @@ impl ParsedMarkdown {
 
         Some(partition.saturating_sub(1))
     }
+
+    /// Extracts the markdown source for a selection, rebalancing inline
+    /// delimiters (`**`, backticks, link syntax, etc.) so partial selections of
+    /// styled spans stay well-formed.
+    ///
+    /// With an exception of a single inline code span, which is returned as plain
+    /// text, since copying a command or identifier is the dominant use case there.
+    pub fn rebalanced_markdown_for_selection(&self, selection: Range) -> String {
+        selection::rebalanced_markdown_for_selection(
+            &self.source,
+            &self.events,
+            &self.root_block_starts,
+            selection,
+        )
+    }
 }
 
 pub enum AutoscrollBehavior {
@@ -1221,7 +1245,7 @@ pub struct MarkdownElement {
     markdown: Entity,
     style: MarkdownStyle,
     code_block_renderer: CodeBlockRenderer,
-    on_url_click: Option>,
+    on_url_click: Option>,
     code_span_link: Option,
     on_source_click: Option,
     on_checkbox_toggle: Option,
@@ -1280,7 +1304,7 @@ impl MarkdownElement {
         mut self,
         handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
     ) -> Self {
-        self.on_url_click = Some(Box::new(handler));
+        self.on_url_click = Some(Rc::new(handler));
         self
     }
 
@@ -1378,18 +1402,58 @@ impl MarkdownElement {
         width: Option,
         height: Option,
     ) {
-        let image_element = div().min_w_0().child(
-            img(source)
-                .id(("markdown-image", range.start))
-                .min_w_0()
-                .max_w_full()
-                .rounded_md()
-                .mr_1()
-                .mb_1()
-                .when_some(height, |this, height| this.h(height))
-                .when_some(width, |this, width| this.w(width))
-                .with_fallback(move || image_fallback_element(dest_url.clone(), alt_text.clone())),
-        );
+        let enclosing_link_url = (builder.link_depth > 0)
+            .then(|| builder.rendered_links.last())
+            .flatten()
+            .map(|link| link.destination_url.clone());
+        let fallback_opens_image_url = enclosing_link_url.is_none();
+
+        let image_element = {
+            let wrapper = div().id(("markdown-image-link", range.start)).min_w_0();
+            let wrapper = if !self.style.prevent_mouse_interaction
+                && let Some(url) = enclosing_link_url
+            {
+                let click_url = url.clone();
+                let markdown = self.markdown.clone();
+                let url_click = self.on_url_click.clone();
+                wrapper
+                    .cursor_pointer()
+                    .on_click(move |_, window, cx| {
+                        if let Some(ref on_url_click) = url_click {
+                            on_url_click(click_url.clone(), window, cx);
+                        } else {
+                            cx.open_url(&click_url);
+                        }
+                    })
+                    .capture_any_mouse_down(move |event, _window, cx| {
+                        if event.button == MouseButton::Right {
+                            markdown.update(cx, |md, _| {
+                                md.capture_for_context_menu(Some(url.clone()), None)
+                            });
+                        }
+                    })
+            } else {
+                wrapper
+            };
+            wrapper.child(
+                img(source)
+                    .id(("markdown-image", range.start))
+                    .min_w_0()
+                    .max_w_full()
+                    .rounded_md()
+                    .mr_1()
+                    .mb_1()
+                    .when_some(height, |this, height| this.h(height))
+                    .when_some(width, |this, width| this.w(width))
+                    .with_fallback(move || {
+                        image_fallback_element(
+                            dest_url.clone(),
+                            alt_text.clone(),
+                            fallback_opens_image_url,
+                        )
+                    }),
+            )
+        };
 
         builder.push_image_child(image_element);
     }
@@ -2795,7 +2859,11 @@ fn collect_image_alt_text(
     }
 }
 
-fn image_fallback_element(dest_url: SharedString, alt_text: Option) -> AnyElement {
+fn image_fallback_element(
+    dest_url: SharedString,
+    alt_text: Option,
+    open_image_url_on_click: bool,
+) -> AnyElement {
     let link_label = alt_text
         .filter(|alt| !alt.is_empty())
         .unwrap_or_else(|| dest_url.clone());
@@ -2804,13 +2872,15 @@ fn image_fallback_element(dest_url: SharedString, alt_text: Option
 
     div()
         .id("image-fallback")
-        .cursor_pointer()
         .min_w_0()
         .child(Label::new(label).color(Color::Warning).underline())
         .tooltip(Tooltip::text(
             "Image failed to load. Open `zed: log` for more details.",
         ))
-        .on_click(move |_, _, cx| cx.open_url(&dest_url))
+        .when(open_image_url_on_click, |this| {
+            this.cursor_pointer()
+                .on_click(move |_, _, cx| cx.open_url(&dest_url))
+        })
         .into_any_element()
 }
 
@@ -5289,6 +5359,98 @@ mod tests {
         });
     }
 
+    fn failing_image_source() -> ImageSource {
+        ImageSource::Custom(Arc::new(|_, _| {
+            Some(Err(gpui::ImageCacheError::Asset(
+                "failed to load image".into(),
+            )))
+        }))
+    }
+
+    fn loaded_image_source() -> ImageSource {
+        let buffer = image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 0, 0, 255]));
+        ImageSource::Render(Arc::new(gpui::RenderImage::new(SmallVec::from_elem(
+            image::Frame::new(buffer),
+            1,
+        ))))
+    }
+
+    fn open_markdown_image_test_window<'a>(
+        source: &str,
+        image_source: ImageSource,
+        cx: &'a mut TestAppContext,
+    ) -> &'a mut gpui::VisualTestContext {
+        struct ImageTestView {
+            markdown: Entity,
+            image_source: ImageSource,
+        }
+
+        impl Render for ImageTestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
+                let image_source = self.image_source.clone();
+                div().size_full().child(
+                    MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default())
+                        .image_resolver(move |_| Some(image_source.clone())),
+                )
+            }
+        }
+
+        ensure_theme_initialized(cx);
+
+        let source = source.to_string();
+        let (_, cx) = cx.add_window_view(|_, cx| ImageTestView {
+            markdown: cx.new(|cx| Markdown::new(source.into(), None, None, cx)),
+            image_source,
+        });
+        cx.run_until_parked();
+        cx
+    }
+
+    #[gpui::test]
+    fn test_clicking_image_fallback_opens_image_url(cx: &mut TestAppContext) {
+        let cx = open_markdown_image_test_window(
+            "![alt text](https://example.com/image.png)",
+            failing_image_source(),
+            cx,
+        );
+
+        cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
+        assert_eq!(
+            cx.opened_url(),
+            Some("https://example.com/image.png".to_string())
+        );
+    }
+
+    #[gpui::test]
+    fn test_clicking_image_fallback_inside_link_opens_link_url(cx: &mut TestAppContext) {
+        let cx = open_markdown_image_test_window(
+            "[![alt text](https://example.com/image.png)](https://example.com/link)",
+            failing_image_source(),
+            cx,
+        );
+
+        cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
+        assert_eq!(
+            cx.opened_url(),
+            Some("https://example.com/link".to_string())
+        );
+    }
+
+    #[gpui::test]
+    fn test_clicking_loaded_image_inside_link_opens_link_url(cx: &mut TestAppContext) {
+        let cx = open_markdown_image_test_window(
+            "[![alt text](https://example.com/image.png)](https://example.com/link)",
+            loaded_image_source(),
+            cx,
+        );
+
+        cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
+        assert_eq!(
+            cx.opened_url(),
+            Some("https://example.com/link".to_string())
+        );
+    }
+
     #[track_caller]
     fn assert_mappings(rendered: &RenderedText, expected: Vec>) {
         assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
@@ -5399,13 +5561,13 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_editor_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) {
+    fn test_ui_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) {
         ensure_theme_initialized(cx);
 
         cx.update(|cx| {
             settings::SettingsStore::update_global(cx, |store, cx| {
                 store.update_user_settings(cx, |settings| {
-                    settings.theme.buffer_font_size = Some(16.0.into());
+                    settings.theme.ui_font_size = Some(16.0.into());
                     settings.theme.markdown_preview_font_size = None;
                 });
             });
@@ -5416,11 +5578,9 @@ mod tests {
             let before = ThemeSettings::get_global(cx).markdown_preview_font_size(cx);
             assert_eq!(before, px(16.0));
 
-            theme_settings::increase_buffer_font_size(cx);
-            theme_settings::increase_buffer_font_size(cx);
-            theme_settings::increase_buffer_font_size(cx);
+            theme_settings::adjust_ui_font_size(cx, |size| size + px(3.0));
 
-            assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(19.0));
+            assert_eq!(ThemeSettings::get_global(cx).ui_font_size(cx), px(19.0));
             assert_eq!(
                 ThemeSettings::get_global(cx).markdown_preview_font_size(cx),
                 before
@@ -5429,13 +5589,13 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_markdown_preview_follows_buffer_font_size_setting_when_unset(cx: &mut TestAppContext) {
+    fn test_markdown_preview_follows_ui_font_size_setting_when_unset(cx: &mut TestAppContext) {
         ensure_theme_initialized(cx);
 
         cx.update(|cx| {
             settings::SettingsStore::update_global(cx, |store, cx| {
                 store.update_user_settings(cx, |settings| {
-                    settings.theme.buffer_font_size = Some(20.0.into());
+                    settings.theme.ui_font_size = Some(20.0.into());
                     settings.theme.markdown_preview_font_size = None;
                 });
             });
@@ -5451,7 +5611,7 @@ mod tests {
         cx.update(|cx| {
             settings::SettingsStore::update_global(cx, |store, cx| {
                 store.update_user_settings(cx, |settings| {
-                    settings.theme.buffer_font_size = Some(24.0.into());
+                    settings.theme.ui_font_size = Some(24.0.into());
                 });
             });
         });
diff --git a/crates/markdown/src/selection.rs b/crates/markdown/src/selection.rs
new file mode 100644
index 00000000000..9e7851b549b
--- /dev/null
+++ b/crates/markdown/src/selection.rs
@@ -0,0 +1,598 @@
+use crate::parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd};
+use std::ops::Range;
+
+struct InlineSpan {
+    range: Range,
+    content: Range,
+    is_code: bool,
+}
+
+impl InlineSpan {
+    fn opening<'a>(&self, source: &'a str) -> &'a str {
+        source
+            .get(self.range.start..self.content.start)
+            .unwrap_or("")
+    }
+
+    fn closing<'a>(&self, source: &'a str) -> &'a str {
+        source.get(self.content.end..self.range.end).unwrap_or("")
+    }
+}
+
+fn is_inline_span_tag(tag: &MarkdownTag) -> bool {
+    matches!(
+        tag,
+        MarkdownTag::Emphasis
+            | MarkdownTag::Strong
+            | MarkdownTag::Strikethrough
+            | MarkdownTag::Superscript
+            | MarkdownTag::Subscript
+            | MarkdownTag::Link { .. }
+    )
+}
+
+fn is_inline_span_tag_end(tag: &MarkdownTagEnd) -> bool {
+    matches!(
+        tag,
+        MarkdownTagEnd::Emphasis
+            | MarkdownTagEnd::Strong
+            | MarkdownTagEnd::Strikethrough
+            | MarkdownTagEnd::Superscript
+            | MarkdownTagEnd::Subscript
+            | MarkdownTagEnd::Link
+    )
+}
+
+fn inline_code_full_range(source: &str, content: &Range) -> Range {
+    let opening_ticks = source[..content.start]
+        .bytes()
+        .rev()
+        .take_while(|&byte| byte == b'`')
+        .count();
+    let closing_ticks = source[content.end..]
+        .bytes()
+        .take_while(|&byte| byte == b'`')
+        .count();
+    let ticks = opening_ticks.min(closing_ticks);
+    content.start - ticks..content.end + ticks
+}
+
+fn collect_inline_spans(source: &str, events: &[(Range, MarkdownEvent)]) -> Vec {
+    fn note_child(stack: &mut [(Range, Option>)], child: &Range) {
+        for (_, content) in stack.iter_mut() {
+            match content {
+                Some(content) => content.end = content.end.max(child.end),
+                None => *content = Some(child.clone()),
+            }
+        }
+    }
+
+    let mut spans = Vec::new();
+    let mut stack: Vec<(Range, Option>)> = Vec::new();
+    for (event_range, event) in events {
+        match event {
+            MarkdownEvent::Start(tag) if is_inline_span_tag(tag) => {
+                note_child(&mut stack, event_range);
+                stack.push((event_range.clone(), None));
+            }
+            MarkdownEvent::End(tag) if is_inline_span_tag_end(tag) => {
+                if let Some((range, content)) = stack.pop() {
+                    let content = content.unwrap_or(range.clone());
+                    spans.push(InlineSpan {
+                        range,
+                        content,
+                        is_code: false,
+                    });
+                }
+                note_child(&mut stack, event_range);
+            }
+            MarkdownEvent::Code | MarkdownEvent::SubstitutedCode(_) => {
+                let range = inline_code_full_range(source, event_range);
+                note_child(&mut stack, &range);
+                spans.push(InlineSpan {
+                    range,
+                    content: event_range.clone(),
+                    is_code: true,
+                });
+            }
+            _ => note_child(&mut stack, event_range),
+        }
+    }
+    spans
+}
+
+pub(crate) fn rebalanced_markdown_for_selection(
+    source: &str,
+    events: &[(Range, MarkdownEvent)],
+    root_block_starts: &[usize],
+    selection: Range,
+) -> String {
+    let Some(selection) = snap_to_char_boundaries(source, selection) else {
+        return String::new();
+    };
+
+    let (start_events, end_events) = boundary_block_events(events, root_block_starts, &selection);
+    let mut spans = collect_inline_spans(source, start_events);
+    spans.extend(collect_inline_spans(source, end_events));
+
+    let Some(selection) = snap_out_of_delimiters(&spans, selection) else {
+        return String::new();
+    };
+
+    if selection_is_only_inside_code_spans(&spans, &selection) {
+        return source[selection].to_string();
+    }
+
+    rebalance_delimiters(source, &spans, &selection)
+}
+
+/// Returns the events of the root blocks containing each selection boundary.
+/// The second slice is empty when both boundaries share a block.
+fn boundary_block_events<'a>(
+    events: &'a [(Range, MarkdownEvent)],
+    root_block_starts: &[usize],
+    selection: &Range,
+) -> (
+    &'a [(Range, MarkdownEvent)],
+    &'a [(Range, MarkdownEvent)],
+) {
+    if root_block_starts.is_empty() {
+        return (events, &[]);
+    }
+    let start_block = root_block_index(root_block_starts, selection.start);
+    let end_block = root_block_index(root_block_starts, selection.end);
+    let start_events = root_block_events(events, root_block_starts, start_block);
+    if end_block == start_block {
+        (start_events, &[])
+    } else {
+        (
+            start_events,
+            root_block_events(events, root_block_starts, end_block),
+        )
+    }
+}
+
+fn root_block_index(root_block_starts: &[usize], offset: usize) -> usize {
+    root_block_starts
+        .partition_point(|block_start| *block_start <= offset)
+        .saturating_sub(1)
+}
+
+fn root_block_events<'a>(
+    events: &'a [(Range, MarkdownEvent)],
+    root_block_starts: &[usize],
+    block: usize,
+) -> &'a [(Range, MarkdownEvent)] {
+    let Some(&block_start) = root_block_starts.get(block) else {
+        return events;
+    };
+    let start = events.partition_point(|(range, _)| range.start < block_start);
+    let end = match root_block_starts.get(block + 1) {
+        Some(&next_block_start) => {
+            events.partition_point(|(range, _)| range.start < next_block_start)
+        }
+        None => events.len(),
+    };
+    events.get(start..end).unwrap_or(events)
+}
+
+fn snap_to_char_boundaries(source: &str, selection: Range) -> Option> {
+    let mut start = selection.start.min(source.len());
+    let mut end = selection.end.min(source.len());
+    if start >= end {
+        return None;
+    }
+    while start > 0 && !source.is_char_boundary(start) {
+        start -= 1;
+    }
+    while end < source.len() && !source.is_char_boundary(end) {
+        end += 1;
+    }
+    Some(start..end)
+}
+
+/// Shrinks selection boundaries that fall inside delimiter syntax (`**`,
+/// etc.) so no delimiter is left half-selected:
+///
+/// - an end in `**bold*|*` snaps back to `**bold|**`
+/// - a start in `*|*bold**` snaps forward to `**|bold**`
+///
+/// This repeats until stable, since snapping can land inside a nested span's
+/// delimiter. Returns `None` if the selection becomes empty.
+fn snap_out_of_delimiters(spans: &[InlineSpan], selection: Range) -> Option> {
+    let mut start = selection.start;
+    let mut end = selection.end;
+    loop {
+        let mut changed = false;
+        for span in spans {
+            if end > span.range.start && end <= span.content.start {
+                end = span.range.start;
+                changed = true;
+            } else if end > span.content.end && end <= span.range.end {
+                end = span.content.end;
+                changed = true;
+            }
+            if start >= span.range.start && start < span.content.start {
+                start = span.content.start;
+                changed = true;
+            } else if start >= span.content.end && start < span.range.end {
+                start = span.range.end;
+                changed = true;
+            }
+        }
+        if !changed {
+            break;
+        }
+    }
+    (start < end).then(|| start..end)
+}
+
+fn selection_is_only_inside_code_spans(spans: &[InlineSpan], selection: &Range) -> bool {
+    let contains = |span: &InlineSpan| {
+        span.content.start <= selection.start && selection.end <= span.content.end
+    };
+    spans.iter().any(|span| span.is_code && contains(span))
+        && !spans.iter().any(|span| !span.is_code && contains(span))
+}
+
+/// Re-adds delimiters cut off by the selection so the result is well-formed
+/// markdown:
+///
+/// - selecting `old te` in `**bold text**` yields `**old te**`
+/// - nested spans are reopened outermost first: selecting `alic` in
+///   `**bold _italic_**` yields `**_alic_**`
+fn rebalance_delimiters(source: &str, spans: &[InlineSpan], selection: &Range) -> String {
+    let nesting_order = |a: &&InlineSpan, b: &&InlineSpan| {
+        a.range
+            .start
+            .cmp(&b.range.start)
+            .then(b.range.end.cmp(&a.range.end))
+    };
+
+    let mut open_at_start = spans
+        .iter()
+        .filter(|span| span.content.start <= selection.start && selection.start < span.content.end)
+        .collect::>();
+    open_at_start.sort_by(nesting_order);
+
+    let mut open_at_end = spans
+        .iter()
+        .filter(|span| span.content.start < selection.end && selection.end <= span.content.end)
+        .collect::>();
+    open_at_end.sort_by(nesting_order);
+
+    let mut result = String::new();
+    for span in &open_at_start {
+        result.push_str(span.opening(source));
+    }
+    result.push_str(&source[selection.clone()]);
+    for span in open_at_end.iter().rev() {
+        result.push_str(span.closing(source));
+    }
+
+    result
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::parser::parse_markdown_with_options;
+    use util::test::marked_text_ranges;
+
+    fn markdown_for(source: &str, selection: Range) -> String {
+        let parsed = parse_markdown_with_options(source, false, false, false);
+        rebalanced_markdown_for_selection(
+            source,
+            &parsed.events,
+            &parsed.root_block_starts,
+            selection,
+        )
+    }
+
+    fn markdown_for_marked(marked_source: &str) -> String {
+        let (source, selection) = marked_range(marked_source);
+        markdown_for(&source, selection)
+    }
+
+    #[track_caller]
+    fn assert_marked_selection(marked_source: &str, expected: &str) {
+        assert_eq!(
+            markdown_for_marked(marked_source),
+            expected,
+            "source: {marked_source:?}"
+        );
+    }
+
+    #[track_caller]
+    fn marked_range(marked_source: &str) -> (String, Range) {
+        let (source, ranges) = marked_text_ranges(marked_source, false);
+        match ranges.as_slice() {
+            [selection] => (source, selection.clone()),
+            _ => panic!("expected exactly one «» range in marked source"),
+        }
+    }
+
+    fn inline_spans(source: &str) -> Vec {
+        let parsed = parse_markdown_with_options(source, false, false, false);
+        collect_inline_spans(source, &parsed.events)
+    }
+
+    #[track_caller]
+    fn assert_snapped(marked_selection: &str, marked_expected: Option<&str>, message: &str) {
+        let (source, selection) = marked_range(marked_selection);
+        let expected = marked_expected.map(|marked_expected| {
+            let (expected_source, range) = marked_range(marked_expected);
+            assert_eq!(
+                expected_source, source,
+                "expected must be marked on the same source"
+            );
+            range
+        });
+        assert_eq!(
+            snap_out_of_delimiters(&inline_spans(&source), selection),
+            expected,
+            "{message}"
+        );
+    }
+
+    #[test]
+    fn test_snap_out_of_delimiters() {
+        assert_snapped(
+            "**«bold»** rest",
+            Some("**«bold»** rest"),
+            "boundaries already in content must be untouched",
+        );
+        assert_snapped(
+            "*«*bold»** rest",
+            Some("**«bold»** rest"),
+            "a start in the opening delimiter must advance into the content",
+        );
+        assert_snapped(
+            "**«bold*»* rest",
+            Some("**«bold»** rest"),
+            "an end in the closing delimiter must retreat to the content end",
+        );
+        assert_snapped(
+            "**bold*«* rest»",
+            Some("**bold**« rest»"),
+            "a start in the closing delimiter must advance past the whole span",
+        );
+        assert_snapped(
+            "«*»*bold** rest",
+            None,
+            "an end in the opening delimiter must retreat to before the span",
+        );
+        assert_snapped(
+            "**bold«**» rest",
+            None,
+            "a selection covering only delimiter text must collapse",
+        );
+    }
+
+    #[test]
+    fn test_snap_out_of_delimiters_cascades_through_nested_spans() {
+        assert_snapped(
+            "**`«code`*»*",
+            Some("**`«code»`**"),
+            "an end inside bold's closing `**` first snaps to code's closing \
+             backtick, so it must cascade into the code content",
+        );
+    }
+
+    #[test]
+    fn test_snap_to_char_boundaries() {
+        // `√` occupies bytes 1..4.
+        let source = "a√b";
+        assert_eq!(
+            snap_to_char_boundaries(source, 0..5),
+            Some(0..5),
+            "boundaries already on char boundaries must be untouched"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 0..2),
+            Some(0..4),
+            "an end mid-character must expand forward to keep the character"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 2..5),
+            Some(1..5),
+            "a start mid-character must expand backward to keep the character"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 2..3),
+            Some(1..4),
+            "a selection entirely inside a character must cover the whole character"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 2..10),
+            Some(1..5),
+            "an end past the source must clamp to its length"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 2..2),
+            None,
+            "an empty selection must collapse, even mid-character"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, 5..10),
+            None,
+            "a selection entirely past the source must collapse"
+        );
+        assert_eq!(
+            snap_to_char_boundaries(source, Range { start: 4, end: 2 }),
+            None,
+            "a reversed selection must collapse"
+        );
+    }
+
+    fn selection_is_plain(marked_source: &str) -> bool {
+        let (source, selection) = marked_range(marked_source);
+        selection_is_only_inside_code_spans(&inline_spans(&source), &selection)
+    }
+
+    #[test]
+    fn test_selection_is_only_inside_code_spans() {
+        assert!(
+            selection_is_plain("run `«cargo» test` now"),
+            "a selection fully inside the code span's content must be plain"
+        );
+        assert!(
+            !selection_is_plain("«run `cargo» test` now"),
+            "a selection reaching outside the code span must not be plain"
+        );
+        assert!(
+            !selection_is_plain("**`«code»`**"),
+            "code nested in bold must not be plain: the bold span also contains it"
+        );
+    }
+
+    #[test]
+    fn test_markdown_for_selection_balances_inline_spans() {
+        assert_marked_selection("This is **«bold»** text in a sentence.", "**bold**");
+        assert_marked_selection("This is **«bold**» text in a sentence.", "**bold**");
+        assert_marked_selection("Th«is is **bo»ld** text in a sentence.", "is is **bo**");
+
+        assert_marked_selection("This is *«italic»* text in a sentence.", "*italic*");
+        assert_marked_selection("This is *it«al»ic* text in a sentence.", "*al*");
+
+        assert_marked_selection("T«his is `cod»e` all `in one` sentence.", "his is `cod`");
+        assert_marked_selection(
+            "This is `c«ode` all `in o»ne` sentence.",
+            "`ode` all `in o`",
+        );
+        assert_marked_selection(
+            "This is `«code` all `in one»` sentence.",
+            "`code` all `in one`",
+        );
+        assert_marked_selection(
+            "This is `«code` all `in one`» sentence.",
+            "`code` all `in one`",
+        );
+
+        // Special case for single inline code blocks
+        assert_marked_selection("This is `«code»` all `in one` sentence.", "code");
+        assert_marked_selection("This is `«code`» all `in one` sentence.", "code");
+        assert_marked_selection("This is `c«od»e` all `in one` sentence.", "od");
+    }
+
+    #[test]
+    fn test_markdown_for_selection_nested_spans() {
+        assert_marked_selection("**bo«ld wi»th `code` inside**", "**ld wi**");
+        assert_marked_selection("**bold with `c«od»e` inside**", "**`od`**");
+        assert_marked_selection("**bold with `«code»` inside**", "**`code`**");
+        assert_marked_selection(
+            "**«bold with `code` inside»**",
+            "**bold with `code` inside**",
+        );
+        assert_marked_selection(
+            "«**bold with `code` inside**»",
+            "**bold with `code` inside**",
+        );
+    }
+
+    #[test]
+    fn test_markdown_for_selection_links() {
+        assert_marked_selection(
+            "[Visit Rust's we«bsite»](https://rust.org)",
+            "[bsite](https://rust.org)",
+        );
+        assert_marked_selection(
+            "[«Visit Rust's website»](https://rust.org)",
+            "[Visit Rust's website](https://rust.org)",
+        );
+        assert_marked_selection("visit https://«example».com now", "example");
+    }
+
+    #[test]
+    fn test_markdown_for_selection_scopes_to_boundary_blocks() {
+        assert_marked_selection(
+            "**bo«ld one**\n\nmiddle `x`\n\n*ita»lic two*",
+            "**ld one**\n\nmiddle `x`\n\n*ita*",
+        );
+        assert_marked_selection("**bold** first\n\nsecond *ita«li»c* here", "*li*");
+        assert_marked_selection("one\n«\ntwo»", "\ntwo");
+        assert_marked_selection("**one*«*\n\ntw»o", "\n\ntw");
+    }
+
+    #[test]
+    fn test_root_block_index() {
+        let starts = [2, 10, 20];
+        assert_eq!(
+            root_block_index(&starts, 0),
+            0,
+            "an offset before the first block start must clamp to the first block"
+        );
+        assert_eq!(root_block_index(&starts, 2), 0);
+        assert_eq!(root_block_index(&starts, 9), 0);
+        assert_eq!(root_block_index(&starts, 10), 1);
+        assert_eq!(root_block_index(&starts, 19), 1);
+        assert_eq!(root_block_index(&starts, 20), 2);
+        assert_eq!(
+            root_block_index(&starts, 100),
+            2,
+            "an offset past the last block start must clamp to the last block"
+        );
+    }
+
+    #[test]
+    fn test_root_block_events_slices_each_block() {
+        let source = "first **a**\n\nsecond `b`\n\nthird *c*";
+        let parsed = parse_markdown_with_options(source, false, false, false);
+        let events = parsed.events.as_slice();
+        let starts = parsed.root_block_starts.as_slice();
+        assert_eq!(starts, &[0, 13, 25]);
+
+        let mut sliced_events = Vec::new();
+        for block in 0..starts.len() {
+            let slice = root_block_events(events, starts, block);
+            assert!(!slice.is_empty(), "block {block} must have events");
+            let block_end = starts.get(block + 1).copied().unwrap_or(source.len());
+            for (range, event) in slice {
+                assert!(
+                    (starts[block]..block_end).contains(&range.start),
+                    "event {event:?} at {range:?} must start within block {block}"
+                );
+            }
+            sliced_events.extend_from_slice(slice);
+        }
+        assert_eq!(
+            sliced_events, events,
+            "the per-block slices must partition all events in order"
+        );
+
+        assert_eq!(
+            root_block_events(events, starts, starts.len()),
+            events,
+            "an out-of-range block index must fall back to all events"
+        );
+    }
+
+    #[test]
+    fn test_markdown_for_selection_plain_text_and_blocks() {
+        assert_eq!(
+            markdown_for_marked("some «text»"),
+            "text",
+            "plain text must be unchanged"
+        );
+        assert_eq!(
+            markdown_for_marked("«para one\n\n- item **bold**\n- item two»"),
+            "para one\n\n- item **bold**\n- item two",
+            "selections spanning multiple blocks must keep interior syntax as-is"
+        );
+        assert_eq!(
+            markdown_for_marked("```rust\n«let x = 1;»\n```"),
+            "let x = 1;",
+            "a selection inside a fenced code block must stay plain"
+        );
+        assert_eq!(
+            markdown_for("abc", 2..100),
+            "c",
+            "out-of-bounds ends must be clamped"
+        );
+        assert_eq!(
+            markdown_for("abc", Range { start: 3, end: 2 }),
+            "",
+            "inverted ranges must yield an empty string"
+        );
+    }
+}
diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs
index db6c1a80786..81349a59f3b 100644
--- a/crates/markdown_preview/src/markdown_preview_view.rs
+++ b/crates/markdown_preview/src/markdown_preview_view.rs
@@ -1385,7 +1385,11 @@ impl SearchableItem for MarkdownPreviewView {
         _window: &mut Window,
         cx: &mut Context,
     ) -> String {
-        self.markdown.read(cx).selected_text().unwrap_or_default()
+        self.markdown
+            .read(cx)
+            .selected_source()
+            .unwrap_or_default()
+            .to_string()
     }
 
     fn activate_match(
diff --git a/crates/migrator/src/migrations/m_2025_10_02/settings.rs b/crates/migrator/src/migrations/m_2025_10_02/settings.rs
index 8942008e632..bb2a5bbb299 100644
--- a/crates/migrator/src/migrations/m_2025_10_02/settings.rs
+++ b/crates/migrator/src/migrations/m_2025_10_02/settings.rs
@@ -14,9 +14,9 @@ fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<(
     let Some(format_on_save) = obj.get("format_on_save").cloned() else {
         return Ok(());
     };
-    let is_format_on_save_set_to_formatter = format_on_save
-        .as_str()
-        .map_or(true, |s| s != "on" && s != "off");
+    let is_format_on_save_set_to_formatter = format_on_save.as_str().map_or(true, |s| {
+        s != "on" && s != "off" && s != "modifications" && s != "modifications_if_available"
+    });
     if !is_format_on_save_set_to_formatter {
         return Ok(());
     }
diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs
index 5b61e6237e7..e233f393b29 100644
--- a/crates/multi_buffer/src/multi_buffer.rs
+++ b/crates/multi_buffer/src/multi_buffer.rs
@@ -626,7 +626,7 @@ impl DiffState {
                     this.buffer_diff_changed(diff, range, cx);
                     cx.emit(Event::BufferDiffChanged);
                 }
-                BufferDiffEvent::BaseTextChanged | BufferDiffEvent::HunksStagedOrUnstaged(_) => {}
+                BufferDiffEvent::BaseTextChanged => {}
             }),
             diff,
             main_buffer: None,
@@ -660,8 +660,7 @@ impl DiffState {
                             );
                             cx.emit(Event::BufferDiffChanged);
                         }
-                        BufferDiffEvent::BaseTextChanged
-                        | BufferDiffEvent::HunksStagedOrUnstaged(_) => {}
+                        BufferDiffEvent::BaseTextChanged => {}
                     }
                 }
             }),
@@ -2115,6 +2114,9 @@ impl MultiBuffer {
         self.title.as_deref()
     }
 
+    /// The title used for buffers not backed by a file and with no title of their own.
+    pub const DEFAULT_TITLE: &str = "untitled";
+
     pub fn title<'a>(&'a self, cx: &'a App) -> Cow<'a, str> {
         if let Some(title) = self.title.as_ref() {
             return title.into();
@@ -2132,7 +2134,7 @@ impl MultiBuffer {
             }
         };
 
-        "untitled".into()
+        Self::DEFAULT_TITLE.into()
     }
 
     fn buffer_content_title(&self, buffer: &Buffer) -> Option> {
diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs
index a4b17a0c09b..40fe15c0894 100644
--- a/crates/onboarding/src/basics_page.rs
+++ b/crates/onboarding/src/basics_page.rs
@@ -565,20 +565,28 @@ fn render_registry_agent_button(
         .name(agent.name().clone())
         .state(state_element)
         .disabled(installed)
-        .on_click(move |_, _, cx| {
+        .on_click(move |_, window, cx| {
             telemetry::event!("Welcome Agent Install Clicked", agent = agent_id.as_str());
-            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(|| {
-                    CustomAgentServerSettings::Registry {
-                        env: Default::default(),
-                        default_mode: None,
-                        default_config_options: HashMap::default(),
-                        favorite_config_option_values: HashMap::default(),
-                    }
-                });
+            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(|| {
+                        CustomAgentServerSettings::Registry {
+                            env: Default::default(),
+                            default_mode: None,
+                            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,
+            );
         })
 }
 
diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs
index f39a9af218f..a8541a4ee1e 100644
--- a/crates/open_ai/src/completion.rs
+++ b/crates/open_ai/src/completion.rs
@@ -3,16 +3,19 @@ use collections::HashMap;
 use futures::{Stream, StreamExt};
 use language_model_core::{
     CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent,
-    Role, StopReason, TokenUsage,
+    LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
+    LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestToolInput,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
+    LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
+    TokenUsage,
     util::{fix_streamed_json, parse_tool_arguments},
 };
 use std::pin::Pin;
 use std::sync::Arc;
 
 use crate::responses::{
-    ContextManagement, Request as ResponseRequest, ResponseCompactionItem, ResponseError,
+    ContextManagement, Request as ResponseRequest, ResponseCompactionItem,
+    ResponseCustomToolCallItem, ResponseCustomToolCallOutputItem, ResponseError,
     ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem,
     ResponseIncludable, ResponseInputContent, ResponseInputItem, ResponseMessageItem,
     ResponseOutputItem, ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem,
@@ -52,7 +55,17 @@ pub fn into_open_ai(
     max_tokens_parameter: ChatCompletionMaxTokensParameter,
     reasoning_effort: Option,
     interleaved_reasoning: bool,
-) -> crate::Request {
+) -> Result {
+    if request
+        .tools
+        .iter()
+        .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. }))
+    {
+        return Err(anyhow!(
+            "OpenAI Chat Completions does not support custom tools; use Responses API instead"
+        ));
+    }
+
     let stream = !model_id.starts_with("o1-");
     let service_tier = service_tier_for(request.speed);
 
@@ -103,13 +116,18 @@ pub fn into_open_ai(
                     );
                 }
                 MessageContent::ToolUse(tool_use) => {
+                    let LanguageModelToolUseInput::Json(input) = &tool_use.input else {
+                        return Err(anyhow!(
+                            "OpenAI Chat Completions cannot replay custom tool call `{}`",
+                            tool_use.name
+                        ));
+                    };
                     let tool_call = ToolCall {
                         id: tool_use.id.to_string(),
                         content: ToolCallContent::Function {
                             function: FunctionContent {
                                 name: tool_use.name.to_string(),
-                                arguments: serde_json::to_string(&tool_use.input)
-                                    .unwrap_or_default(),
+                                arguments: serde_json::to_string(input).unwrap_or_default(),
                             },
                         },
                     };
@@ -152,7 +170,7 @@ pub fn into_open_ai(
         }
     }
 
-    crate::Request {
+    Ok(crate::Request {
         model: model_id.into(),
         messages,
         stream,
@@ -184,14 +202,21 @@ pub fn into_open_ai(
         tools: request
             .tools
             .into_iter()
-            .map(|tool| crate::ToolDefinition::Function {
-                function: FunctionDefinition {
-                    name: tool.name,
-                    description: Some(tool.description),
-                    parameters: Some(tool.input_schema),
-                },
+            .map(|tool| match tool.input {
+                LanguageModelRequestToolInput::Function { input_schema, .. } => {
+                    Ok(crate::ToolDefinition::Function {
+                        function: FunctionDefinition {
+                            name: tool.name,
+                            description: Some(tool.description),
+                            parameters: Some(input_schema),
+                        },
+                    })
+                }
+                LanguageModelRequestToolInput::Custom { .. } => Err(anyhow!(
+                    "OpenAI Chat Completions does not support custom tools; use Responses API instead"
+                )),
             })
-            .collect(),
+            .collect::>()?,
         tool_choice: request.tool_choice.map(|choice| match choice {
             LanguageModelToolChoice::Auto => crate::ToolChoice::Auto,
             LanguageModelToolChoice::Any => crate::ToolChoice::Required,
@@ -199,7 +224,7 @@ pub fn into_open_ai(
         }),
         reasoning_effort,
         service_tier,
-    }
+    })
 }
 
 pub fn into_open_ai_response(
@@ -232,22 +257,39 @@ pub fn into_open_ai_response(
 
     let mut input_items = Vec::new();
     let mut replayed_reasoning_item_indexes = HashMap::default();
+    let mut tool_use_kinds_by_id = HashMap::default();
     for (index, message) in messages.into_iter().enumerate() {
         append_message_to_response_items(
             message,
             index,
             &mut replayed_reasoning_item_indexes,
+            &mut tool_use_kinds_by_id,
             &mut input_items,
         );
     }
 
     let tools: Vec<_> = tools
         .into_iter()
-        .map(|tool| crate::responses::ToolDefinition::Function {
-            name: tool.name,
-            description: Some(tool.description),
-            parameters: Some(tool.input_schema),
-            strict: None,
+        .map(|tool| match tool.input {
+            LanguageModelRequestToolInput::Function { input_schema, .. } => {
+                crate::responses::ToolDefinition::Function {
+                    name: tool.name,
+                    description: Some(tool.description),
+                    parameters: Some(input_schema),
+                    strict: None,
+                }
+            }
+            LanguageModelRequestToolInput::Custom { format } => {
+                crate::responses::ToolDefinition::Custom {
+                    name: tool.name,
+                    description: if tool.description.is_empty() {
+                        None
+                    } else {
+                        Some(tool.description)
+                    },
+                    format: format.map(custom_tool_format_into_open_ai),
+                }
+            }
         })
         .collect();
 
@@ -323,6 +365,7 @@ fn append_message_to_response_items(
     message: LanguageModelRequestMessage,
     index: usize,
     replayed_reasoning_item_indexes: &mut HashMap,
+    tool_use_kinds_by_id: &mut HashMap,
     input_items: &mut Vec,
 ) {
     let mut content_parts: Vec = Vec::new();
@@ -386,11 +429,29 @@ fn append_message_to_response_items(
                     input_items,
                 );
                 let call_id = tool_use.id.to_string();
-                input_items.push(ResponseInputItem::FunctionCall(ResponseFunctionCallItem {
-                    call_id,
-                    name: tool_use.name.to_string(),
-                    arguments: tool_use.raw_input,
-                }));
+                match tool_use.input {
+                    LanguageModelToolUseInput::Json(_) => {
+                        tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Function);
+                        input_items.push(ResponseInputItem::FunctionCall(
+                            ResponseFunctionCallItem {
+                                call_id,
+                                name: tool_use.name.to_string(),
+                                arguments: tool_use.raw_input,
+                            },
+                        ));
+                    }
+                    LanguageModelToolUseInput::Text(_) => {
+                        tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Custom);
+                        input_items.push(ResponseInputItem::CustomToolCall(
+                            ResponseCustomToolCallItem {
+                                id: None,
+                                call_id,
+                                name: tool_use.name.to_string(),
+                                input: tool_use.raw_input,
+                            },
+                        ));
+                    }
+                }
             }
             MessageContent::ToolResult(tool_result) => {
                 flush_response_parts(
@@ -424,12 +485,24 @@ fn append_message_to_response_items(
                         ResponseFunctionCallOutputContent::List(parts)
                     }
                 };
-                input_items.push(ResponseInputItem::FunctionCallOutput(
-                    ResponseFunctionCallOutputItem {
-                        call_id: tool_result.tool_use_id.to_string(),
-                        output,
-                    },
-                ));
+                match tool_use_kinds_by_id.get(&tool_result.tool_use_id) {
+                    Some(ReplayToolKind::Custom) => {
+                        input_items.push(ResponseInputItem::CustomToolCallOutput(
+                            ResponseCustomToolCallOutputItem {
+                                call_id: tool_result.tool_use_id.to_string(),
+                                output,
+                            },
+                        ));
+                    }
+                    Some(ReplayToolKind::Function) | None => {
+                        input_items.push(ResponseInputItem::FunctionCallOutput(
+                            ResponseFunctionCallOutputItem {
+                                call_id: tool_result.tool_use_id.to_string(),
+                                output,
+                            },
+                        ));
+                    }
+                }
             }
         }
     }
@@ -443,6 +516,33 @@ fn append_message_to_response_items(
     );
 }
 
+#[derive(Clone, Copy)]
+enum ReplayToolKind {
+    Function,
+    Custom,
+}
+
+fn custom_tool_format_into_open_ai(
+    format: LanguageModelCustomToolFormat,
+) -> crate::responses::CustomToolFormat {
+    match format {
+        LanguageModelCustomToolFormat::Text => crate::responses::CustomToolFormat::Text,
+        LanguageModelCustomToolFormat::Grammar { syntax, definition } => {
+            crate::responses::CustomToolFormat::Grammar {
+                syntax: match syntax {
+                    LanguageModelCustomToolGrammarSyntax::Lark => {
+                        crate::responses::CustomToolGrammarSyntax::Lark
+                    }
+                    LanguageModelCustomToolGrammarSyntax::Regex => {
+                        crate::responses::CustomToolGrammarSyntax::Regex
+                    }
+                },
+                definition,
+            }
+        }
+    }
+}
+
 fn append_reasoning_details_to_response_items(
     reasoning_details: Option<&serde_json::Value>,
     replayed_reasoning_item_indexes: &mut HashMap,
@@ -665,7 +765,7 @@ impl OpenAiEventMapper {
                                     id: entry.id.clone().into(),
                                     name: entry.name.as_str().into(),
                                     is_input_complete: false,
-                                    input,
+                                    input: LanguageModelToolUseInput::Json(input),
                                     raw_input: entry.arguments.clone(),
                                     thought_signature: None,
                                 },
@@ -688,7 +788,7 @@ impl OpenAiEventMapper {
                                 id: tool_call.id.clone().into(),
                                 name: tool_call.name.as_str().into(),
                                 is_input_complete: true,
-                                input,
+                                input: LanguageModelToolUseInput::Json(input),
                                 raw_input: tool_call.arguments.clone(),
                                 thought_signature: None,
                             },
@@ -736,6 +836,7 @@ struct RawToolCall {
 
 pub struct OpenAiResponseEventMapper {
     function_calls_by_item: HashMap,
+    custom_tool_calls_by_item: HashMap,
     reasoning_items: Vec,
     current_message_phase: Option,
     pending_stop_reason: Option,
@@ -748,10 +849,17 @@ struct PendingResponseFunctionCall {
     arguments: String,
 }
 
+struct PendingResponseCustomToolCall {
+    call_id: String,
+    name: Arc,
+    input: String,
+}
+
 impl OpenAiResponseEventMapper {
     pub fn new() -> Self {
         Self {
             function_calls_by_item: HashMap::default(),
+            custom_tool_calls_by_item: HashMap::default(),
             reasoning_items: Vec::new(),
             current_message_phase: None,
             pending_stop_reason: None,
@@ -805,6 +913,23 @@ impl OpenAiResponseEventMapper {
                             self.function_calls_by_item.insert(item_id, entry);
                         }
                     }
+                    ResponseOutputItem::CustomToolCall(custom_tool_call) => {
+                        if let Some(item_id) = custom_tool_call.id.clone() {
+                            let call_id = custom_tool_call
+                                .call_id
+                                .clone()
+                                .or_else(|| custom_tool_call.id.clone())
+                                .unwrap_or_else(|| item_id.clone());
+                            let entry = PendingResponseCustomToolCall {
+                                call_id,
+                                name: Arc::::from(
+                                    custom_tool_call.name.clone().unwrap_or_default(),
+                                ),
+                                input: custom_tool_call.input.clone(),
+                            };
+                            self.custom_tool_calls_by_item.insert(item_id, entry);
+                        }
+                    }
                     ResponseOutputItem::Compaction(_) => {
                         events.push(Ok(LanguageModelCompletionEvent::Compaction(
                             CompactionContent::Pending,
@@ -814,7 +939,8 @@ impl OpenAiResponseEventMapper {
                 }
                 events
             }
-            ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
+            ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. }
+            | ResponsesStreamEvent::ReasoningDelta { delta, .. } => {
                 if delta.is_empty() {
                     Vec::new()
                 } else {
@@ -847,7 +973,7 @@ impl OpenAiResponseEventMapper {
                                 id: LanguageModelToolUseId::from(entry.call_id.clone()),
                                 name: entry.name.clone(),
                                 is_input_complete: false,
-                                input,
+                                input: LanguageModelToolUseInput::Json(input),
                                 raw_input: entry.arguments.clone(),
                                 thought_signature: None,
                             },
@@ -872,7 +998,7 @@ impl OpenAiResponseEventMapper {
                                     id: LanguageModelToolUseId::from(entry.call_id.clone()),
                                     name: entry.name.clone(),
                                     is_input_complete: true,
-                                    input,
+                                    input: LanguageModelToolUseInput::Json(input),
                                     raw_input,
                                     thought_signature: None,
                                 },
@@ -891,6 +1017,30 @@ impl OpenAiResponseEventMapper {
                     Vec::new()
                 }
             }
+            ResponsesStreamEvent::CustomToolCallInputDelta { item_id, delta, .. } => {
+                if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) {
+                    entry.input.push_str(&delta);
+                    return vec![Ok(LanguageModelCompletionEvent::ToolUse(
+                        LanguageModelToolUse {
+                            id: LanguageModelToolUseId::from(entry.call_id.clone()),
+                            name: entry.name.clone(),
+                            is_input_complete: false,
+                            input: LanguageModelToolUseInput::Text(entry.input.clone()),
+                            raw_input: entry.input.clone(),
+                            thought_signature: None,
+                        },
+                    ))];
+                }
+                Vec::new()
+            }
+            ResponsesStreamEvent::CustomToolCallInputDone { item_id, input, .. } => {
+                if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id)
+                    && !input.is_empty()
+                {
+                    entry.input = input;
+                }
+                self.finish_pending_custom_tool_call(&item_id, None)
+            }
             ResponsesStreamEvent::Completed { response } => {
                 self.handle_completion(response, StopReason::EndTurn)
             }
@@ -958,6 +1108,13 @@ impl OpenAiResponseEventMapper {
             ResponsesStreamEvent::OutputItemDone { item, .. } => match item {
                 ResponseOutputItem::Reasoning(reasoning) => self.capture_reasoning_item(&reasoning),
                 ResponseOutputItem::Message(message) => self.capture_message_phase(&message),
+                ResponseOutputItem::CustomToolCall(custom_tool_call) => {
+                    if let Some(item_id) = custom_tool_call.id.as_ref() {
+                        self.finish_pending_custom_tool_call(item_id, Some(&custom_tool_call))
+                    } else {
+                        Vec::new()
+                    }
+                }
                 ResponseOutputItem::Compaction(compaction) => {
                     vec![Ok(LanguageModelCompletionEvent::Compaction(
                         CompactionContent::Encrypted {
@@ -973,6 +1130,7 @@ impl OpenAiResponseEventMapper {
             | ResponsesStreamEvent::ContentPartDone { .. }
             | ResponsesStreamEvent::ReasoningSummaryTextDone { .. }
             | ResponsesStreamEvent::ReasoningSummaryPartDone { .. }
+            | ResponsesStreamEvent::ReasoningDone { .. }
             | ResponsesStreamEvent::Created { .. }
             | ResponsesStreamEvent::InProgress { .. }
             | ResponsesStreamEvent::Unknown => Vec::new(),
@@ -1013,48 +1171,109 @@ impl OpenAiResponseEventMapper {
     ) -> Vec> {
         let mut events = Vec::new();
         for item in output {
-            if let ResponseOutputItem::FunctionCall(function_call) = item {
-                let Some(call_id) = function_call
-                    .call_id
-                    .clone()
-                    .or_else(|| function_call.id.clone())
-                else {
-                    log::error!(
-                        "Function call item missing both call_id and id: {:?}",
-                        function_call
-                    );
-                    continue;
-                };
-                let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default());
-                let arguments = &function_call.arguments;
-                self.pending_stop_reason = Some(StopReason::ToolUse);
-                match parse_tool_arguments(arguments) {
-                    Ok(input) => {
-                        events.push(Ok(LanguageModelCompletionEvent::ToolUse(
-                            LanguageModelToolUse {
+            match item {
+                ResponseOutputItem::FunctionCall(function_call) => {
+                    let Some(call_id) = function_call
+                        .call_id
+                        .clone()
+                        .or_else(|| function_call.id.clone())
+                    else {
+                        log::error!(
+                            "Function call item missing both call_id and id: {:?}",
+                            function_call
+                        );
+                        continue;
+                    };
+                    let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default());
+                    let arguments = &function_call.arguments;
+                    self.pending_stop_reason = Some(StopReason::ToolUse);
+                    match parse_tool_arguments(arguments) {
+                        Ok(input) => {
+                            events.push(Ok(LanguageModelCompletionEvent::ToolUse(
+                                LanguageModelToolUse {
+                                    id: LanguageModelToolUseId::from(call_id.clone()),
+                                    name: name.clone(),
+                                    is_input_complete: true,
+                                    input: LanguageModelToolUseInput::Json(input),
+                                    raw_input: arguments.clone(),
+                                    thought_signature: None,
+                                },
+                            )));
+                        }
+                        Err(error) => {
+                            events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
                                 id: LanguageModelToolUseId::from(call_id.clone()),
-                                name: name.clone(),
-                                is_input_complete: true,
-                                input,
-                                raw_input: arguments.clone(),
-                                thought_signature: None,
-                            },
-                        )));
-                    }
-                    Err(error) => {
-                        events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
-                            id: LanguageModelToolUseId::from(call_id.clone()),
-                            tool_name: name.clone(),
-                            raw_input: Arc::::from(arguments.clone()),
-                            json_parse_error: error.to_string(),
-                        }));
+                                tool_name: name.clone(),
+                                raw_input: Arc::::from(arguments.clone()),
+                                json_parse_error: error.to_string(),
+                            }));
+                        }
                     }
                 }
+                ResponseOutputItem::CustomToolCall(custom_tool_call) => {
+                    events.extend(self.emit_custom_tool_call(custom_tool_call));
+                }
+                _ => {}
             }
         }
         events
     }
 
+    fn emit_custom_tool_call(
+        &mut self,
+        custom_tool_call: &crate::responses::ResponseCustomToolCall,
+    ) -> Vec> {
+        let Some(call_id) = custom_tool_call
+            .call_id
+            .clone()
+            .or_else(|| custom_tool_call.id.clone())
+        else {
+            log::error!(
+                "Custom tool call item missing both call_id and id: {:?}",
+                custom_tool_call
+            );
+            return Vec::new();
+        };
+        self.pending_stop_reason = Some(StopReason::ToolUse);
+        let input = custom_tool_call.input.clone();
+        vec![Ok(LanguageModelCompletionEvent::ToolUse(
+            LanguageModelToolUse {
+                id: LanguageModelToolUseId::from(call_id),
+                name: Arc::from(custom_tool_call.name.clone().unwrap_or_default()),
+                is_input_complete: true,
+                input: LanguageModelToolUseInput::Text(input.clone()),
+                raw_input: input,
+                thought_signature: None,
+            },
+        ))]
+    }
+
+    fn finish_pending_custom_tool_call(
+        &mut self,
+        item_id: &str,
+        fallback: Option<&crate::responses::ResponseCustomToolCall>,
+    ) -> Vec> {
+        let Some(mut entry) = self.custom_tool_calls_by_item.remove(item_id) else {
+            return Vec::new();
+        };
+        if let Some(fallback) = fallback
+            && !fallback.input.is_empty()
+        {
+            entry.input = fallback.input.clone();
+        }
+        self.pending_stop_reason = Some(StopReason::ToolUse);
+        vec![Ok(LanguageModelCompletionEvent::ToolUse(
+            LanguageModelToolUse {
+                id: LanguageModelToolUseId::from(entry.call_id),
+                name: entry.name,
+                is_input_complete: true,
+                input: LanguageModelToolUseInput::Text(entry.input.clone()),
+                raw_input: entry.input,
+                thought_signature: None,
+            },
+        ))]
+    }
+
     fn capture_reasoning_items_from_output(
         &mut self,
         output: &[ResponseOutputItem],
@@ -1243,15 +1462,17 @@ fn response_reasoning_input_item_from_output(
 #[cfg(test)]
 mod tests {
     use crate::responses::{
-        ReasoningSummaryPart, ResponseError, ResponseFunctionToolCall, ResponseIncompleteDetails,
-        ResponseInputTokensDetails, ResponseOutputItem, ResponseOutputMessage,
-        ResponseReasoningItem, ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent,
+        ReasoningSummaryPart, ResponseCustomToolCall, ResponseError, ResponseFunctionToolCall,
+        ResponseIncompleteDetails, ResponseInputItem, ResponseInputTokensDetails,
+        ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseSummary,
+        ResponseUsage, StreamEvent as ResponsesStreamEvent, ToolDefinition,
     };
     use futures::{StreamExt, executor::block_on};
     use language_model_core::{
-        LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool,
+        LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
+        LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput,
         LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
-        LanguageModelToolUseId, SharedString, Speed,
+        LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, Speed,
     };
     use pretty_assertions::assert_eq;
     use serde_json::json;
@@ -1304,6 +1525,16 @@ mod tests {
         })
     }
 
+    fn response_item_custom_tool_call(id: &str, input: &str) -> ResponseOutputItem {
+        ResponseOutputItem::CustomToolCall(ResponseCustomToolCall {
+            id: Some(id.to_string()),
+            status: Some("in_progress".to_string()),
+            name: Some("apply_patch".to_string()),
+            call_id: Some("call_abc".to_string()),
+            input: input.to_string(),
+        })
+    }
+
     fn response_reasoning_item(
         id: &str,
         summary: Vec,
@@ -1371,6 +1602,22 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn responses_stream_maps_mantle_reasoning_delta() {
+        let event = serde_json::from_value::(json!({
+            "type": "response.reasoning.delta",
+            "delta": "checking the contract terms"
+        }))
+        .unwrap();
+
+        let mapped = map_response_events(vec![event]);
+        assert!(matches!(
+            &mapped[0],
+            LanguageModelCompletionEvent::Thinking { text, signature: None }
+                if text == "checking the contract terms"
+        ));
+    }
+
     #[test]
     fn response_usage_deserializes_cached_tokens() -> Result<()> {
         let usage: ResponseUsage = serde_json::from_value(json!({
@@ -1399,6 +1646,97 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn responses_custom_tool_wire_types_round_trip() -> Result<()> {
+        let tool_json = json!({
+            "type": "custom",
+            "name": "apply_patch",
+            "description": "Apply a patch",
+            "format": {
+                "type": "grammar",
+                "syntax": "lark",
+                "definition": "start: /.+/"
+            }
+        });
+        let tool: ToolDefinition = serde_json::from_value(tool_json.clone())?;
+        assert_eq!(serde_json::to_value(tool)?, tool_json);
+
+        let text_tool_json = json!({
+            "type": "custom",
+            "name": "write_text",
+            "format": { "type": "text" }
+        });
+        let text_tool: ToolDefinition = serde_json::from_value(text_tool_json.clone())?;
+        assert_eq!(serde_json::to_value(text_tool)?, text_tool_json);
+
+        let input_json = json!({
+            "type": "custom_tool_call",
+            "id": "ctc_1",
+            "call_id": "call_abc",
+            "name": "apply_patch",
+            "input": "*** Begin Patch\n*** End Patch"
+        });
+        let input: ResponseInputItem = serde_json::from_value(input_json.clone())?;
+        assert_eq!(serde_json::to_value(input)?, input_json);
+
+        let output_json = json!({
+            "type": "custom_tool_call_output",
+            "call_id": "call_abc",
+            "output": "ok"
+        });
+        let output: ResponseInputItem = serde_json::from_value(output_json.clone())?;
+        assert_eq!(serde_json::to_value(output)?, output_json);
+
+        let output_item_json = json!({
+            "id": "ctc_1",
+            "type": "custom_tool_call",
+            "status": "completed",
+            "call_id": "call_abc",
+            "name": "apply_patch",
+            "input": "*** Begin Patch\n*** End Patch"
+        });
+        let output_item: ResponseOutputItem = serde_json::from_value(output_item_json.clone())?;
+        assert_eq!(serde_json::to_value(output_item)?, output_item_json);
+
+        let delta_json = json!({
+            "type": "response.custom_tool_call_input.delta",
+            "output_index": 0,
+            "item_id": "ctc_1",
+            "sequence_number": 5,
+            "delta": "chunk"
+        });
+        let delta: ResponsesStreamEvent = serde_json::from_value(delta_json)?;
+        assert!(matches!(
+            delta,
+            ResponsesStreamEvent::CustomToolCallInputDelta {
+                output_index: 0,
+                sequence_number: Some(5),
+                ref item_id,
+                ref delta,
+            } if item_id == "ctc_1" && delta == "chunk"
+        ));
+
+        let done_json = json!({
+            "type": "response.custom_tool_call_input.done",
+            "output_index": 0,
+            "item_id": "ctc_1",
+            "sequence_number": 6,
+            "input": "full text"
+        });
+        let done: ResponsesStreamEvent = serde_json::from_value(done_json)?;
+        assert!(matches!(
+            done,
+            ResponsesStreamEvent::CustomToolCallInputDone {
+                output_index: 0,
+                sequence_number: Some(6),
+                ref item_id,
+                ref input,
+            } if item_id == "ctc_1" && input == "full text"
+        ));
+
+        Ok(())
+    }
+
     #[test]
     fn into_open_ai_response_builds_complete_payload() {
         let tool_call_id = LanguageModelToolUseId::from("call-42");
@@ -1408,7 +1746,7 @@ mod tests {
             id: tool_call_id.clone(),
             name: Arc::from("get_weather"),
             raw_input: tool_arguments.clone(),
-            input: tool_input,
+            input: LanguageModelToolUseInput::Json(tool_input),
             is_input_complete: true,
             thought_signature: None,
         };
@@ -1460,12 +1798,12 @@ mod tests {
                     reasoning_details: None,
                 },
             ],
-            tools: vec![LanguageModelRequestTool {
-                name: "get_weather".into(),
-                description: "Fetches the weather".into(),
-                input_schema: json!({ "type": "object" }),
-                use_input_streaming: false,
-            }],
+            tools: vec![LanguageModelRequestTool::function(
+                "get_weather".into(),
+                "Fetches the weather".into(),
+                json!({ "type": "object" }),
+                false,
+            )],
             tool_choice: Some(LanguageModelToolChoice::Any),
             stop: vec!["".into()],
             temperature: None,
@@ -1544,6 +1882,166 @@ mod tests {
         assert_eq!(serialized, expected);
     }
 
+    #[test]
+    fn responses_stream_maps_custom_tool_input() {
+        let events = vec![
+            ResponsesStreamEvent::OutputItemAdded {
+                output_index: 0,
+                sequence_number: None,
+                item: response_item_custom_tool_call("ctc_1", ""),
+            },
+            ResponsesStreamEvent::CustomToolCallInputDelta {
+                item_id: "ctc_1".into(),
+                output_index: 0,
+                delta: "*** Begin".into(),
+                sequence_number: Some(1),
+            },
+            ResponsesStreamEvent::CustomToolCallInputDelta {
+                item_id: "ctc_1".into(),
+                output_index: 0,
+                delta: " Patch".into(),
+                sequence_number: Some(2),
+            },
+            ResponsesStreamEvent::CustomToolCallInputDone {
+                item_id: "ctc_1".into(),
+                output_index: 0,
+                input: "*** Begin Patch".into(),
+                sequence_number: Some(3),
+            },
+            ResponsesStreamEvent::OutputItemDone {
+                output_index: 0,
+                sequence_number: None,
+                item: response_item_custom_tool_call("ctc_1", "*** Begin Patch"),
+            },
+            ResponsesStreamEvent::Completed {
+                response: ResponseSummary::default(),
+            },
+        ];
+
+        let mapped = map_response_events(events);
+        assert_eq!(
+            mapped,
+            vec![
+                LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+                    id: LanguageModelToolUseId::from("call_abc"),
+                    name: Arc::from("apply_patch"),
+                    raw_input: "*** Begin".into(),
+                    input: LanguageModelToolUseInput::Text("*** Begin".into()),
+                    is_input_complete: false,
+                    thought_signature: None,
+                }),
+                LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+                    id: LanguageModelToolUseId::from("call_abc"),
+                    name: Arc::from("apply_patch"),
+                    raw_input: "*** Begin Patch".into(),
+                    input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
+                    is_input_complete: false,
+                    thought_signature: None,
+                }),
+                LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+                    id: LanguageModelToolUseId::from("call_abc"),
+                    name: Arc::from("apply_patch"),
+                    raw_input: "*** Begin Patch".into(),
+                    input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
+                    is_input_complete: true,
+                    thought_signature: None,
+                }),
+                LanguageModelCompletionEvent::Stop(StopReason::ToolUse),
+            ]
+        );
+    }
+
+    #[test]
+    fn into_open_ai_response_replays_custom_tool_calls() {
+        let tool_call_id = LanguageModelToolUseId::from("call_abc");
+        let raw_input = "*** Begin Patch\n*** End Patch".to_string();
+        let tool_use = LanguageModelToolUse {
+            id: tool_call_id.clone(),
+            name: Arc::from("apply_patch"),
+            raw_input: raw_input.clone(),
+            input: LanguageModelToolUseInput::Text(raw_input.clone()),
+            is_input_complete: true,
+            thought_signature: None,
+        };
+        let tool_result = LanguageModelToolResult {
+            tool_use_id: tool_call_id,
+            tool_name: Arc::from("apply_patch"),
+            is_error: false,
+            content: vec![LanguageModelToolResultContent::Text(Arc::from("ok"))],
+            output: None,
+        };
+
+        let request = LanguageModelRequest {
+            thread_id: None,
+            prompt_id: None,
+            intent: None,
+            messages: vec![LanguageModelRequestMessage {
+                role: Role::Assistant,
+                content: vec![
+                    MessageContent::ToolUse(tool_use),
+                    MessageContent::ToolResult(tool_result),
+                ],
+                cache: false,
+                reasoning_details: None,
+            }],
+            tools: vec![LanguageModelRequestTool {
+                name: "apply_patch".into(),
+                description: "Apply a patch".into(),
+                input: LanguageModelRequestToolInput::Custom {
+                    format: Some(LanguageModelCustomToolFormat::Grammar {
+                        syntax: LanguageModelCustomToolGrammarSyntax::Lark,
+                        definition: "start: /.+/".into(),
+                    }),
+                },
+            }],
+            tool_choice: None,
+            stop: Vec::new(),
+            temperature: None,
+            thinking_allowed: false,
+            thinking_effort: None,
+            speed: None,
+            compact_at_tokens: None,
+        };
+
+        let response =
+            into_open_ai_response(request, "custom-model", false, false, None, None, false);
+        let serialized = serde_json::to_value(response).unwrap();
+        assert_eq!(
+            serialized,
+            json!({
+                "model": "custom-model",
+                "input": [
+                    {
+                        "type": "custom_tool_call",
+                        "call_id": "call_abc",
+                        "name": "apply_patch",
+                        "input": raw_input
+                    },
+                    {
+                        "type": "custom_tool_call_output",
+                        "call_id": "call_abc",
+                        "output": "ok"
+                    }
+                ],
+                "store": false,
+                "stream": true,
+                "parallel_tool_calls": false,
+                "tools": [
+                    {
+                        "type": "custom",
+                        "name": "apply_patch",
+                        "description": "Apply a patch",
+                        "format": {
+                            "type": "grammar",
+                            "syntax": "lark",
+                            "definition": "start: /.+/"
+                        }
+                    }
+                ]
+            })
+        );
+    }
+
     #[test]
     fn into_open_ai_response_replays_encrypted_reasoning_details() {
         let tool_call_id = LanguageModelToolUseId::from("call-42");
@@ -1552,7 +2050,7 @@ mod tests {
             id: tool_call_id,
             name: Arc::from("get_weather"),
             raw_input: tool_arguments.clone(),
-            input: json!({ "city": "Boston" }),
+            input: LanguageModelToolUseInput::Json(json!({ "city": "Boston" })),
             is_input_complete: true,
             thought_signature: None,
         };
@@ -1832,7 +2330,7 @@ mod tests {
                 ChatCompletionMaxTokensParameter::MaxCompletionTokens,
                 None,
                 false,
-            );
+            )?;
 
             let serialized = serde_json::to_value(&chat)?;
             assert_eq!(
@@ -1877,7 +2375,7 @@ mod tests {
             ChatCompletionMaxTokensParameter::MaxTokens,
             None,
             false,
-        );
+        )?;
 
         let serialized = serde_json::to_value(&chat)?;
         assert_eq!(serialized.get("max_completion_tokens"), None);
@@ -2682,8 +3180,7 @@ mod tests {
             }) if id.to_string() == "call_123"
                 && name.as_ref() == "get_weather"
                 && raw_input == ""
-                && input.is_object()
-                && input.as_object().unwrap().is_empty()
+                && matches!(input, LanguageModelToolUseInput::Json(value) if value.as_object().is_some_and(|object| object.is_empty()))
         ));
         assert!(matches!(
             mapped[1],
@@ -3180,7 +3677,7 @@ mod tests {
             id: tool_use_id.clone(),
             name: Arc::from("search"),
             raw_input: tool_arguments.clone(),
-            input: tool_input,
+            input: LanguageModelToolUseInput::Json(tool_input),
             is_input_complete: true,
             thought_signature: None,
         };
@@ -3241,7 +3738,8 @@ mod tests {
             ChatCompletionMaxTokensParameter::MaxCompletionTokens,
             None,
             true,
-        );
+        )
+        .unwrap();
         assert_eq!(
             serde_json::to_value(&result).unwrap()["messages"],
             json!([
@@ -3265,7 +3763,8 @@ mod tests {
             ChatCompletionMaxTokensParameter::MaxCompletionTokens,
             None,
             false,
-        );
+        )
+        .unwrap();
         assert_eq!(
             serde_json::to_value(&result).unwrap()["messages"],
             json!([
diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs
index ec25a80d7e2..3f1502b9cb0 100644
--- a/crates/open_ai/src/open_ai.rs
+++ b/crates/open_ai/src/open_ai.rs
@@ -68,7 +68,6 @@ pub enum Model {
     #[serde(rename = "gpt-5")]
     Five,
     #[serde(rename = "gpt-5-mini")]
-    #[default]
     FiveMini,
     #[serde(rename = "gpt-5-nano")]
     FiveNano,
@@ -90,6 +89,13 @@ pub enum Model {
     FivePointFive,
     #[serde(rename = "gpt-5.5-pro")]
     FivePointFivePro,
+    #[serde(rename = "gpt-5.6-sol")]
+    #[default]
+    FivePointSixSol,
+    #[serde(rename = "gpt-5.6-terra")]
+    FivePointSixTerra,
+    #[serde(rename = "gpt-5.6-luna")]
+    FivePointSixLuna,
     #[serde(rename = "custom")]
     Custom {
         name: String,
@@ -116,7 +122,7 @@ const fn default_supports_images() -> bool {
 
 impl Model {
     pub fn default_fast() -> Self {
-        Self::FiveMini
+        Self::FivePointSixLuna
     }
 
     pub fn from_id(id: &str) -> Result {
@@ -136,6 +142,9 @@ impl Model {
             "gpt-5.4-pro" => Ok(Self::FivePointFourPro),
             "gpt-5.5" => Ok(Self::FivePointFive),
             "gpt-5.5-pro" => Ok(Self::FivePointFivePro),
+            "gpt-5.6-sol" => Ok(Self::FivePointSixSol),
+            "gpt-5.6-terra" => Ok(Self::FivePointSixTerra),
+            "gpt-5.6-luna" => Ok(Self::FivePointSixLuna),
             invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
         }
     }
@@ -157,6 +166,9 @@ impl Model {
             Self::FivePointFourPro => "gpt-5.4-pro",
             Self::FivePointFive => "gpt-5.5",
             Self::FivePointFivePro => "gpt-5.5-pro",
+            Self::FivePointSixSol => "gpt-5.6-sol",
+            Self::FivePointSixTerra => "gpt-5.6-terra",
+            Self::FivePointSixLuna => "gpt-5.6-luna",
             Self::Custom { name, .. } => name,
         }
     }
@@ -178,6 +190,9 @@ impl Model {
             Self::FivePointFourPro => "gpt-5.4-pro",
             Self::FivePointFive => "gpt-5.5",
             Self::FivePointFivePro => "gpt-5.5-pro",
+            Self::FivePointSixSol => "gpt-5.6-sol",
+            Self::FivePointSixTerra => "gpt-5.6-terra",
+            Self::FivePointSixLuna => "gpt-5.6-luna",
             Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()),
         }
     }
@@ -199,6 +214,9 @@ impl Model {
             Self::FivePointFourPro => 1_050_000,
             Self::FivePointFive => 1_050_000,
             Self::FivePointFivePro => 1_050_000,
+            Self::FivePointSixSol => 1_050_000,
+            Self::FivePointSixTerra => 1_050_000,
+            Self::FivePointSixLuna => 1_050_000,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
@@ -223,6 +241,9 @@ impl Model {
             Self::FivePointFourPro => Some(128_000),
             Self::FivePointFive => Some(128_000),
             Self::FivePointFivePro => Some(128_000),
+            Self::FivePointSixSol => Some(128_000),
+            Self::FivePointSixTerra => Some(128_000),
+            Self::FivePointSixLuna => Some(128_000),
         }
     }
 
@@ -236,6 +257,7 @@ impl Model {
             | Self::FivePointFour
             | Self::FivePointFourMini
             | Self::FivePointFourNano => Some(ReasoningEffort::None),
+            Self::FivePointSixSol => Some(ReasoningEffort::Low),
             Self::O3
             | Self::Five
             | Self::FiveMini
@@ -243,7 +265,9 @@ impl Model {
             | Self::FivePointThreeCodex
             | Self::FivePointFourPro
             | Self::FivePointFive
-            | Self::FivePointFivePro => Some(ReasoningEffort::Medium),
+            | Self::FivePointFivePro
+            | Self::FivePointSixTerra
+            | Self::FivePointSixLuna => Some(ReasoningEffort::Medium),
             _ => None,
         }
     }
@@ -290,6 +314,14 @@ impl Model {
                 ReasoningEffort::High,
                 ReasoningEffort::XHigh,
             ],
+            Self::FivePointSixSol | Self::FivePointSixTerra | Self::FivePointSixLuna => &[
+                ReasoningEffort::None,
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+                ReasoningEffort::Max,
+            ],
             Self::FivePointTwo
             | Self::FivePointFour
             | Self::FivePointFive
@@ -333,6 +365,9 @@ impl Model {
             | Self::FivePointFourPro
             | Self::FivePointFive
             | Self::FivePointFivePro
+            | Self::FivePointSixSol
+            | Self::FivePointSixTerra
+            | Self::FivePointSixLuna
             | Self::FiveNano => true,
             Self::O3 | Model::Custom { .. } => false,
         }
@@ -360,7 +395,10 @@ impl Model {
             | Self::FivePointFour
             | Self::FivePointFourPro
             | Self::FivePointFive
-            | Self::FivePointFivePro => true,
+            | Self::FivePointFivePro
+            | Self::FivePointSixSol
+            | Self::FivePointSixTerra
+            | Self::FivePointSixLuna => true,
             Self::Four
             | Self::FourOmniMini
             | Self::O3
@@ -387,7 +425,10 @@ impl Model {
             | Self::FivePointThreeCodex
             | Self::FivePointFourMini
             | Self::FivePointFour
-            | Self::FivePointFive => true,
+            | Self::FivePointFive
+            | Self::FivePointSixSol
+            | Self::FivePointSixTerra
+            | Self::FivePointSixLuna => true,
             Self::Four
             | Self::FiveNano
             | Self::FivePointFourNano
diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs
index 647f9fcbace..f508a274605 100644
--- a/crates/open_ai/src/responses.rs
+++ b/crates/open_ai/src/responses.rs
@@ -66,6 +66,8 @@ pub enum ResponseInputItem {
     Message(ResponseMessageItem),
     FunctionCall(ResponseFunctionCallItem),
     FunctionCallOutput(ResponseFunctionCallOutputItem),
+    CustomToolCall(ResponseCustomToolCallItem),
+    CustomToolCallOutput(ResponseCustomToolCallOutputItem),
     Reasoning(ResponseReasoningInputItem),
     Compaction(ResponseCompactionItem),
 }
@@ -98,6 +100,21 @@ pub struct ResponseFunctionCallOutputItem {
     pub output: ResponseFunctionCallOutputContent,
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ResponseCustomToolCallItem {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub id: Option,
+    pub call_id: String,
+    pub name: String,
+    pub input: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ResponseCustomToolCallOutputItem {
+    pub call_id: String,
+    pub output: ResponseFunctionCallOutputContent,
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 pub struct ResponseReasoningInputItem {
     #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -157,7 +174,7 @@ pub enum ReasoningSummaryMode {
     Detailed,
 }
 
-#[derive(Serialize, Debug)]
+#[derive(Serialize, Deserialize, Debug)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ToolDefinition {
     Function {
@@ -169,9 +186,33 @@ pub enum ToolDefinition {
         #[serde(skip_serializing_if = "Option::is_none")]
         strict: Option,
     },
+    Custom {
+        name: String,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        description: Option,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        format: Option,
+    },
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum CustomToolFormat {
+    Text,
+    Grammar {
+        syntax: CustomToolGrammarSyntax,
+        definition: String,
+    },
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum CustomToolGrammarSyntax {
+    Lark,
+    Regex,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct ResponseError {
     #[serde(default)]
     pub code: Option,
@@ -310,6 +351,23 @@ pub enum StreamEvent {
         output_index: usize,
         summary_index: usize,
     },
+    #[serde(rename = "response.reasoning.delta")]
+    ReasoningDelta {
+        #[serde(default)]
+        item_id: Option,
+        #[serde(default)]
+        output_index: Option,
+        delta: String,
+    },
+    #[serde(rename = "response.reasoning.done")]
+    ReasoningDone {
+        #[serde(default)]
+        item_id: Option,
+        #[serde(default)]
+        output_index: Option,
+        #[serde(default)]
+        text: Option,
+    },
     #[serde(rename = "response.function_call_arguments.delta")]
     FunctionCallArgumentsDelta {
         item_id: String,
@@ -326,6 +384,22 @@ pub enum StreamEvent {
         #[serde(default)]
         sequence_number: Option,
     },
+    #[serde(rename = "response.custom_tool_call_input.delta")]
+    CustomToolCallInputDelta {
+        item_id: String,
+        output_index: usize,
+        delta: String,
+        #[serde(default)]
+        sequence_number: Option,
+    },
+    #[serde(rename = "response.custom_tool_call_input.done")]
+    CustomToolCallInputDone {
+        item_id: String,
+        output_index: usize,
+        input: String,
+        #[serde(default)]
+        sequence_number: Option,
+    },
     #[serde(rename = "response.completed")]
     Completed { response: ResponseSummary },
     #[serde(rename = "response.incomplete")]
@@ -343,7 +417,7 @@ pub enum StreamEvent {
     Unknown,
 }
 
-#[derive(Deserialize, Debug, Default, Clone)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct ResponseSummary {
     #[serde(default)]
     pub id: Option,
@@ -361,13 +435,13 @@ pub struct ResponseSummary {
     pub service_tier: Option,
 }
 
-#[derive(Deserialize, Debug, Default, Clone)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct ResponseIncompleteDetails {
     #[serde(default)]
     pub reason: Option,
 }
 
-#[derive(Deserialize, Debug, Default, Clone)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct ResponseUsage {
     #[serde(default)]
     pub input_tokens: Option,
@@ -381,30 +455,32 @@ pub struct ResponseUsage {
     pub total_tokens: Option,
 }
 
-#[derive(Deserialize, Debug, Default, Clone)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct ResponseInputTokensDetails {
     #[serde(default)]
     pub cached_tokens: u64,
 }
 
-#[derive(Deserialize, Debug, Default, Clone)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct ResponseOutputTokensDetails {
     #[serde(default)]
     pub reasoning_tokens: u64,
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ResponseOutputItem {
     Message(ResponseOutputMessage),
     FunctionCall(ResponseFunctionToolCall),
+    CustomToolCall(ResponseCustomToolCall),
     Reasoning(ResponseReasoningItem),
     Compaction(ResponseCompactionItem),
+    /// Deserialization-only catch-all; must never be serialized back to the API.
     #[serde(other)]
     Unknown,
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct ResponseReasoningItem {
     #[serde(default)]
     pub id: Option,
@@ -418,7 +494,7 @@ pub struct ResponseReasoningItem {
     pub status: Option,
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ReasoningSummaryPart {
     SummaryText {
@@ -428,7 +504,7 @@ pub enum ReasoningSummaryPart {
     Unknown,
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct ResponseOutputMessage {
     #[serde(default)]
     pub id: Option,
@@ -442,7 +518,7 @@ pub struct ResponseOutputMessage {
     pub phase: Option,
 }
 
-#[derive(Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct ResponseFunctionToolCall {
     #[serde(default)]
     pub id: Option,
@@ -456,6 +532,20 @@ pub struct ResponseFunctionToolCall {
     pub status: Option,
 }
 
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct ResponseCustomToolCall {
+    #[serde(default)]
+    pub id: Option,
+    #[serde(default)]
+    pub status: Option,
+    #[serde(default)]
+    pub call_id: Option,
+    #[serde(default)]
+    pub name: Option,
+    #[serde(default)]
+    pub input: String,
+}
+
 pub async fn stream_response(
     client: &dyn HttpClient,
     provider_name: &str,
@@ -563,6 +653,16 @@ pub async fn stream_response(
                                     });
                                 }
                             }
+                            ResponseOutputItem::CustomToolCall(custom_tool_call) => {
+                                if let Some(ref item_id) = custom_tool_call.id {
+                                    all_events.push(StreamEvent::CustomToolCallInputDone {
+                                        item_id: item_id.clone(),
+                                        output_index,
+                                        input: custom_tool_call.input.clone(),
+                                        sequence_number: None,
+                                    });
+                                }
+                            }
                             ResponseOutputItem::Reasoning(reasoning) => {
                                 if let Some(ref item_id) = reasoning.id {
                                     for part in &reasoning.summary {
diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs
index ab030721e35..79d95b9cd25 100644
--- a/crates/opencode/src/opencode.rs
+++ b/crates/opencode/src/opencode.rs
@@ -77,6 +77,10 @@ pub enum Model {
     ClaudeSonnet4,
     #[serde(rename = "claude-haiku-4-5")]
     ClaudeHaiku4_5,
+    #[serde(rename = "claude-sonnet-5")]
+    ClaudeSonnet5,
+    #[serde(rename = "claude-fable-5")]
+    ClaudeFable5,
 
     // -- OpenAI Responses API models --
     #[serde(rename = "gpt-5.5")]
@@ -203,23 +207,24 @@ impl Model {
         match self {
             // Models available in both Zen and Go
             Self::Glm5_1
+            | Self::Glm5_2
             | Self::KimiK2_6
+            | Self::KimiK2_7Code
             | Self::MiniMaxM2_7
+            | Self::MiniMaxM3
             | Self::DeepSeekV4Pro
             | Self::DeepSeekV4Flash
             | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go],
 
             // Go-only models
-            Self::MimoV2_5Pro
-            | Self::MimoV2_5
-            | Self::Qwen3_7Plus
-            | Self::Qwen3_7Max
-            | Self::KimiK2_7Code
-            | Self::Glm5_2
-            | Self::MiniMaxM3 => &[OpenCodeSubscription::Go],
+            Self::MimoV2_5Pro | Self::MimoV2_5 | Self::Qwen3_7Plus | Self::Qwen3_7Max => {
+                &[OpenCodeSubscription::Go]
+            }
 
             // Deprecated on Go (per models.dev); still offered on Zen
-            Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 => &[OpenCodeSubscription::Zen],
+            Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 | Self::Qwen3_5Plus => {
+                &[OpenCodeSubscription::Zen]
+            }
 
             // Free models
             Self::Nemotron3UltraFree | Self::BigPickle => &[OpenCodeSubscription::Free],
@@ -243,6 +248,8 @@ impl Model {
             Self::ClaudeSonnet4_5 => "claude-sonnet-4-5",
             Self::ClaudeSonnet4 => "claude-sonnet-4",
             Self::ClaudeHaiku4_5 => "claude-haiku-4-5",
+            Self::ClaudeSonnet5 => "claude-sonnet-5",
+            Self::ClaudeFable5 => "claude-fable-5",
 
             Self::Gpt5_5 => "gpt-5.5",
             Self::Gpt5_5Pro => "gpt-5.5-pro",
@@ -302,6 +309,8 @@ impl Model {
             Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5",
             Self::ClaudeSonnet4 => "Claude Sonnet 4",
             Self::ClaudeHaiku4_5 => "Claude Haiku 4.5",
+            Self::ClaudeSonnet5 => "Claude Sonnet 5",
+            Self::ClaudeFable5 => "Claude Fable 5",
 
             Self::Gpt5_5 => "GPT 5.5",
             Self::Gpt5_5Pro => "GPT 5.5 Pro",
@@ -356,7 +365,7 @@ impl Model {
         match self {
             // Models offered by OpenCode have the same configuration across subscriptions
             //  with one outlier: non-free MiniMax models
-            Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => {
+            Self::MiniMaxM3 | Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => {
                 if subscription == OpenCodeSubscription::Zen {
                     ApiProtocol::OpenAiChat
                 } else {
@@ -364,11 +373,13 @@ impl Model {
                 }
             }
 
-            Self::ClaudeOpus4_8
+            Self::ClaudeFable5
+            | 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
@@ -394,9 +405,9 @@ impl Model {
 
             Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google,
 
-            Self::Qwen3_7Max | Self::Qwen3_7Plus => ApiProtocol::Anthropic,
-
-            Self::MiniMaxM3 => ApiProtocol::Anthropic,
+            Self::Qwen3_7Max | Self::Qwen3_7Plus | Self::Qwen3_6Plus | Self::Qwen3_5Plus => {
+                ApiProtocol::Anthropic
+            }
 
             Self::Glm5
             | Self::Glm5_1
@@ -407,8 +418,6 @@ impl Model {
             | Self::KimiK2_7Code
             | Self::MimoV2_5Pro
             | Self::MimoV2_5
-            | Self::Qwen3_5Plus
-            | Self::Qwen3_6Plus
             | Self::DeepSeekV4Pro
             | Self::DeepSeekV4Flash
             | Self::BigPickle
@@ -432,6 +441,7 @@ impl Model {
             | Self::Glm5_2
             | Self::MiniMaxM2_5
             | Self::MiniMaxM2_7
+            | Self::MiniMaxM3
             | Self::Nemotron3UltraFree
             | Self::BigPickle => true,
 
@@ -453,6 +463,8 @@ impl Model {
             Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000,
             Self::ClaudeOpus4_1 => 200_000,
             Self::ClaudeSonnet4 => 1_000_000,
+            Self::ClaudeSonnet5 => 1_000_000,
+            Self::ClaudeFable5 => 1_000_000,
 
             // OpenAI models
             Self::Gpt5_5 | Self::Gpt5_5Pro => 1_050_000,
@@ -473,7 +485,13 @@ impl Model {
 
             // OpenAI-compatible models
             Self::MiniMaxM2_7 => 204_800,
-            Self::MiniMaxM3 => 512_000,
+            Self::MiniMaxM3 => {
+                if subscription == OpenCodeSubscription::Go {
+                    1_000_000
+                } else {
+                    512_000
+                }
+            }
             Self::MiniMaxM2_5 => 204_800,
             Self::Glm5 | Self::Glm5_1 => {
                 if subscription == OpenCodeSubscription::Go {
@@ -514,6 +532,8 @@ impl Model {
             | Self::ClaudeHaiku4_5
             | Self::ClaudeSonnet4 => Some(64_000),
             Self::ClaudeOpus4_1 => Some(32_000),
+            Self::ClaudeSonnet5 => Some(128_000),
+            Self::ClaudeFable5 => Some(128_000),
 
             // OpenAI models
             Self::Gpt5_5
@@ -539,7 +559,13 @@ impl Model {
 
             // OpenAI-compatible models
             Self::MiniMaxM2_7 => Some(131_072),
-            Self::MiniMaxM3 => Some(131_072),
+            Self::MiniMaxM3 => {
+                if subscription == OpenCodeSubscription::Go {
+                    Some(131_072)
+                } else {
+                    Some(128_000)
+                }
+            }
             Self::MiniMaxM2_5 => {
                 if subscription == OpenCodeSubscription::Go {
                     Some(65_536)
@@ -587,7 +613,9 @@ impl Model {
             | Self::ClaudeSonnet4_6
             | Self::ClaudeSonnet4_5
             | Self::ClaudeSonnet4
-            | Self::ClaudeHaiku4_5 => true,
+            | Self::ClaudeHaiku4_5
+            | Self::ClaudeSonnet5
+            | Self::ClaudeFable5 => true,
 
             // OpenAI models support images
             Self::Gpt5_5
@@ -649,20 +677,11 @@ impl Model {
 
     pub fn supported_reasoning_effort_levels(&self) -> Option> {
         match self {
-            Self::ClaudeOpus4_8 => Some(vec![
-                ReasoningEffort::Low,
-                ReasoningEffort::Medium,
-                ReasoningEffort::High,
-                ReasoningEffort::XHigh,
-            ]),
-
-            Self::Nemotron3UltraFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![
-                ReasoningEffort::Low,
-                ReasoningEffort::Medium,
-                ReasoningEffort::High,
-            ]),
-
-            Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![
+            // Anthropic models
+            Self::ClaudeFable5
+            | Self::ClaudeOpus4_8
+            | Self::ClaudeOpus4_7
+            | Self::ClaudeSonnet5 => Some(vec![
                 ReasoningEffort::Low,
                 ReasoningEffort::Medium,
                 ReasoningEffort::High,
@@ -670,6 +689,107 @@ impl Model {
                 ReasoningEffort::Max,
             ]),
 
+            Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::Max,
+            ]),
+
+            Self::ClaudeOpus4_5 => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            // OpenAI models
+            Self::Gpt5_5
+            | Self::Gpt5_4
+            | Self::Gpt5_4Mini
+            | Self::Gpt5_4Nano
+            | Self::Gpt5_3Codex
+            | Self::Gpt5_2 => Some(vec![
+                ReasoningEffort::None,
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+            ]),
+
+            Self::Gpt5_5Pro | Self::Gpt5_4Pro => Some(vec![
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+            ]),
+
+            Self::Gpt5_2Codex | Self::Gpt5_3Spark | Self::Gpt5_1CodexMax => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+                ReasoningEffort::XHigh,
+            ]),
+
+            Self::Gpt5_1 => Some(vec![
+                ReasoningEffort::None,
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            Self::Gpt5Codex | Self::Gpt5_1Codex | Self::Gpt5_1CodexMini => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            Self::Gpt5 | Self::Gpt5Nano => Some(vec![
+                ReasoningEffort::Minimal,
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            // Google models
+            Self::Gemini3Flash | Self::Gemini3_5Flash => Some(vec![
+                ReasoningEffort::Minimal,
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            Self::Gemini3_1Pro => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            // DeepSeek models
+            Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![
+                // OpenCode also supports Low&Medium but as per DeepSeek those are mapped to High
+                ReasoningEffort::High,
+                ReasoningEffort::Max,
+            ]),
+
+            // MiniMax models
+            Self::MiniMaxM3 => Some(vec![ReasoningEffort::None]),
+
+            // NVIDIA models
+            Self::Nemotron3UltraFree => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            // Xiaomi MiMo models
+            Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![
+                ReasoningEffort::Low,
+                ReasoningEffort::Medium,
+                ReasoningEffort::High,
+            ]),
+
+            // Z AI models
+            Self::Glm5_2 => Some(vec![ReasoningEffort::High, ReasoningEffort::Max]),
+
             Self::Custom {
                 reasoning_effort_levels,
                 ..
diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs
index a8786aaa40c..c880891502b 100644
--- a/crates/outline_panel/src/outline_panel.rs
+++ b/crates/outline_panel/src/outline_panel.rs
@@ -3447,10 +3447,8 @@ impl OutlinePanel {
                     };
                     let fetched_outlines = outline_task.await;
                     let outlines_with_children = fetched_outlines
-                        .windows(2)
-                        .filter_map(|window| {
-                            let current = &window[0];
-                            let next = &window[1];
+                        .array_windows::<2>()
+                        .filter_map(|[current, next]| {
                             if next.depth > current.depth {
                                 Some((current.range.clone(), current.depth))
                             } else {
@@ -4752,7 +4750,7 @@ impl OutlinePanel {
                                 }
                             })
                             .with_render_fn(cx.entity(), move |outline_panel, params, _, _| {
-                                const LEFT_OFFSET: Pixels = px(14.);
+                                const LEFT_OFFSET: Pixels = ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET;
 
                                 let indent_size = params.indent_size;
                                 let item_height = params.item_height;
diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs
index fc90c5f2530..a91ec6b5b4e 100644
--- a/crates/picker/src/footer.rs
+++ b/crates/picker/src/footer.rs
@@ -123,7 +123,7 @@ impl Picker {
 
     fn render_preview_controls(
         &self,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context,
     ) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx);
@@ -132,6 +132,12 @@ impl Picker {
         let current = self.preview_layout().unwrap_or(preview::Layout::Hidden);
         let preview_visible = current != preview::Layout::Hidden;
 
+        let diff_split = if self.is_auto_vertical(window) {
+            IconName::DiffSplitAuto
+        } else {
+            IconName::DiffSplit
+        };
+
         h_flex()
             .child(
                 Button::new("picker-preview-toggle", "Preview")
@@ -145,23 +151,7 @@ impl Picker {
                     ),
             )
             .when(preview_visible, |this| {
-                this.child(Divider::vertical().h_4().mx_1())
-                    .child(
-                        IconButton::new("picker-preview-right", IconName::DiffSplit)
-                            .icon_size(IconSize::Small)
-                            .toggle_state(current == preview::Layout::Right)
-                            .tooltip(move |_window, cx| {
-                                Tooltip::for_action_in(
-                                    "Preview to the Right",
-                                    &SetPreviewRight,
-                                    &right_focus_handle,
-                                    cx,
-                                )
-                            })
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.set_preview_layout(preview::Layout::Right, window, cx)
-                            })),
-                    )
+                this.child(Divider::vertical().mx_1())
                     .child(
                         IconButton::new("picker-preview-below", IconName::DiffUnified)
                             .icon_size(IconSize::Small)
@@ -178,6 +168,22 @@ impl Picker {
                                 this.set_preview_layout(preview::Layout::Below, window, cx)
                             })),
                     )
+                    .child(
+                        IconButton::new("picker-preview-right", diff_split)
+                            .icon_size(IconSize::Small)
+                            .toggle_state(current == preview::Layout::Right)
+                            .tooltip(move |_window, cx| {
+                                Tooltip::for_action_in(
+                                    "Preview to the Right",
+                                    &SetPreviewRight,
+                                    &right_focus_handle,
+                                    cx,
+                                )
+                            })
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.set_preview_layout(preview::Layout::Right, window, cx)
+                            })),
+                    )
             })
     }
 
diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs
index 2f9d0ad3c4f..c74e78f1e84 100644
--- a/crates/picker/src/picker.rs
+++ b/crates/picker/src/picker.rs
@@ -547,6 +547,9 @@ impl Picker {
             actions_menu_handle: PopoverMenuHandle::default(),
             reopenable: true,
         };
+        // give delegate the initial preview layout
+        this.delegate
+            .preview_layout_changed(matches!(initial_layout, preview::Layout::Right));
         if this.reopenable {
             let focus_handle = this.focus_handle(cx);
             workspace::register_reopenable_picker(&focus_handle, cx);
@@ -1277,10 +1280,34 @@ impl Picker {
     fn preview_layout(&self) -> Option {
         self.preview.as_ref().map(|p| p.layout)
     }
+    fn is_auto_vertical(&self, window: &Window) -> bool {
+        self.preview_layout() == Some(preview::Layout::Right)
+            && self
+                .size_bounds
+                .would_clamp_width_if_horizontal(&self.shape, window)
+    }
+    /// To check whether we're rendering vertically instead of
+    /// horizontally due to the auto override
+    fn preview_layout_rendered(&self, window: &Window) -> Option {
+        let would_clamp = matches!(
+            self.preview,
+            Some(Preview {
+                layout: preview::Layout::Right,
+                ..
+            })
+        ) && self.is_auto_vertical(window);
+        if would_clamp {
+            Some(preview::Layout::Below)
+        } else {
+            self.preview_layout()
+        }
+    }
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn results_width(&self, window: &Window) -> gpui::Pixels {
-        let layout = self.preview_layout().unwrap_or(preview::Layout::Hidden);
+        let layout = self
+            .preview_layout_rendered(window)
+            .unwrap_or(preview::Layout::Hidden);
         let pos = self
             .shape
             .results_position_and_size(layout, &self.size_bounds, window);
diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs
index 88a9b6219db..df3e9604f70 100644
--- a/crates/picker/src/preview.rs
+++ b/crates/picker/src/preview.rs
@@ -4,6 +4,7 @@ use std::sync::Arc;
 
 use gpui::{AnyElement, App, Entity, IntoElement, Window};
 use language::{Anchor, Buffer, HighlightedText};
+use project::Symbol;
 
 /// The editor-agnostic interface a [`Picker`](crate::Picker) uses to drive its
 /// preview.
@@ -67,6 +68,13 @@ pub enum PreviewSource {
     /// Used by pickers (like the text picker) that already hold the matched
     /// buffer.
     Buffer(Entity),
+    /// The buffer is identified by a project symbol; the preview opens it and
+    /// highlights the symbol's range.
+    ///
+    /// Used by pickers (like the project symbols picker) that only know the
+    /// matched symbol. The highlight is derived from the symbol once its buffer
+    /// loads, so callers don't supply a [`MatchLocation`].
+    Symbol(Symbol),
     /// No buffer to show; display this message centered in the preview instead.
     ///
     /// Used by pickers that have a selection without a previewable buffer (like
@@ -117,4 +125,15 @@ impl Update {
             match_location: Some(highlight),
         }
     }
+
+    /// Preview the buffer for `symbol`, highlighting and scrolling to its range.
+    ///
+    /// The buffer is opened and the highlight derived by the preview once the
+    /// buffer loads.
+    pub fn from_symbol(symbol: Symbol) -> Self {
+        Self {
+            source: PreviewSource::Symbol(symbol),
+            match_location: None,
+        }
+    }
 }
diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs
index 4ff7c425622..e60b46469c8 100644
--- a/crates/picker/src/render.rs
+++ b/crates/picker/src/render.rs
@@ -22,7 +22,9 @@ pub mod window_controls;
 impl Render for Picker {
     fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
         self.finish_any_completed_resize(window, cx);
-
+        // toggle between BelowForced and Right based on whether it'd clamp if
+        // horizontal
+        let rendered_layout = self.preview_layout_rendered(window);
         let content = match &self.preview {
             Some(
                 preview @ Preview {
@@ -32,6 +34,15 @@ impl Render for Picker {
             ) => self
                 .render_with_preview_below(preview, window, cx)
                 .into_any_element(),
+            // render sideways based on bounds
+            Some(
+                preview @ Preview {
+                    layout: Layout::Right,
+                    ..
+                },
+            ) if rendered_layout == Some(Layout::Below) => self
+                .render_with_preview_below(preview, window, cx)
+                .into_any_element(),
             Some(
                 preview @ Preview {
                     layout: Layout::Right,
@@ -58,7 +69,9 @@ impl Render for Picker {
             .when(has_preview, |this| this.overflow_hidden())
             .child(content);
 
-        let layout = self.preview_layout().unwrap_or(Layout::Hidden);
+        let layout = self
+            .preview_layout_rendered(window)
+            .unwrap_or(Layout::Hidden);
 
         div()
             .relative()
@@ -101,7 +114,7 @@ impl Picker {
             .relative()
             .map(|this| {
                 self.shape.apply_results_size(
-                    self.preview_layout(),
+                    self.preview_layout_rendered(window),
                     &self.size_bounds,
                     self.fill_height(),
                     this,
@@ -309,7 +322,11 @@ impl Picker {
                     ),
             )
             .when(self.is_resizable(), |this| {
-                this.child(self.render_resize(window_controls::Middle(preview.layout), window, cx))
+                this.child(self.render_resize(
+                    window_controls::Middle(preview::Layout::Below),
+                    window,
+                    cx,
+                ))
             })
     }
 
@@ -365,7 +382,8 @@ impl Picker {
         if let Shape::Resizing(pos) = self.shape
             && !cx.has_active_drag()
         {
-            let centered = Shape::centered_and_relative(pos, self.preview_layout(), window);
+            let centered =
+                Shape::centered_and_relative(pos, self.preview_layout_rendered(window), window);
             persistence::store_shape_for_this_layout(
                 D::name(),
                 self.preview_layout(),
diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs
index a604a600e1f..cc7fbf2dc7c 100644
--- a/crates/picker/src/render/window_controls.rs
+++ b/crates/picker/src/render/window_controls.rs
@@ -438,7 +438,7 @@ impl Picker {
                 side.position(
                     this,
                     self.shape.clamped_position_and_size(
-                        self.preview_layout(),
+                        self.preview_layout_rendered(window),
                         &self.size_bounds,
                         window,
                     ),
@@ -451,7 +451,7 @@ impl Picker {
                 ResizeDrag::::start_new(
                     self.shape,
                     &self.size_bounds,
-                    self.preview_layout(),
+                    self.preview_layout_rendered(window),
                     window,
                 ),
                 |_, _, _, cx| cx.new(|_| DragPreview),
@@ -464,7 +464,7 @@ impl Picker {
                     side.clamp(
                         &mut working,
                         &this.size_bounds,
-                        this.preview_layout(),
+                        this.preview_layout_rendered(window),
                         window,
                     );
                     this.shape = Shape::Resizing(working);
@@ -487,9 +487,11 @@ impl Picker {
             return;
         }
         side.revert_to_default_size(&mut self.shape, &self.default_shape, window);
-        let pos =
-            self.shape
-                .clamped_position_and_size(self.preview_layout(), &self.size_bounds, window);
+        let pos = self.shape.clamped_position_and_size(
+            self.preview_layout_rendered(window),
+            &self.size_bounds,
+            window,
+        );
         self.shape = Shape::Resizing(pos);
         cx.notify();
     }
diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs
index d37e2b0aae5..217ff6a8fc5 100644
--- a/crates/picker/src/shape.rs
+++ b/crates/picker/src/shape.rs
@@ -20,6 +20,12 @@ pub(crate) struct PositionAndShape {
     pub(crate) preview: Pixels,
 }
 
+impl PositionAndShape {
+    pub(crate) fn width(&self) -> Pixels {
+        self.right - self.left
+    }
+}
+
 macro_rules! relative_size {
     ($name:ident, $accessor:ident) => {
         /// Size type that is the sum of a relative size to the viewport and a
@@ -379,6 +385,16 @@ impl SizeBounds {
         working.preview = working.preview.clamp(min_preview, max_preview);
     }
 
+    pub(crate) fn would_clamp_width_if_horizontal(&self, shape: &Shape, window: &Window) -> bool {
+        let min_width = self.min_width(Some(Layout::Right), window);
+
+        let unbounded_width = shape
+            .picker_position_and_size(Some(Layout::Right), window)
+            .width();
+
+        unbounded_width <= min_width
+    }
+
     /// Clamps a whole picker rect (results + preview) into bounds: the total size
     /// against the per-layout min/max, then the divider so both panes keep their
     /// minimums. Width is clamped about its center, height anchored at the top.
diff --git a/crates/picker_preview/Cargo.toml b/crates/picker_preview/Cargo.toml
index 49417be5435..12d7aec9cc3 100644
--- a/crates/picker_preview/Cargo.toml
+++ b/crates/picker_preview/Cargo.toml
@@ -13,7 +13,7 @@ path = "src/picker_preview.rs"
 doctest = false
 
 [dependencies]
-anyhow.workspace = true
+
 editor.workspace = true
 gpui.workspace = true
 language.workspace = true
diff --git a/crates/picker_preview/src/picker_preview.rs b/crates/picker_preview/src/picker_preview.rs
index 50aff9d3ce7..eb4e179171c 100644
--- a/crates/picker_preview/src/picker_preview.rs
+++ b/crates/picker_preview/src/picker_preview.rs
@@ -4,16 +4,17 @@ use std::sync::Arc;
 
 use gpui::{
     Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText,
-    Task, TaskExt as _, Window, px,
+    Task, Window, px,
 };
-use language::{Buffer, HighlightedText, HighlightedTextBuilder, ToPoint};
+use language::{Bias, Buffer, HighlightedText, HighlightedTextBuilder, ToPoint};
 use picker::{
     MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate, ToMultiBuffer,
 };
-use project::Project;
+use project::{Project, Symbol};
 use rope::Point;
 use settings::Settings;
 use ui::{ActiveTheme, Color, div, prelude::*, v_flex};
+use util::ResultExt as _;
 use util::rel_path::RelPath;
 
 use editor::{Editor, EditorSettings, RowHighlightOptions, display_map::HighlightKey};
@@ -61,6 +62,8 @@ struct EditorPreview {
     /// When set show a text message instead of a preview
     message: Option,
     preview_editor: Entity,
+    /// Store the load preview task so we have only one at the time
+    pending_update: Task<()>,
 }
 
 impl EditorPreview {
@@ -100,6 +103,7 @@ impl EditorPreview {
             preview_editor,
             current_path: None,
             message: None,
+            pending_update: Task::ready(()),
         };
         this.clear(); // picker starts with no results.
         this
@@ -125,6 +129,9 @@ impl EditorPreview {
                 self.update_from_buffer(buffer, highlight, window, cx);
                 cx.notify();
             }
+            PreviewSource::Symbol(symbol) => {
+                self.update_from_symbol(symbol, window, cx);
+            }
             PreviewSource::Message(message) => {
                 self.message = Some(message);
                 cx.notify();
@@ -152,15 +159,41 @@ impl EditorPreview {
             }
         });
 
-        cx.spawn_in(window, async move |this, cx| {
-            let buffer = open_task.await?;
+        self.pending_update = cx.spawn_in(window, async move |this, cx| {
+            let Some(buffer) = open_task.await.log_err() else {
+                return;
+            };
             this.update_in(cx, |this, window, cx| {
                 this.update_from_buffer(buffer, highlight, window, cx);
                 cx.notify();
-            })?;
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
+            })
+            .ok();
+        });
+    }
+
+    fn update_from_symbol(&mut self, symbol: Symbol, window: &mut Window, cx: &mut Context) {
+        let open_task = self.project.update(cx, |project, cx| {
+            project.open_buffer_for_symbol(&symbol, cx)
+        });
+
+        self.pending_update = cx.spawn_in(window, async move |this, cx| {
+            let Some(buffer) = open_task.await.log_err() else {
+                return;
+            };
+            this.update_in(cx, |this, window, cx| {
+                let snapshot = buffer.read(cx).text_snapshot();
+                let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Left);
+                let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left);
+                let highlight = MatchLocation {
+                    anchor_range: snapshot.anchor_before(start)..snapshot.anchor_after(end),
+                    range: snapshot.point_utf16_to_offset(start)
+                        ..snapshot.point_utf16_to_offset(end),
+                };
+                this.update_from_buffer(buffer, Some(highlight), window, cx);
+                cx.notify();
+            })
+            .ok();
+        });
     }
 
     fn update_from_buffer(
diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs
index f9076753998..816cf97c865 100644
--- a/crates/project/src/buffer_store.rs
+++ b/crates/project/src/buffer_store.rs
@@ -647,6 +647,12 @@ impl LocalBufferStore {
             let path = path.clone();
             let buffer = match load_file.await {
                 Ok(loaded) => {
+                    let is_writable = loaded.is_writable;
+                    let capability = if is_writable {
+                        Capability::ReadWrite
+                    } else {
+                        Capability::Read
+                    };
                     let reservation = cx.reserve_entity::();
                     let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64());
                     let text_buffer = cx
@@ -655,8 +661,7 @@ impl LocalBufferStore {
                         })
                         .await;
                     cx.insert_entity(reservation, |_| {
-                        let mut buffer =
-                            Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
+                        let mut buffer = Buffer::build(text_buffer, Some(loaded.file), capability);
                         buffer.set_encoding(loaded.encoding);
                         buffer.set_has_bom(loaded.has_bom);
                         buffer
diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs
index ef5077d53a2..3fb29cdb1c9 100644
--- a/crates/project/src/context_server_store.rs
+++ b/crates/project/src/context_server_store.rs
@@ -8,7 +8,7 @@ use std::time::Duration;
 use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
 use context_server::oauth::{self, McpOAuthTokenProvider, OAuthDiscovery, OAuthSession};
-use context_server::transport::{HttpTransport, TransportError};
+use context_server::transport::HttpTransport;
 use context_server::{ContextServer, ContextServerCommand, ContextServerId};
 use credentials_provider::CredentialsProvider;
 use futures::future::Either;
@@ -22,7 +22,7 @@ use rand::Rng as _;
 use registry::ContextServerDescriptorRegistry;
 use remote::{Interactive, RemoteClient};
 use rpc::{AnyProtoClient, TypedEnvelope, proto};
-use settings::{Settings as _, SettingsStore};
+use settings::{Settings as _, SettingsLocation, SettingsStore, WorktreeId};
 use util::{ResultExt as _, rel_path::RelPath};
 
 use crate::{
@@ -93,6 +93,9 @@ enum ContextServerState {
     Running {
         server: Arc,
         configuration: Arc,
+        /// Initiates the OAuth flow if the transport shuts down on an
+        /// authentication challenge; cancelled by any state transition.
+        _transport_watch: Task<()>,
     },
     Stopped {
         server: Arc,
@@ -277,9 +280,15 @@ enum ContextServerStoreState {
     },
 }
 
+#[derive(Clone, PartialEq)]
+struct ContextServerSettingsEntry {
+    worktree_id: Option,
+    settings: ContextServerSettings,
+}
+
 pub struct ContextServerStore {
     state: ContextServerStoreState,
-    context_server_settings: HashMap, ContextServerSettings>,
+    context_server_settings: HashMap, ContextServerSettingsEntry>,
     servers: HashMap,
     server_ids: Vec,
     worktree_store: Entity,
@@ -362,7 +371,7 @@ impl ContextServerStore {
     pub fn configured_server_ids(&self) -> Vec {
         self.context_server_settings
             .iter()
-            .filter(|(_, settings)| settings.enabled())
+            .filter(|(_, entry)| entry.settings.enabled())
             .map(|(id, _)| ContextServerId(id.clone()))
             .collect()
     }
@@ -448,12 +457,11 @@ impl ContextServerStore {
             let ai_was_disabled = this.ai_disabled;
             this.ai_disabled = ai_disabled;
 
-            let settings =
-                &Self::resolve_project_settings(&this.worktree_store, cx).context_servers;
-            let settings_changed = &this.context_server_settings != settings;
+            let settings = Self::resolve_all_context_server_settings(&this.worktree_store, cx);
+            let settings_changed = this.context_server_settings != settings;
 
             if settings_changed {
-                this.context_server_settings = settings.clone();
+                this.context_server_settings = settings;
             }
 
             // When AI is disabled, stop all running servers
@@ -493,9 +501,7 @@ impl ContextServerStore {
         let mut this = Self {
             state,
             _subscriptions: subscriptions,
-            context_server_settings: Self::resolve_project_settings(&worktree_store, cx)
-                .context_servers
-                .clone(),
+            context_server_settings: Self::resolve_all_context_server_settings(&worktree_store, cx),
             worktree_store,
             project: weak_project,
             registry,
@@ -539,7 +545,9 @@ impl ContextServerStore {
     /// or project settings. This is available regardless of whether the server is
     /// currently running, unlike [`Self::configuration_for_server`].
     pub fn settings_for_server(&self, id: &ContextServerId) -> Option<&ContextServerSettings> {
-        self.context_server_settings.get(&id.0)
+        self.context_server_settings
+            .get(&id.0)
+            .map(|entry| &entry.settings)
     }
 
     /// Returns whether a server is provided by an extension (as opposed to a
@@ -549,7 +557,7 @@ impl ContextServerStore {
     /// configuration, so it stays correct even when a custom server is disabled
     /// or has not been started yet (in which case it has no runtime state).
     pub fn is_extension_provided(&self, id: &ContextServerId, cx: &App) -> bool {
-        match self.context_server_settings.get(&id.0) {
+        match self.settings_for_server(id) {
             Some(ContextServerSettings::Stdio { .. } | ContextServerSettings::Http { .. }) => false,
             Some(ContextServerSettings::Extension { .. }) => true,
             // No custom settings entry: the server can only originate from an
@@ -621,13 +629,13 @@ impl ContextServerStore {
         cx.spawn(async move |this, cx| {
             let this = this.upgrade().context("Context server store dropped")?;
             let id = server.id();
-            let settings = this
+            let settings_entry = this
                 .update(cx, |this, _| {
                     this.context_server_settings.get(&id.0).cloned()
                 })
                 .context("Failed to get context server settings")?;
 
-            if !settings.enabled() {
+            if !settings_entry.settings.enabled() {
                 return anyhow::Ok(());
             }
 
@@ -635,7 +643,7 @@ impl ContextServerStore {
                 (this.registry.clone(), this.worktree_store.clone())
             });
             let configuration = ContextServerConfiguration::from_settings(
-                settings,
+                settings_entry.settings,
                 id.clone(),
                 registry,
                 worktree_store,
@@ -708,9 +716,12 @@ impl ContextServerStore {
                 let new_state = match server.clone().start(cx).await {
                     Ok(_) => {
                         debug_assert!(server.client().is_some());
+                        let _transport_watch =
+                            Self::watch_transport_shutdown(this.clone(), server.clone(), cx);
                         ContextServerState::Running {
                             server,
                             configuration,
+                            _transport_watch,
                         }
                     }
                     Err(err) => resolve_start_failure(&id, err, server, configuration, cx).await,
@@ -733,6 +744,97 @@ impl ContextServerStore {
         );
     }
 
+    /// Watches a running server's transport and initiates the OAuth flow if it
+    /// shuts down on an authentication challenge.
+    ///
+    /// MCP servers may accept `initialize` unauthenticated and only send a 401
+    /// with a `WWW-Authenticate` challenge on a later request or notification.
+    /// The HTTP transport records the challenge, and the failed send tears
+    /// down the client's output loop. Observing that shutdown — rather than
+    /// relying on some request to carry a typed error back to a caller — is
+    /// what lets any post-initialize 401 move the server into `AuthRequired`
+    /// instead of leaving it `Running` with a dead client.
+    fn watch_transport_shutdown(
+        this: WeakEntity,
+        server: Arc,
+        cx: &mut AsyncApp,
+    ) -> Task<()> {
+        let Some(shutdown) = server
+            .client()
+            .and_then(|client| client.wait_for_shutdown())
+        else {
+            return Task::ready(());
+        };
+        cx.spawn(async move |cx| {
+            let Some(www_authenticate) = shutdown.await else {
+                // Non-auth transport deaths leave the server state untouched,
+                // as they did before this watch existed.
+                return;
+            };
+            this.update(cx, |this, cx| {
+                this.handle_auth_challenge(server, www_authenticate, cx);
+            })
+            .log_err();
+        })
+    }
+
+    fn handle_auth_challenge(
+        &mut self,
+        server: Arc,
+        www_authenticate: oauth::WwwAuthenticate,
+        cx: &mut Context,
+    ) {
+        let id = server.id();
+
+        // Act only if this exact server is still the one we consider running.
+        // If the state has changed since the challenge was recorded, whoever
+        // changed it owns the lifecycle now.
+        let Some(ContextServerState::Running {
+            server: running_server,
+            configuration,
+            ..
+        }) = self.servers.get(&id)
+        else {
+            return;
+        };
+        if !Arc::ptr_eq(running_server, &server) {
+            return;
+        }
+        let configuration = configuration.clone();
+
+        log::info!("{id} received 401 after initialization; initiating OAuth authorization");
+
+        // The 401 already tore down the client's output loop. Stop the dead
+        // client, then resolve auth using the captured `WWW-Authenticate` — do
+        // not restart via `run_server`, as a fresh `initialize` would succeed
+        // and lose the challenge.
+        server.stop().log_err();
+
+        let task = cx.spawn({
+            let id = id.clone();
+            let server = server.clone();
+            let configuration = configuration.clone();
+            async move |this, cx| {
+                let new_state =
+                    resolve_auth_required(&id, &www_authenticate, server, configuration, cx).await;
+                this.update(cx, |this, cx| {
+                    this.update_server_state(id.clone(), new_state, cx)
+                })
+                .log_err();
+            }
+        });
+
+        self.update_server_state(
+            id,
+            ContextServerState::Starting {
+                configuration,
+                _task: task,
+                server,
+            },
+            cx,
+        );
+    }
+
     fn remove_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> {
         let state = self
             .servers
@@ -884,8 +986,7 @@ impl ContextServerStore {
             };
 
         let server: Arc = this.update(cx, |this, cx| {
-            let global_timeout =
-                Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout;
+            let global_timeout = this.timeout_for_server(&id, cx);
 
             match configuration.as_ref() {
                 ContextServerConfiguration::Http {
@@ -942,31 +1043,37 @@ impl ContextServerStore {
     ) -> Result {
         let server_id = ContextServerId(envelope.payload.server_id.into());
 
-        let (settings, registry, worktree_store) = this.update(&mut cx, |this, inner_cx| {
-            let ContextServerStoreState::Local {
-                is_headless: true, ..
-            } = &this.state
-            else {
-                anyhow::bail!("unexpected GetContextServerCommand request in a non-local project");
-            };
+        let (settings_entry, registry, worktree_store) =
+            this.update(&mut cx, |this, inner_cx| {
+                let ContextServerStoreState::Local {
+                    is_headless: true, ..
+                } = &this.state
+                else {
+                    anyhow::bail!(
+                        "unexpected GetContextServerCommand request in a non-local project"
+                    );
+                };
 
-            let settings = this
-                .context_server_settings
-                .get(&server_id.0)
-                .cloned()
-                .or_else(|| {
-                    this.registry
-                        .read(inner_cx)
-                        .context_server_descriptor(&server_id.0)
-                        .map(|_| ContextServerSettings::default_extension())
-                })
-                .with_context(|| format!("context server `{}` not found", server_id))?;
+                let settings = this
+                    .context_server_settings
+                    .get(&server_id.0)
+                    .cloned()
+                    .or_else(|| {
+                        this.registry
+                            .read(inner_cx)
+                            .context_server_descriptor(&server_id.0)
+                            .map(|_| ContextServerSettingsEntry {
+                                worktree_id: None,
+                                settings: ContextServerSettings::default_extension(),
+                            })
+                    })
+                    .with_context(|| format!("context server `{}` not found", server_id))?;
 
-            anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone()))
-        })?;
+                anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone()))
+            })?;
 
         let configuration = ContextServerConfiguration::from_settings(
-            settings,
+            settings_entry.settings,
             server_id.clone(),
             registry,
             worktree_store,
@@ -990,19 +1097,29 @@ impl ContextServerStore {
         })
     }
 
-    fn resolve_project_settings<'a>(
-        worktree_store: &'a Entity,
-        cx: &'a App,
-    ) -> &'a ProjectSettings {
-        let location = worktree_store
-            .read(cx)
-            .visible_worktrees(cx)
-            .next()
-            .map(|worktree| settings::SettingsLocation {
-                worktree_id: worktree.read(cx).id(),
+    /// Merges context server settings from all visible worktrees so that servers defined
+    /// in any project folder in a multi-root workspace are picked up.
+    fn resolve_all_context_server_settings(
+        worktree_store: &Entity,
+        cx: &App,
+    ) -> HashMap, ContextServerSettingsEntry> {
+        let mut merged = HashMap::default();
+        for worktree in worktree_store.read(cx).visible_worktrees(cx) {
+            let worktree_id = worktree.read(cx).id();
+            let location = settings::SettingsLocation {
+                worktree_id,
                 path: RelPath::empty(),
-            });
-        ProjectSettings::get(location, cx)
+            };
+            for (id, settings) in &ProjectSettings::get(Some(location), cx).context_servers {
+                merged
+                    .entry(id.clone())
+                    .or_insert_with(|| ContextServerSettingsEntry {
+                        worktree_id: Some(worktree_id),
+                        settings: settings.clone(),
+                    });
+            }
+        }
+        merged
     }
 
     fn create_oauth_token_provider(
@@ -1037,6 +1154,23 @@ impl ContextServerStore {
         ))
     }
 
+    fn timeout_for_server(&self, id: &ContextServerId, cx: &App) -> u64 {
+        let worktree_id = self
+            .context_server_settings
+            .get(&id.0)
+            .as_ref()
+            .and_then(|entry| entry.worktree_id);
+
+        ProjectSettings::get(
+            worktree_id.map(|id| SettingsLocation {
+                worktree_id: id,
+                path: &RelPath::empty(),
+            }),
+            cx,
+        )
+        .context_server_timeout
+    }
+
     /// Initiate the OAuth browser flow for a server in the `AuthRequired` state.
     ///
     /// This starts a loopback HTTP callback server on an ephemeral port, builds
@@ -1049,6 +1183,7 @@ impl ContextServerStore {
         cx: &mut Context,
     ) -> Result<()> {
         let state = self.servers.get(id).context("Context server not found")?;
+        let global_timeout = self.timeout_for_server(id, cx);
 
         let (discovery, server, configuration) = match state {
             ContextServerState::AuthRequired {
@@ -1107,6 +1242,7 @@ impl ContextServerStore {
                     id.clone(),
                     discovery.clone(),
                     configuration.clone(),
+                    global_timeout,
                     cx,
                 )
                 .await;
@@ -1150,6 +1286,7 @@ impl ContextServerStore {
         cx: &mut Context,
     ) -> Result<()> {
         let state = self.servers.get(id).context("Context server not found")?;
+        let global_timeout = self.timeout_for_server(id, cx);
 
         let (server, configuration, discovery) = match state {
             ContextServerState::ClientSecretRequired {
@@ -1193,6 +1330,7 @@ impl ContextServerStore {
                     id.clone(),
                     discovery.clone(),
                     configuration.clone(),
+                    global_timeout,
                     cx,
                 )
                 .await;
@@ -1262,6 +1400,7 @@ impl ContextServerStore {
         id: ContextServerId,
         discovery: Arc,
         configuration: Arc,
+        global_timeout: u64,
         cx: &mut AsyncApp,
     ) -> Result<()> {
         let resource = oauth::canonical_server_uri(&discovery.resource_metadata.resource);
@@ -1362,34 +1501,29 @@ impl ContextServerStore {
             cx,
         );
 
-        let new_server = this.update(cx, |this, cx| {
-            let global_timeout =
-                Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout;
-
-            match configuration.as_ref() {
-                ContextServerConfiguration::Http {
-                    url,
-                    headers,
-                    timeout,
-                    oauth: _,
-                } => {
-                    let transport = HttpTransport::new_with_token_provider(
-                        http_client.clone(),
-                        url.to_string(),
-                        headers.clone(),
-                        cx.background_executor().clone(),
-                        Some(token_provider.clone()),
-                    );
-                    Ok(Arc::new(ContextServer::new_with_timeout(
-                        id.clone(),
-                        Arc::new(transport),
-                        Some(Duration::from_secs(
-                            timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS),
-                        )),
-                    )))
-                }
-                _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"),
+        let new_server = this.update(cx, |_this, cx| match configuration.as_ref() {
+            ContextServerConfiguration::Http {
+                url,
+                headers,
+                timeout,
+                oauth: _,
+            } => {
+                let transport = HttpTransport::new_with_token_provider(
+                    http_client.clone(),
+                    url.to_string(),
+                    headers.clone(),
+                    cx.background_executor().clone(),
+                    Some(token_provider.clone()),
+                );
+                Ok(Arc::new(ContextServer::new_with_timeout(
+                    id.clone(),
+                    Arc::new(transport),
+                    Some(Duration::from_secs(
+                        timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS),
+                    )),
+                )))
             }
+            _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"),
         })??;
 
         this.update(cx, |this, cx| {
@@ -1584,29 +1718,33 @@ impl ContextServerStore {
         for (id, _) in registry.read_with(cx, |registry, _| registry.context_server_descriptors()) {
             configured_servers
                 .entry(id)
-                .or_insert(ContextServerSettings::default_extension());
+                .or_insert(ContextServerSettingsEntry {
+                    worktree_id: None,
+                    settings: ContextServerSettings::default_extension(),
+                });
         }
 
         let (enabled_servers, disabled_servers): (HashMap<_, _>, HashMap<_, _>) =
             configured_servers
                 .into_iter()
-                .partition(|(_, settings)| settings.enabled());
+                .partition(|(_, entry)| entry.settings.enabled());
 
-        let configured_servers = join_all(enabled_servers.into_iter().map(|(id, settings)| {
-            let id = ContextServerId(id);
-            ContextServerConfiguration::from_settings(
-                settings,
-                id.clone(),
-                registry.clone(),
-                worktree_store.clone(),
-                cx,
-            )
-            .map(move |config| (id, config))
-        }))
-        .await
-        .into_iter()
-        .filter_map(|(id, config)| config.map(|config| (id, config)))
-        .collect::>();
+        let configured_servers =
+            join_all(enabled_servers.into_iter().map(|(id, settings_entry)| {
+                let id = ContextServerId(id);
+                ContextServerConfiguration::from_settings(
+                    settings_entry.settings,
+                    id.clone(),
+                    registry.clone(),
+                    worktree_store.clone(),
+                    cx,
+                )
+                .map(move |config| (id, config))
+            }))
+            .await
+            .into_iter()
+            .filter_map(|(id, config)| config.map(|config| (id, config)))
+            .collect::>();
 
         let mut servers_to_start = Vec::new();
         let mut servers_to_remove = HashSet::default();
@@ -1686,43 +1824,34 @@ async fn resolve_start_failure(
     configuration: Arc,
     cx: &AsyncApp,
 ) -> ContextServerState {
-    let www_authenticate = err.downcast_ref::().map(|e| match e {
-        TransportError::AuthRequired { www_authenticate } => www_authenticate.clone(),
-    });
-
-    if www_authenticate.is_some() && configuration.has_static_auth_header() {
-        log::warn!("{id} received 401 with a static Authorization header configured");
-        return ContextServerState::Error {
-            configuration,
-            server,
-            error: "Server returned 401 Unauthorized. Check your configured Authorization header."
-                .into(),
-        };
-    }
-
-    let server_url = match configuration.as_ref() {
-        ContextServerConfiguration::Http { url, .. } if !configuration.has_static_auth_header() => {
-            url.clone()
-        }
-        _ => {
-            if www_authenticate.is_some() {
-                log::error!("{id} got OAuth 401 on a non-HTTP transport or with static auth");
-            } else {
-                log::error!("{id} context server failed to start: {err}");
-            }
-            return ContextServerState::Error {
-                configuration,
-                server,
-                error: err.to_string().into(),
-            };
-        }
-    };
+    // Read the challenge from the transport rather than downcasting `err`: it
+    // is recorded before the failed send's error propagates, so a 401 is
+    // recognized even when another error (e.g. the request timeout) wins the
+    // race to become the reported startup failure.
+    let www_authenticate = server.auth_challenge();
 
     // When the error is NOT a 401 but there is a cached OAuth session in the
     // keychain, the session is likely stale/expired and caused the failure
     // (e.g. timeout because the server rejected the token silently). Clear it
     // so the next start attempt can get a clean 401 and trigger the auth flow.
+    // If there is no such session this is an ordinary startup error.
     if www_authenticate.is_none() {
+        let server_url = match configuration.as_ref() {
+            ContextServerConfiguration::Http { url, .. }
+                if !configuration.has_static_auth_header() =>
+            {
+                url.clone()
+            }
+            _ => {
+                log::error!("{id} context server failed to start: {err}");
+                return ContextServerState::Error {
+                    configuration,
+                    server,
+                    error: err.to_string().into(),
+                };
+            }
+        };
+
         let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
         match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await {
             Ok(Some(_)) => {
@@ -1751,6 +1880,46 @@ async fn resolve_start_failure(
     let www_authenticate = www_authenticate
         .as_ref()
         .unwrap_or(&default_www_authenticate);
+
+    resolve_auth_required(id, www_authenticate, server, configuration, cx).await
+}
+
+/// Runs OAuth discovery for a server that returned a 401 and produces the
+/// appropriate state (`AuthRequired`, `ClientSecretRequired`, or `Error`).
+///
+/// Shared by the startup path ([`resolve_start_failure`]) and the
+/// post-initialize path ([`ContextServerStore::handle_auth_challenge`]) so
+/// that a 401 at any point — not only during `initialize` — can initiate the
+/// OAuth flow.
+async fn resolve_auth_required(
+    id: &ContextServerId,
+    www_authenticate: &oauth::WwwAuthenticate,
+    server: Arc,
+    configuration: Arc,
+    cx: &AsyncApp,
+) -> ContextServerState {
+    if configuration.has_static_auth_header() {
+        log::warn!("{id} received 401 with a static Authorization header configured");
+        return ContextServerState::Error {
+            configuration,
+            server,
+            error: "Server returned 401 Unauthorized. Check your configured Authorization header."
+                .into(),
+        };
+    }
+
+    let server_url = match configuration.as_ref() {
+        ContextServerConfiguration::Http { url, .. } => url.clone(),
+        _ => {
+            log::error!("{id} got OAuth 401 on a non-HTTP transport");
+            return ContextServerState::Error {
+                configuration,
+                server,
+                error: "Server returned 401 Unauthorized on a non-HTTP transport".into(),
+            };
+        }
+    };
+
     let http_client = cx.update(|cx| cx.http_client());
 
     match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await {
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index 1edd3780307..550433c7ec8 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -1,5 +1,5 @@
-pub mod branch_diff;
 mod conflict_set;
+pub mod diff_buffer_list;
 pub mod git_traversal;
 pub mod job_debug_queue;
 pub mod pending_op;
@@ -15,7 +15,7 @@ use crate::{
 };
 use anyhow::{Context as _, Result, anyhow, bail};
 use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs};
-use buffer_diff::{BufferDiff, BufferDiffEvent};
+use buffer_diff::{BufferDiff, DiffHunk, DiffHunkSecondaryStatus, PendingHunk, PendingSense};
 use client::ProjectId;
 use collections::HashMap;
 pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate};
@@ -51,7 +51,7 @@ use gpui::{
     Subscription, Task, TaskExt, WeakEntity,
 };
 use language::{
-    Buffer, BufferEvent, Capability, Language, LanguageRegistry,
+    Anchor, Buffer, BufferEvent, Capability, Language, LanguageRegistry,
     proto::{deserialize_version, serialize_version},
 };
 use parking_lot::Mutex;
@@ -82,7 +82,7 @@ use std::{
 };
 use sum_tree::{Edit, SumTree, TreeMap};
 use task::Shell;
-use text::{Bias, BufferId};
+use text::{Bias, BufferId, OffsetRangeExt, Rope, ToOffset};
 use util::{
     ResultExt, debug_panic,
     paths::{PathStyle, SanitizedPath},
@@ -106,6 +106,7 @@ pub struct GitStore {
     loading_diffs:
         HashMap<(BufferId, DiffKind), Shared, Arc>>>>,
     diffs: HashMap>,
+    buffer_ids_by_index_text_buffer_id: HashMap,
     shared_diffs: HashMap>,
     _subscriptions: Vec,
 }
@@ -142,6 +143,16 @@ struct BufferGitState {
 
     head_text: Option>,
     index_text: Option>,
+    /// The optimistic, in-flight index state: the sole input to the index write,
+    /// expressed relative to the currently-loaded index text (`I0`). Never shown
+    /// to any view (views use per-diff pending hunks). Cleared when a
+    /// recalculation settles.
+    ///
+    /// `I0` is immutable for the lifetime of any pending edit (the index text
+    /// buffer is only fast-forwarded when a recalculation settles, which clears
+    /// this state in the same update), so byte offsets stay valid for the whole
+    /// window.
+    pending_index_edits: Option, Arc)>>,
     oid_texts: HashMap>,
     head_text_buffer: WeakEntity,
     index_text_buffer: WeakEntity,
@@ -151,6 +162,24 @@ struct BufferGitState {
     language_changed: bool,
 }
 
+fn pending_hunks(
+    hunks: &[DiffHunk],
+    version: &clock::Global,
+    sense: PendingSense,
+) -> Vec {
+    hunks
+        .iter()
+        .map(|hunk| {
+            PendingHunk::new(
+                hunk.buffer_range.clone(),
+                hunk.diff_base_byte_range.clone(),
+                version.clone(),
+                sense,
+            )
+        })
+        .collect()
+}
+
 #[derive(Clone, Debug)]
 enum DiffBasesChange {
     SetIndex(Option),
@@ -170,14 +199,80 @@ enum DiffKind {
     SinceOid(Option),
 }
 
-#[derive(Debug, Default, Clone, Copy)]
+struct IndexTextFile {
+    path: Arc,
+    full_path: PathBuf,
+    path_style: PathStyle,
+    file_name: String,
+    worktree_id: WorktreeId,
+    is_private: bool,
+}
+
+impl IndexTextFile {
+    fn new(file: &dyn language::File, cx: &App) -> Self {
+        Self {
+            path: file.path().clone(),
+            full_path: file.full_path(cx),
+            path_style: file.path_style(cx),
+            file_name: file.file_name(cx).to_string(),
+            worktree_id: file.worktree_id(cx),
+            is_private: file.is_private(),
+        }
+    }
+}
+
+impl language::File for IndexTextFile {
+    fn as_local(&self) -> Option<&dyn language::LocalFile> {
+        None
+    }
+
+    fn disk_state(&self) -> language::DiskState {
+        language::DiskState::Historic { was_deleted: false }
+    }
+
+    fn path(&self) -> &Arc {
+        &self.path
+    }
+
+    fn full_path(&self, _: &App) -> PathBuf {
+        self.full_path.clone()
+    }
+
+    fn path_style(&self, _: &App) -> PathStyle {
+        self.path_style
+    }
+
+    fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
+        &self.file_name
+    }
+
+    fn worktree_id(&self, _: &App) -> WorktreeId {
+        self.worktree_id
+    }
+
+    fn to_proto(&self, _: &App) -> rpc::proto::File {
+        rpc::proto::File {
+            worktree_id: self.worktree_id.to_proto(),
+            entry_id: None,
+            path: self.path.as_ref().to_proto(),
+            mtime: None,
+            is_deleted: false,
+            is_historic: true,
+        }
+    }
+
+    fn is_private(&self) -> bool {
+        self.is_private
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
 pub enum GitAccess {
     /// Either:
     /// - the user owns `.git`
     /// - the user doesn't own `.git`, but has both of:
     ///   - OS-level read permissions
     ///   - the directory is marked as safe (git config safe.directory)
-    #[default]
     Yes,
 
     /// The user is not the owner of `.git`, and one of the following is true:
@@ -486,6 +581,7 @@ pub enum RepositoryEvent {
     GitWorktreeListChanged,
     PendingOpsChanged { pending_ops: SumTree },
     GraphEvent((LogSource, LogOrder), GitGraphEvent),
+    GitDirectoryChanged,
 }
 
 #[derive(Clone, Debug)]
@@ -648,6 +744,7 @@ impl GitStore {
             loading_diffs: HashMap::default(),
             shared_diffs: HashMap::default(),
             diffs: HashMap::default(),
+            buffer_ids_by_index_text_buffer_id: HashMap::default(),
         }
     }
 
@@ -897,6 +994,16 @@ impl GitStore {
                 .as_ref()
                 .and_then(|weak| weak.upgrade())
         {
+            // If this unstaged diff was first opened as the uncommitted diff's
+            // secondary, its index text wasn't highlighted. Enable it now and
+            // recalc so the language gets applied to the deleted (base) side.
+            diff_state.update(cx, |diff_state, cx| {
+                if !diff_state.index_text_buffer_language_enabled {
+                    diff_state.index_text_buffer_language_enabled = true;
+                    let buffer_snapshot = buffer.read(cx).text_snapshot();
+                    diff_state.recalculate_diffs(buffer_snapshot, cx);
+                }
+            });
             if let Some(task) =
                 diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation())
             {
@@ -944,15 +1051,17 @@ impl GitStore {
         cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) })
     }
 
+    /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with
+    /// the index text buffer that is the diff's main buffer.
     pub fn open_staged_diff(
         &mut self,
         buffer: Entity,
         cx: &mut Context,
-    ) -> Task>> {
+    ) -> Task, Entity)>> {
         let buffer_id = buffer.read(cx).remote_id();
 
         if let Some(diff_state) = self.diffs.get(&buffer_id)
-            && let Some(staged_diff) = diff_state.read(cx).staged_diff()
+            && let Some(staged_diff) = diff_state.read(cx).staged_diff_and_index_text_buffer()
         {
             if let Some(task) =
                 diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation())
@@ -988,7 +1097,270 @@ impl GitStore {
             })
             .clone();
 
-        cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) })
+        cx.spawn(async move |this, cx| {
+            let diff = task.await.map_err(|e| anyhow!("{e}"))?;
+            this.update(cx, |this, cx| {
+                let index_text_buffer = this
+                    .diffs
+                    .get(&buffer_id)
+                    .and_then(|diff_state| {
+                        let (_, index_text_buffer) = diff_state.read(cx).staged_diff.as_ref()?;
+                        Some(index_text_buffer.clone())
+                    })
+                    .context("index text buffer missing after opening staged diff")?;
+                Ok((diff, index_text_buffer))
+            })?
+        })
+    }
+
+    /// Stages the worktree changes covered by `worktree_ranges`, acting on the
+    /// given unstaged (index-vs-worktree) diff. Used by both the unstaged-changes
+    /// view and the uncommitted (gutter) controls: "stage" means the same index
+    /// change regardless of which view it was invoked from, so callers holding an
+    /// uncommitted diff pass its unstaged secondary.
+    ///
+    /// Decomposes the worktree region into the unstaged hunks it covers, so no
+    /// worktree->index projection is needed. Optimistically suppresses the staged
+    /// hunks from the unstaged diff and, if the uncommitted diff happens to be
+    /// open, marks the corresponding uncommitted hunks as staging.
+    pub fn stage_hunks(
+        &mut self,
+        buffer: Entity,
+        unstaged_diff: Entity,
+        worktree_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if worktree_ranges.is_empty() {
+            return Ok(());
+        }
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let buffer_id = buffer_snapshot.remote_id();
+        let file_exists = buffer_snapshot
+            .file()
+            .is_some_and(|file| file.disk_state().exists());
+
+        let unstaged_snapshot = unstaged_diff.read(cx).snapshot(cx);
+
+        // Decompose: the unstaged hunks the worktree region covers carry the
+        // index range directly. Sorting by buffer offset also sorts by index
+        // offset, since the hunks are non-overlapping. We read the raw hunks
+        // (ignoring optimistic suppression) so that re-staging a hunk that was
+        // optimistically staged and then unstaged still finds it. The footprints
+        // let the patch drop earlier edits the region covers even where the raw
+        // diff is empty (re-staging a hunk that is staged on disk but
+        // optimistically unstaged).
+        let mut unstaged_hunks = Vec::new();
+        let mut index_footprints = Vec::new();
+        for range in &worktree_ranges {
+            unstaged_hunks.extend(
+                unstaged_snapshot.raw_hunks_intersecting_range(range.clone(), &buffer_snapshot),
+            );
+            index_footprints.push(
+                unstaged_snapshot.base_text_range_for_buffer_range(range.clone(), &buffer_snapshot),
+            );
+        }
+        unstaged_hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot));
+        unstaged_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start);
+
+        // The uncommitted hunks the region covers get the optimistic
+        // "staging" secondary status (free cross-view update).
+        let uncommitted_diff = self.get_uncommitted_diff(buffer_id, cx);
+        let mut uncommitted_hunks = Vec::new();
+        if let Some(uncommitted_diff) = &uncommitted_diff {
+            let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx);
+            for range in &worktree_ranges {
+                uncommitted_hunks.extend(
+                    uncommitted_snapshot
+                        .hunks_intersecting_range(range.clone(), &buffer_snapshot)
+                        .filter(|hunk| {
+                            hunk.secondary_status != DiffHunkSecondaryStatus::NoSecondaryHunk
+                        }),
+                );
+            }
+            uncommitted_hunks
+                .sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot));
+            uncommitted_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start);
+        }
+
+        let index_edits = if !file_exists {
+            // The worktree file is gone: staging removes it from the index.
+            None
+        } else {
+            Some(
+                unstaged_hunks
+                    .iter()
+                    .map(|hunk| {
+                        let worktree_range = hunk.buffer_range.to_offset(&buffer_snapshot);
+                        let replacement: Arc = Arc::from(
+                            buffer_snapshot
+                                .text_for_range(worktree_range)
+                                .collect::(),
+                        );
+                        (hunk.diff_base_byte_range.clone(), replacement)
+                    })
+                    .collect::>(),
+            )
+        };
+
+        let version = buffer_snapshot.version().clone();
+        let unstaged_pending = pending_hunks(&unstaged_hunks, &version, PendingSense::Suppress);
+        let uncommitted_pending = pending_hunks(
+            &uncommitted_hunks,
+            &version,
+            PendingSense::SetSecondaryStatus { stage: true },
+        );
+        drop(unstaged_snapshot);
+
+        let diff_state = self
+            .diffs
+            .get(&buffer_id)
+            .cloned()
+            .context("failed to find git state for buffer")?;
+        diff_state.update(cx, |diff_state, _| {
+            diff_state.remove_overlapping_pending_index_edits(&index_footprints);
+            diff_state.insert_pending_index_edits(index_edits);
+        });
+
+        if let Some(uncommitted_diff) = uncommitted_diff {
+            uncommitted_diff.update(cx, |diff, cx| {
+                diff.set_pending_hunks(&uncommitted_pending, &buffer_snapshot, cx);
+            });
+        }
+        unstaged_diff.update(cx, |diff, cx| {
+            diff.set_pending_hunks(&unstaged_pending, &buffer_snapshot, cx);
+        });
+
+        self.write_optimistic_index(buffer_id, cx);
+        Ok(())
+    }
+
+    /// Unstages the worktree changes covered by `worktree_ranges`, acting on the
+    /// given uncommitted (HEAD-vs-worktree) diff, invoked from the uncommitted
+    /// (gutter) controls. Uses the worktree->index projection (the hard part)
+    /// because the acted-on hunks are HEAD-vs-worktree.
+    pub fn unstage_uncommitted_hunks(
+        &mut self,
+        buffer: Entity,
+        uncommitted_diff: Entity,
+        worktree_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if worktree_ranges.is_empty() {
+            return Ok(());
+        }
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let buffer_id = buffer_snapshot.remote_id();
+        let file_exists = buffer_snapshot
+            .file()
+            .is_some_and(|file| file.disk_state().exists());
+
+        let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx);
+        let unstaged_snapshot = uncommitted_snapshot
+            .secondary_diff()
+            .context("diff has no unstaged secondary")?;
+
+        let mut hunks = Vec::new();
+        for range in &worktree_ranges {
+            hunks.extend(
+                uncommitted_snapshot.hunks_intersecting_range(range.clone(), &buffer_snapshot),
+            );
+        }
+        hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot));
+        hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start);
+
+        let (index_edits, pending) = uncommitted_snapshot.compute_uncommitted_index_edits(
+            unstaged_snapshot,
+            false,
+            &hunks,
+            &buffer_snapshot,
+            file_exists,
+        );
+        drop(uncommitted_snapshot);
+
+        let diff_state = self
+            .diffs
+            .get(&buffer_id)
+            .cloned()
+            .context("failed to find git state for buffer")?;
+        diff_state.update(cx, |diff_state, _| {
+            diff_state.insert_pending_index_edits(index_edits)
+        });
+
+        uncommitted_diff.update(cx, |diff, cx| {
+            diff.set_pending_hunks(&pending, &buffer_snapshot, cx);
+        });
+
+        self.write_optimistic_index(buffer_id, cx);
+        Ok(())
+    }
+
+    /// Unstages staged (HEAD-vs-index) hunks covered by `index_ranges` (in the
+    /// index text buffer's coordinates), acting on the given staged diff,
+    /// invoked from the staged-changes view. The acted-on hunks already carry
+    /// an index range, so no projection is needed; optimistically suppresses
+    /// them from the staged diff.
+    pub fn unstage_staged_hunks(
+        &mut self,
+        staged_diff: Entity,
+        index_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if index_ranges.is_empty() {
+            return Ok(());
+        }
+        let index_buffer_id = staged_diff.read(cx).buffer_id;
+        let buffer_id = *self
+            .buffer_ids_by_index_text_buffer_id
+            .get(&index_buffer_id)
+            .context("failed to find git state for index text buffer")?;
+        let diff_state = self
+            .diffs
+            .get(&buffer_id)
+            .cloned()
+            .context("failed to find git state for buffer")?;
+        let index_buffer = diff_state
+            .read(cx)
+            .index_text_buffer()
+            .context("index text is not loaded")?;
+        let index_snapshot = index_buffer.read(cx).text_snapshot();
+        let staged_snapshot = staged_diff.read(cx).snapshot(cx);
+
+        let mut hunks = Vec::new();
+        for range in &index_ranges {
+            hunks.extend(staged_snapshot.hunks_intersecting_range(range.clone(), &index_snapshot));
+        }
+        hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&index_snapshot));
+        hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start);
+
+        let index_edits = staged_diff
+            .read(cx)
+            .unstage_staged_hunks(&hunks, &index_snapshot);
+        let version = index_snapshot.version().clone();
+        let pending = pending_hunks(&hunks, &version, PendingSense::Suppress);
+        drop(staged_snapshot);
+
+        diff_state.update(cx, |diff_state, _| {
+            diff_state.insert_pending_index_edits(index_edits)
+        });
+        staged_diff.update(cx, |diff, cx| {
+            diff.set_pending_hunks(&pending, &index_snapshot, cx);
+        });
+
+        self.write_optimistic_index(buffer_id, cx);
+        Ok(())
+    }
+
+    /// Derives the desired index text from the buffer's optimistic patch and
+    /// schedules the write.
+    fn write_optimistic_index(&mut self, buffer_id: BufferId, cx: &mut Context) {
+        let Some(diff_state) = self.diffs.get(&buffer_id) else {
+            return;
+        };
+        let new_index_text = diff_state
+            .read(cx)
+            .pending_index_text(cx)
+            .map(|rope| rope.to_string());
+        self.write_index_text_for_buffer_id(buffer_id, new_index_text, cx);
     }
 
     pub fn open_diff_since(
@@ -1058,9 +1430,6 @@ impl GitStore {
                     });
 
                     this.update(cx, |this, cx| {
-                        cx.subscribe(&buffer_diff, Self::on_buffer_diff_event)
-                            .detach();
-
                         this.loading_diffs.remove(&(buffer_id, diff_kind));
 
                         let git_store = cx.weak_entity();
@@ -1174,6 +1543,9 @@ impl GitStore {
             let buffer_id = buffer.remote_id();
             let language = buffer.language().cloned();
             let language_registry = buffer.language_registry();
+            let index_text_file = buffer.file().map(|file| {
+                Arc::new(IndexTextFile::new(file.as_ref(), cx)) as Arc
+            });
             let text_snapshot = buffer.text_snapshot();
             this.loading_diffs.remove(&(buffer_id, kind));
 
@@ -1194,7 +1566,7 @@ impl GitStore {
                 let diff = match kind {
                     DiffKind::Unstaged => {
                         let base_text_buffer = diff_state.update(cx, |diff_state, cx| {
-                            diff_state.get_or_create_index_text_buffer(cx)
+                            diff_state.get_or_create_index_text_buffer(index_text_file.clone(), cx)
                         });
                         cx.new(|cx| {
                             BufferDiff::new_with_base_text_buffer(
@@ -1208,7 +1580,10 @@ impl GitStore {
                         let (index_text_buffer, base_text_buffer) =
                             diff_state.update(cx, |diff_state, cx| {
                                 (
-                                    diff_state.get_or_create_index_text_buffer(cx),
+                                    diff_state.get_or_create_index_text_buffer(
+                                        index_text_file.clone(),
+                                        cx,
+                                    ),
                                     diff_state.get_or_create_head_text_buffer(cx),
                                 )
                             });
@@ -1244,16 +1619,19 @@ impl GitStore {
                         unreachable!("open_diff_internal is not used for OID diffs")
                     }
                 };
-                cx.subscribe(&diff, Self::on_buffer_diff_event).detach();
                 diff
             };
-            diff_state.update(cx, |diff_state, cx| {
+            let rx = diff_state.update(cx, |diff_state, cx| {
                 diff_state.language = language;
                 diff_state.language_registry = language_registry;
 
                 match kind {
                     DiffKind::Unstaged => {
                         diff_state.unstaged_diff = Some(diff.downgrade());
+                        // The deleted (base) side of a standalone unstaged diff
+                        // is the index, so highlight it. The recalc kicked off by
+                        // `diff_bases_changed` below applies the language.
+                        diff_state.index_text_buffer_language_enabled = true;
                     }
                     DiffKind::Staged => {
                         diff_state.index_text_buffer_language_enabled = true;
@@ -1266,7 +1644,8 @@ impl GitStore {
                         let unstaged_diff = if let Some(diff) = existing_unstaged_diff {
                             diff
                         } else {
-                            let base_text_buffer = diff_state.get_or_create_index_text_buffer(cx);
+                            let base_text_buffer =
+                                diff_state.get_or_create_index_text_buffer(index_text_file, cx);
                             let unstaged_diff = cx.new(|cx| {
                                 BufferDiff::new_with_base_text_buffer(
                                     &text_snapshot,
@@ -1295,7 +1674,15 @@ impl GitStore {
                     }
                     Ok(diff)
                 })
-            })
+            });
+            let diff_state = this.diffs.get(&buffer_id).cloned();
+            if let Some(index_text_buffer) =
+                diff_state.and_then(|diff_state| diff_state.read(cx).index_text_buffer())
+            {
+                this.buffer_ids_by_index_text_buffer_id
+                    .insert(index_text_buffer.read(cx).remote_id(), buffer_id);
+            }
+            rx
         })??
         .await
     }
@@ -1817,13 +2204,21 @@ impl GitStore {
         cx: &mut Context,
     ) {
         let mut removed_ids = Vec::new();
+
+        let is_trusted = TrustedWorktrees::try_get_global(cx)
+            .map(|trusted_worktrees| {
+                trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                    trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx)
+                })
+            })
+            .unwrap_or(false);
+
         for update in updated_git_repositories.iter() {
             if let Some((id, existing)) = self.repositories.iter().find(|(_, repo)| {
-                let existing_work_directory_abs_path =
-                    repo.read(cx).work_directory_abs_path.clone();
-                Some(&existing_work_directory_abs_path)
+                let existing_work_directory_abs_path = &repo.read(cx).work_directory_abs_path;
+                Some(existing_work_directory_abs_path)
                     == update.old_work_directory_abs_path.as_ref()
-                    || Some(&existing_work_directory_abs_path)
+                    || Some(existing_work_directory_abs_path)
                         == update.new_work_directory_abs_path.as_ref()
             }) {
                 let repo_id = *id;
@@ -1842,17 +2237,6 @@ impl GitStore {
                             update.repository_dir_abs_path.clone()
                         && let Some(common_dir_abs_path) = update.common_dir_abs_path.clone()
                     {
-                        let is_trusted = TrustedWorktrees::try_get_global(cx)
-                            .map(|trusted_worktrees| {
-                                trusted_worktrees.update(cx, |trusted_worktrees, cx| {
-                                    trusted_worktrees.can_trust(
-                                        &self.worktree_store,
-                                        worktree_id,
-                                        cx,
-                                    )
-                                })
-                            })
-                            .unwrap_or(false);
                         existing.update(cx, |existing, cx| {
                             existing.reinitialize_local_backend(
                                 new_work_directory_abs_path,
@@ -1888,16 +2272,7 @@ impl GitStore {
                 ..
             } = update
             {
-                let repository_dir_abs_path = repository_dir_abs_path.clone();
-                let common_dir_abs_path = common_dir_abs_path.clone();
                 let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
-                let is_trusted = TrustedWorktrees::try_get_global(cx)
-                    .map(|trusted_worktrees| {
-                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
-                            trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx)
-                        })
-                    })
-                    .unwrap_or(false);
                 let git_store = cx.weak_entity();
                 let repo = cx.new(|cx| {
                     let mut repo = Repository::local(
@@ -2009,6 +2384,8 @@ impl GitStore {
             }
             BufferStoreEvent::BufferDropped(buffer_id) => {
                 self.diffs.remove(buffer_id);
+                self.buffer_ids_by_index_text_buffer_id
+                    .retain(|_, main_buffer_id| main_buffer_id != buffer_id);
                 for diffs in self.shared_diffs.values_mut() {
                     diffs.remove(buffer_id);
                 }
@@ -2085,48 +2462,45 @@ impl GitStore {
         }
     }
 
-    fn on_buffer_diff_event(
+    fn write_index_text_for_buffer_id(
         &mut self,
-        diff: Entity,
-        event: &BufferDiffEvent,
+        buffer_id: BufferId,
+        new_index_text: Option,
         cx: &mut Context,
     ) {
-        if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event {
-            let buffer_id = diff.read(cx).buffer_id;
-            if let Some(diff_state) = self.diffs.get(&buffer_id) {
-                let new_index_text = new_index_text.as_ref().map(|rope| rope.to_string());
-                if new_index_text.as_deref() == diff_state.read(cx).index_text.as_deref() {
-                    return;
-                }
-                let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| {
-                    diff_state.hunk_staging_operation_count += 1;
-                    diff_state.hunk_staging_operation_count
-                });
-                if let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) {
-                    let recv = repo.update(cx, |repo, cx| {
-                        log::debug!("hunks changed for {}", path.as_unix_str());
-                        repo.spawn_set_index_text_job(
-                            path,
-                            new_index_text,
-                            Some(hunk_staging_operation_count),
-                            cx,
-                        )
-                    });
-                    let diff = diff.downgrade();
-                    cx.spawn(async move |this, cx| {
-                        if let Ok(Err(error)) = cx.background_spawn(recv).await {
-                            diff.update(cx, |diff, cx| {
-                                diff.clear_pending_hunks(cx);
-                            })
-                            .ok();
-                            this.update(cx, |_, cx| cx.emit(GitStoreEvent::IndexWriteError(error)))
-                                .ok();
-                        }
-                    })
-                    .detach();
-                }
+        let Some(diff_state) = self.diffs.get(&buffer_id) else {
+            return;
+        };
+        let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| {
+            diff_state.hunk_staging_operation_count += 1;
+            diff_state.hunk_staging_operation_count
+        });
+        let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) else {
+            return;
+        };
+        let recv = repo.update(cx, |repo, cx| {
+            log::debug!("hunks changed for {}", path.as_unix_str());
+            repo.spawn_set_index_text_job(
+                path,
+                new_index_text,
+                Some(hunk_staging_operation_count),
+                cx,
+            )
+        });
+        cx.spawn(async move |this, cx| {
+            if let Ok(Err(error)) = cx.background_spawn(recv).await {
+                this.update(cx, |this, cx| {
+                    if let Some(diff_state) = this.diffs.get(&buffer_id).cloned() {
+                        diff_state.update(cx, |diff_state, cx| {
+                            diff_state.clear_pending_index_edits_and_hunks(cx);
+                        });
+                    }
+                    cx.emit(GitStoreEvent::IndexWriteError(error));
+                })
+                .ok();
             }
-        }
+        })
+        .detach();
     }
 
     fn local_worktree_git_repos_changed(
@@ -2146,6 +2520,7 @@ impl GitStore {
                         || update.new_work_directory_abs_path.as_ref() == Some(repo_abs_path)
                 }) {
                     repository.reload_buffer_diff_bases(cx);
+                    cx.emit(RepositoryEvent::GitDirectoryChanged);
                 }
             });
         }
@@ -3165,7 +3540,7 @@ impl GitStore {
 
         let branch = repository_handle
             .update(&mut cx, |repository_handle, _| {
-                repository_handle.default_branch(false)
+                repository_handle.default_branch(envelope.payload.include_remote_name)
             })
             .await??
             .map(Into::into);
@@ -3962,6 +4337,7 @@ impl BufferGitState {
             hunk_staging_operation_count_as_of_write: 0,
             head_text: Default::default(),
             index_text: Default::default(),
+            pending_index_edits: Some(Vec::new()),
             oid_texts: Default::default(),
             head_text_buffer: WeakEntity::new_invalid(),
             index_text_buffer: WeakEntity::new_invalid(),
@@ -3989,13 +4365,27 @@ impl BufferGitState {
         buffer
     }
 
-    fn get_or_create_index_text_buffer(&mut self, cx: &mut Context) -> Entity {
+    fn index_text_buffer(&self) -> Option> {
+        self.index_text_buffer.upgrade()
+    }
+
+    fn get_or_create_index_text_buffer(
+        &mut self,
+        file: Option>,
+        cx: &mut Context,
+    ) -> Entity {
         if let Some(buffer) = self.index_text_buffer.upgrade() {
+            if let Some(file) = file {
+                buffer.update(cx, |buffer, cx| buffer.file_updated(file, cx));
+            }
             return buffer;
         }
         let index_text = self.index_text.clone();
         let buffer = cx.new(|cx| {
             let mut buffer = Buffer::local(index_text.as_deref().unwrap_or(""), cx);
+            if let Some(file) = file {
+                buffer.file_updated(file, cx);
+            }
             buffer.set_capability(Capability::ReadOnly, cx);
             buffer
         });
@@ -4066,6 +4456,11 @@ impl BufferGitState {
         self.staged_diff.as_ref().and_then(|(set, _)| set.upgrade())
     }
 
+    fn staged_diff_and_index_text_buffer(&self) -> Option<(Entity, Entity)> {
+        let (diff, index_text_buffer) = self.staged_diff.as_ref()?;
+        Some((diff.upgrade()?, index_text_buffer.clone()))
+    }
+
     fn uncommitted_diff(&self) -> Option> {
         self.uncommitted_diff.as_ref().and_then(|set| set.upgrade())
     }
@@ -4087,6 +4482,100 @@ impl BufferGitState {
         }
     }
 
+    fn remove_overlapping_pending_index_edits(&mut self, ranges: &[Range]) {
+        if let Some(edits) = &mut self.pending_index_edits {
+            edits.retain(|(existing, _)| {
+                ranges.iter().all(|footprint| {
+                    existing.end < footprint.start || footprint.end < existing.start
+                })
+            });
+        }
+    }
+
+    fn insert_pending_index_edits(&mut self, edits: Option, Arc)>>) {
+        match edits {
+            None => {
+                self.pending_index_edits = None;
+            }
+            Some(new_edits) => {
+                let mut edits = self.pending_index_edits.take().unwrap_or_default();
+                for (range, replacement) in new_edits {
+                    edits.retain(|(existing, _)| {
+                        existing.end < range.start || range.end < existing.start
+                    });
+                    let position =
+                        edits.partition_point(|(existing, _)| existing.start < range.start);
+                    edits.insert(position, (range, replacement));
+                }
+                self.pending_index_edits = Some(edits);
+            }
+        }
+    }
+
+    fn pending_index_text(&self, cx: &App) -> Option {
+        let index_text_buffer = self.index_text_buffer.upgrade()?;
+        let edits = self.pending_index_edits.as_ref()?;
+        #[cfg(debug_assertions)]
+        for window in edits.windows(2) {
+            debug_assert!(window[0].0.end <= window[1].0.start);
+        }
+        let mut index_text = index_text_buffer.read(cx).text_snapshot().as_rope().clone();
+        for (old_range, replacement_text) in edits.iter().rev() {
+            index_text.replace(old_range.clone(), replacement_text);
+        }
+        Some(index_text)
+    }
+
+    fn clear_pending_index_edits(&mut self) {
+        self.pending_index_edits = Some(Vec::new());
+    }
+
+    fn clear_pending_hunks(&mut self, cx: &mut Context) {
+        for diff in [
+            self.uncommitted_diff(),
+            self.unstaged_diff(),
+            self.staged_diff(),
+        ]
+        .into_iter()
+        .flatten()
+        {
+            diff.update(cx, |diff, cx| diff.clear_pending_hunks(cx));
+        }
+    }
+
+    fn mark_whole_file_stage_or_unstage_pending(
+        &mut self,
+        stage: bool,
+        buffer_snapshot: &text::BufferSnapshot,
+        cx: &mut Context,
+    ) {
+        if let Some(uncommitted_diff) = self.uncommitted_diff() {
+            uncommitted_diff.update(cx, |uncommitted_diff, cx| {
+                uncommitted_diff.mark_all_hunks_pending(stage, buffer_snapshot, cx);
+            });
+        }
+
+        if stage {
+            if let Some(unstaged_diff) = self.unstaged_diff() {
+                unstaged_diff.update(cx, |unstaged_diff, cx| {
+                    unstaged_diff.suppress_all_hunks_pending(buffer_snapshot, cx);
+                });
+            }
+        } else if let Some(staged_diff) = self.staged_diff()
+            && let Some(index_text_buffer) = self.index_text_buffer()
+        {
+            let index_snapshot = index_text_buffer.read(cx).text_snapshot();
+            staged_diff.update(cx, |staged_diff, cx| {
+                staged_diff.suppress_all_hunks_pending(&index_snapshot, cx);
+            });
+        }
+    }
+
+    fn clear_pending_index_edits_and_hunks(&mut self, cx: &mut Context) {
+        self.clear_pending_index_edits();
+        self.clear_pending_hunks(cx);
+    }
+
     fn handle_base_texts_updated(
         &mut self,
         buffer: text::BufferSnapshot,
@@ -4100,16 +4589,17 @@ impl BufferGitState {
         };
 
         let diff_bases_change = match mode {
-            Mode::HeadOnly => DiffBasesChange::SetHead(message.committed_text),
-            Mode::IndexOnly => DiffBasesChange::SetIndex(message.staged_text),
-            Mode::IndexMatchesHead => DiffBasesChange::SetBoth(message.committed_text),
-            Mode::IndexAndHead => DiffBasesChange::SetEach {
+            Mode::HeadOnly => Some(DiffBasesChange::SetHead(message.committed_text)),
+            Mode::IndexOnly => Some(DiffBasesChange::SetIndex(message.staged_text)),
+            Mode::IndexMatchesHead => Some(DiffBasesChange::SetBoth(message.committed_text)),
+            Mode::IndexAndHead => Some(DiffBasesChange::SetEach {
                 index: message.staged_text,
                 head: message.committed_text,
-            },
+            }),
+            Mode::Unchanged => None,
         };
 
-        self.diff_bases_changed(buffer, Some(diff_bases_change), cx);
+        self.diff_bases_changed(buffer, diff_bases_change, cx);
     }
 
     pub fn wait_for_recalculation(&mut self) -> Option + use<>> {
@@ -4414,7 +4904,9 @@ impl BufferGitState {
                 return Ok(());
             }
 
-            this.update(cx, |_, cx| {
+            this.update(cx, |this, cx| {
+                this.clear_pending_index_edits();
+
                 if let (Some(staged_diff), Some(new_staged_diff)) =
                     (staged_diff.as_ref(), new_staged_diff.clone())
                 {
@@ -4433,7 +4925,7 @@ impl BufferGitState {
                                 head_text_buffer.fast_forward(edited_head_text, cx)
                             });
                         }
-                        diff.set_snapshot(new_staged_diff, cx)
+                        diff.set_snapshot_with_secondary(new_staged_diff, None, true, cx)
                     });
                 }
 
@@ -4448,7 +4940,7 @@ impl BufferGitState {
                                 index_text_buffer.fast_forward(edited_index_text, cx)
                             });
                         }
-                        diff.set_snapshot(new_unstaged_diff, cx)
+                        diff.set_snapshot_with_secondary(new_unstaged_diff, None, true, cx)
                     }))
                 } else {
                     None
@@ -5178,26 +5670,36 @@ impl Repository {
 
                 let buffer_diff_base_changes = cx
                     .background_spawn(async move {
-                        let mut changes = Vec::new();
-                        for (
-                            buffer,
-                            repo_path,
-                            is_symlink,
-                            current_index_text,
-                            current_head_text,
-                        ) in &repo_diff_state_updates
+                        let mut revisions = Vec::new();
+                        for (_, repo_path, is_symlink, current_index_text, current_head_text) in
+                            &repo_diff_state_updates
                         {
-                            let index_text = if current_index_text.is_some() && !*is_symlink {
-                                backend.load_index_text(repo_path.clone())
-                            } else {
-                                future::ready(None).boxed()
-                            };
-                            let head_text = if current_head_text.is_some() && !*is_symlink {
-                                backend.load_committed_text(repo_path.clone())
-                            } else {
-                                future::ready(None).boxed()
-                            };
-                            let (index_text, head_text) = future::join(index_text, head_text).await;
+                            if current_index_text.is_some() && !*is_symlink {
+                                revisions.push(format!(":{}", repo_path.as_unix_str()));
+                            }
+                            if current_head_text.is_some() && !*is_symlink {
+                                revisions.push(format!("HEAD:{}", repo_path.as_unix_str()));
+                            }
+                        }
+
+                        let mut loaded_revisions = backend
+                            .load_revisions(revisions)
+                            .await
+                            .log_err()
+                            .into_iter()
+                            .flatten();
+
+                        let mut changes = Vec::new();
+                        for (buffer, _, is_symlink, current_index_text, current_head_text) in
+                            &repo_diff_state_updates
+                        {
+                            let index_text = (current_index_text.is_some() && !*is_symlink)
+                                .then(|| loaded_revisions.next().flatten())
+                                .flatten();
+
+                            let head_text = (current_head_text.is_some() && !*is_symlink)
+                                .then(|| loaded_revisions.next().flatten())
+                                .flatten();
 
                             let change =
                                 match (current_index_text.as_ref(), current_head_text.as_ref()) {
@@ -5255,21 +5757,23 @@ impl Repository {
                         diff_state.update(cx, |diff_state, cx| {
                             use proto::update_diff_bases::Mode;
 
-                            if let Some((diff_bases_change, (client, project_id))) =
-                                diff_bases_change.clone().zip(downstream_client)
-                            {
-                                let (staged_text, committed_text, mode) = match diff_bases_change {
-                                    DiffBasesChange::SetIndex(index) => {
-                                        (index, None, Mode::IndexOnly)
-                                    }
-                                    DiffBasesChange::SetHead(head) => (None, head, Mode::HeadOnly),
-                                    DiffBasesChange::SetEach { index, head } => {
-                                        (index, head, Mode::IndexAndHead)
-                                    }
-                                    DiffBasesChange::SetBoth(text) => {
-                                        (None, text, Mode::IndexMatchesHead)
-                                    }
-                                };
+                            if let Some((client, project_id)) = downstream_client {
+                                let (staged_text, committed_text, mode) =
+                                    match diff_bases_change.clone() {
+                                        Some(DiffBasesChange::SetIndex(index)) => {
+                                            (index, None, Mode::IndexOnly)
+                                        }
+                                        Some(DiffBasesChange::SetHead(head)) => {
+                                            (None, head, Mode::HeadOnly)
+                                        }
+                                        Some(DiffBasesChange::SetEach { index, head }) => {
+                                            (index, head, Mode::IndexAndHead)
+                                        }
+                                        Some(DiffBasesChange::SetBoth(text)) => {
+                                            (None, text, Mode::IndexMatchesHead)
+                                        }
+                                        None => (None, None, Mode::Unchanged),
+                                    };
                                 client
                                     .send(proto::UpdateDiffBases {
                                         project_id: project_id.to_proto(),
@@ -6465,33 +6969,15 @@ impl Repository {
                                         else {
                                             continue;
                                         };
-                                        let Some(uncommitted_diff) =
-                                            diff_state.read(cx).uncommitted_diff.as_ref().and_then(
-                                                |uncommitted_diff| uncommitted_diff.upgrade(),
-                                            )
-                                        else {
-                                            continue;
-                                        };
                                         let buffer_snapshot = buffer.read(cx).text_snapshot();
-                                        let file_exists = buffer
-                                            .read(cx)
-                                            .file()
-                                            .is_some_and(|file| file.disk_state().exists());
                                         let hunk_staging_operation_count =
                                             diff_state.update(cx, |diff_state, cx| {
-                                                uncommitted_diff.update(
-                                                    cx,
-                                                    |uncommitted_diff, cx| {
-                                                        uncommitted_diff
-                                                            .stage_or_unstage_all_hunks(
-                                                                stage,
-                                                                &buffer_snapshot,
-                                                                file_exists,
-                                                                cx,
-                                                            );
-                                                    },
-                                                );
-
+                                                diff_state
+                                                    .mark_whole_file_stage_or_unstage_pending(
+                                                        stage,
+                                                        &buffer_snapshot,
+                                                        cx,
+                                                    );
                                                 diff_state.hunk_staging_operation_count += 1;
                                                 diff_state.hunk_staging_operation_count
                                             });
@@ -6558,14 +7044,8 @@ impl Repository {
                                         if result.is_ok() {
                                             diff_state.hunk_staging_operation_count_as_of_write =
                                                 hunk_staging_operation_count;
-                                        } else if let Some(uncommitted_diff) =
-                                            &diff_state.uncommitted_diff
-                                        {
-                                            uncommitted_diff
-                                                .update(cx, |uncommitted_diff, cx| {
-                                                    uncommitted_diff.clear_pending_hunks(cx);
-                                                })
-                                                .ok();
+                                        } else {
+                                            diff_state.clear_pending_hunks(cx);
                                         }
                                     })
                                     .ok();
@@ -7869,6 +8349,7 @@ impl Repository {
                         .request(proto::GetDefaultBranch {
                             project_id: project_id.0,
                             repository_id: id.to_proto(),
+                            include_remote_name,
                         })
                         .await?;
 
@@ -8526,8 +9007,13 @@ impl Repository {
         let rx = self.send_job("load_committed_text", None, move |state, _| async move {
             match state {
                 RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
-                    let committed_text = backend.load_committed_text(repo_path.clone()).await;
-                    let staged_text = backend.load_index_text(repo_path).await;
+                    let revisions = vec![
+                        format!("HEAD:{}", repo_path.as_unix_str()),
+                        format!(":{}", repo_path.as_unix_str()),
+                    ];
+                    let mut loaded_revisions = backend.load_revisions(revisions).await?.into_iter();
+                    let committed_text = loaded_revisions.next().flatten();
+                    let staged_text = loaded_revisions.next().flatten();
                     let diff_bases_change = if committed_text == staged_text {
                         DiffBasesChange::SetBoth(committed_text)
                     } else {
diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/diff_buffer_list.rs
similarity index 76%
rename from crates/project/src/git_store/branch_diff.rs
rename to crates/project/src/git_store/diff_buffer_list.rs
index 67aa198945d..2f1586d3e04 100644
--- a/crates/project/src/git_store/branch_diff.rs
+++ b/crates/project/src/git_store/diff_buffer_list.rs
@@ -24,6 +24,8 @@ use crate::{
 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
 pub enum DiffBase {
     Head,
+    Index,
+    Staged,
     Merge { base_ref: SharedString },
 }
 
@@ -33,7 +35,7 @@ impl DiffBase {
     }
 }
 
-pub struct BranchDiff {
+pub struct DiffBufferList {
     diff_base: DiffBase,
     repo: Option>,
     project: Entity,
@@ -52,9 +54,9 @@ pub enum BranchDiffEvent {
     DiffBaseChanged,
 }
 
-impl EventEmitter for BranchDiff {}
+impl EventEmitter for DiffBufferList {}
 
-impl BranchDiff {
+impl DiffBufferList {
     pub fn new(
         source: DiffBase,
         project: Entity,
@@ -350,8 +352,13 @@ impl BranchDiff {
                     .as_ref()
                     .and_then(|t| t.entries.get(&item.repo_path))
                     .cloned();
-                let Some(status) = self.merge_statuses(Some(item.status), branch_diff.as_ref())
-                else {
+                let Some(status) = (match self.diff_base {
+                    DiffBase::Head | DiffBase::Merge { .. } => {
+                        self.merge_statuses(Some(item.status), branch_diff.as_ref())
+                    }
+                    DiffBase::Index => item.status.staging().has_unstaged().then_some(item.status),
+                    DiffBase::Staged => item.status.staging().has_staged().then_some(item.status),
+                }) else {
                     continue;
                 };
                 if !status.has_changes() {
@@ -363,7 +370,13 @@ impl BranchDiff {
                 else {
                     continue;
                 };
-                let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx);
+                let task = Self::load_buffer(
+                    self.diff_base.clone(),
+                    branch_diff,
+                    project_path,
+                    repo.clone(),
+                    cx,
+                );
 
                 output.push(DiffBuffer {
                     repo_path: item.repo_path.clone(),
@@ -383,8 +396,13 @@ impl BranchDiff {
                 let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else {
                     continue;
                 };
-                let task =
-                    Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx);
+                let task = Self::load_buffer(
+                    self.diff_base.clone(),
+                    Some(branch_diff.clone()),
+                    project_path,
+                    repo.clone(),
+                    cx,
+                );
 
                 let file_status = diff_status_to_file_status(branch_diff);
 
@@ -400,44 +418,87 @@ impl BranchDiff {
 
     #[instrument(skip_all)]
     fn load_buffer(
+        diff_base: DiffBase,
         branch_diff: Option,
         project_path: crate::ProjectPath,
         repo: Entity,
         cx: &Context<'_, Project>,
-    ) -> Task, Entity, Entity)>> {
+    ) -> Task> {
         let task = cx.spawn(async move |project, cx| {
             let buffer = project
                 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
                 .await?;
 
-            let changes = if let Some(entry) = branch_diff {
-                let oid = match entry {
-                    git::status::TreeDiffStatus::Added { .. } => None,
-                    git::status::TreeDiffStatus::Modified { old, .. }
-                    | git::status::TreeDiffStatus::Deleted { old } => Some(old),
-                };
-                project
-                    .update(cx, |project, cx| {
-                        project.git_store().update(cx, |git_store, cx| {
-                            git_store.open_diff_since(oid, buffer.clone(), repo, cx)
-                        })
-                    })?
-                    .await?
-            } else {
-                project
-                    .update(cx, |project, cx| {
-                        project.open_uncommitted_diff(buffer.clone(), cx)
-                    })?
-                    .await?
+            let main_buffer = buffer.clone();
+            let load_conflict_set = diff_base != DiffBase::Staged;
+            let (display_buffer, changes) = match diff_base {
+                DiffBase::Head => {
+                    let diff = project
+                        .update(cx, |project, cx| {
+                            project.open_uncommitted_diff(buffer.clone(), cx)
+                        })?
+                        .await?;
+                    (buffer, diff)
+                }
+                DiffBase::Index => {
+                    let diff = project
+                        .update(cx, |project, cx| {
+                            project.open_unstaged_diff(buffer.clone(), cx)
+                        })?
+                        .await?;
+                    (buffer, diff)
+                }
+                DiffBase::Staged => {
+                    let (diff, index_buffer) = project
+                        .update(cx, |project, cx| {
+                            project.open_staged_diff(buffer.clone(), cx)
+                        })?
+                        .await?;
+                    (index_buffer, diff)
+                }
+                DiffBase::Merge { .. } => {
+                    let diff = if let Some(entry) = branch_diff {
+                        let oid = match entry {
+                            git::status::TreeDiffStatus::Added { .. } => None,
+                            git::status::TreeDiffStatus::Modified { old, .. }
+                            | git::status::TreeDiffStatus::Deleted { old } => Some(old),
+                        };
+                        project
+                            .update(cx, |project, cx| {
+                                project.git_store().update(cx, |git_store, cx| {
+                                    git_store.open_diff_since(oid, buffer.clone(), repo, cx)
+                                })
+                            })?
+                            .await?
+                    } else {
+                        project
+                            .update(cx, |project, cx| {
+                                project.open_uncommitted_diff(buffer.clone(), cx)
+                            })?
+                            .await?
+                    };
+                    (buffer, diff)
+                }
             };
-            let conflict_set = project
-                .update(cx, |project, cx| {
-                    project.git_store().update(cx, |git_store, cx| {
-                        git_store.open_conflict_set(buffer.clone(), cx)
-                    })
-                })?
-                .await;
-            Ok((buffer, changes, conflict_set))
+            let conflict_set = if load_conflict_set {
+                Some(
+                    project
+                        .update(cx, |project, cx| {
+                            project.git_store().update(cx, |git_store, cx| {
+                                git_store.open_conflict_set(main_buffer.clone(), cx)
+                            })
+                        })?
+                        .await,
+                )
+            } else {
+                None
+            };
+            Ok(LoadedDiffBuffer {
+                display_buffer,
+                main_buffer,
+                diff: changes,
+                conflict_set,
+            })
         });
         task
     }
@@ -461,9 +522,17 @@ fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> File
     file_status
 }
 
+#[derive(Debug)]
+pub struct LoadedDiffBuffer {
+    pub display_buffer: Entity,
+    pub main_buffer: Entity,
+    pub diff: Entity,
+    pub conflict_set: Option>,
+}
+
 #[derive(Debug)]
 pub struct DiffBuffer {
     pub repo_path: RepoPath,
     pub file_status: FileStatus,
-    pub load: Task, Entity, Entity)>>,
+    pub load: Task>,
 }
diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs
index feb1936c47a..f9e563ccdb3 100644
--- a/crates/project/src/lsp_store.rs
+++ b/crates/project/src/lsp_store.rs
@@ -1635,12 +1635,36 @@ impl LocalLspStore {
             .handle
             .read_with(cx, |buffer, _| buffer.max_point().row > 0);
 
+        // When formatting a selection, only the rows it spans may be touched.
+        let selection_row_ranges = buffer.ranges.as_ref().map(|ranges| {
+            buffer.handle.read_with(cx, |buffer, _cx| {
+                let snapshot = buffer.snapshot();
+                ranges
+                    .iter()
+                    .map(|range| {
+                        let start = range.start.to_point(&snapshot);
+                        let end = range.end.to_point(&snapshot);
+                        // A selection ending at column 0 of a row only includes that row's
+                        // preceding line break, not its content, so it shouldn't be trimmed.
+                        let end_row = if end.column == 0 && end.row > start.row {
+                            end.row
+                        } else {
+                            end.row + 1
+                        };
+                        start.row..end_row
+                    })
+                    .collect::>()
+            })
+        });
+
         // handle whitespace formatting
         if settings.remove_trailing_whitespace_on_save {
             zlog::trace!(logger => "removing trailing whitespace");
             let diff = buffer
                 .handle
-                .read_with(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx))
+                .read_with(cx, |buffer, cx| {
+                    buffer.remove_trailing_whitespace(selection_row_ranges.as_deref(), cx)
+                })
                 .await;
             extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| {
                 buffer.apply_diff(diff, cx);
@@ -1649,8 +1673,11 @@ impl LocalLspStore {
 
         if settings.ensure_final_newline_on_save {
             zlog::trace!(logger => "ensuring final newline");
+            let diff = buffer.handle.read_with(cx, |buffer, _cx| {
+                buffer.ensure_final_newline(selection_row_ranges.as_deref())
+            });
             extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| {
-                buffer.ensure_final_newline(cx);
+                buffer.apply_diff(diff, cx);
             })?;
         }
 
@@ -1706,9 +1733,13 @@ impl LocalLspStore {
 
         let formatters = match (trigger, &settings.format_on_save) {
             (FormatTrigger::Save, FormatOnSave::Off) => &[],
-            (FormatTrigger::Manual, _) | (FormatTrigger::Save, FormatOnSave::On) => {
-                settings.formatter.as_ref()
-            }
+            (FormatTrigger::Manual, _)
+            | (
+                FormatTrigger::Save,
+                FormatOnSave::On
+                | FormatOnSave::Modifications
+                | FormatOnSave::ModificationsIfAvailable,
+            ) => settings.formatter.as_ref(),
         };
 
         let formatters = code_actions_on_format_formatters
@@ -1736,6 +1767,7 @@ impl LocalLspStore {
                 &adapters_and_servers,
                 &settings,
                 request_timeout,
+                trigger,
                 logger,
                 cx,
             )
@@ -1756,6 +1788,7 @@ impl LocalLspStore {
         adapters_and_servers: &[(Arc, Arc)],
         settings: &LanguageSettings,
         request_timeout: Duration,
+        trigger: FormatTrigger,
         logger: zlog::Logger,
         cx: &mut AsyncApp,
     ) -> anyhow::Result<()> {
@@ -1898,23 +1931,22 @@ impl LocalLspStore {
                 };
 
                 let Some(language_server) = language_server else {
-                    log::debug!(
-                        "No language server found to format buffer '{:?}'. Skipping",
-                        buffer_path_abs.as_path().to_string_lossy()
+                    zlog::debug!(
+                        logger =>
+                        "No language server found to format buffer {buffer_path_abs:?}. Skipping",
                     );
                     return Ok(());
                 };
 
                 zlog::trace!(
                     logger =>
-                    "Formatting buffer '{:?}' using language server '{:?}'",
-                    buffer_path_abs.as_path().to_string_lossy(),
+                    "Formatting buffer {buffer_path_abs:?} using language server {:?}",
                     language_server.name()
                 );
 
                 let edits = if let Some(ranges) = buffer.ranges.as_ref() {
                     zlog::trace!(logger => "formatting ranges");
-                    Self::format_ranges_via_lsp(
+                    let range_edits = Self::format_ranges_via_lsp(
                         &lsp_store,
                         &buffer.handle,
                         ranges,
@@ -1924,7 +1956,38 @@ impl LocalLspStore {
                         cx,
                     )
                     .await
-                    .context("Failed to format ranges via language server")?
+                    .context("Failed to format ranges via language server")?;
+
+                    match range_edits {
+                        Some(edits) => edits,
+                        None => {
+                            if trigger == FormatTrigger::Save
+                                && settings.format_on_save == FormatOnSave::ModificationsIfAvailable
+                            {
+                                zlog::debug!(
+                                    logger =>
+                                    "Falling back to full format - LSP does not support range formatting"
+                                );
+                                Self::format_via_lsp(
+                                    &lsp_store,
+                                    &buffer.handle,
+                                    buffer_path_abs,
+                                    &language_server,
+                                    &settings,
+                                    cx,
+                                )
+                                .await
+                                .context("failed to format via language server")?
+                            } else {
+                                zlog::debug!(
+                                    logger =>
+                                    "Skipping range format - language server {:?} does not support range formatting",
+                                    language_server.name()
+                                );
+                                Vec::new()
+                            }
+                        }
+                    }
                 } else {
                     zlog::trace!(logger => "formatting full");
                     Self::format_via_lsp(
@@ -2175,12 +2238,8 @@ impl LocalLspStore {
                             formatting_transaction_id,
                             cx,
                             |buffer, cx| {
-                                zlog::info!(
-                                    "Applying edits {edits:?}. Content: {:?}",
-                                    buffer.text()
-                                );
+                                zlog::trace!("Applying {} edits", edits.len());
                                 buffer.edit(edits, None, cx);
-                                zlog::info!("Applied edits. New Content: {:?}", buffer.text());
                             },
                         )?;
                     }
@@ -2322,14 +2381,18 @@ impl LocalLspStore {
         language_server: &Arc,
         settings: &LanguageSettings,
         cx: &mut AsyncApp,
-    ) -> Result, Arc)>> {
+    ) -> Result, Arc)>>> {
         let capabilities = &language_server.capabilities();
         let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref();
-        if range_formatting_provider == Some(&OneOf::Left(false)) {
-            anyhow::bail!(
-                "{} language server does not support range formatting",
+        if !matches!(
+            range_formatting_provider,
+            Some(OneOf::Left(true) | OneOf::Right(_))
+        ) {
+            log::debug!(
+                "Skipping range formatting: language server {} does not support range formatting",
                 language_server.name()
             );
+            return Ok(None);
         }
 
         let uri = file_path_to_lsp_url(abs_path)?;
@@ -2388,8 +2451,9 @@ impl LocalLspStore {
                 )
             })?
             .await
+            .map(Some)
         } else {
-            Ok(Vec::with_capacity(0))
+            Ok(Some(Vec::with_capacity(0)))
         }
     }
 
@@ -3411,6 +3475,25 @@ impl LocalLspStore {
                         .to_file_path()
                         .map_err(|()| anyhow!("can't convert URI to path"))?;
 
+                    // An LSP "rename symbol" can also rename the file, with the text edit
+                    // applied only to the in-memory buffer. Persist it before renaming, or
+                    // fs.rename moves the stale on-disk content and the files' contents swap.
+                    let dirty_buffer = this.update(cx, |this, cx| {
+                        let project_path = this
+                            .worktree_store()
+                            .read(cx)
+                            .project_path_for_absolute_path(&source_abs_path, cx)?;
+                        let buffer = this.buffer_store().read(cx).get_by_path(&project_path)?;
+                        buffer.read(cx).is_dirty().then_some(buffer)
+                    });
+                    if let Some(buffer) = dirty_buffer {
+                        this.update(cx, |this, cx| {
+                            this.buffer_store()
+                                .update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx))
+                        })
+                        .await?;
+                    }
+
                     let options = fs::RenameOptions {
                         overwrite: op
                             .options
@@ -4196,9 +4279,10 @@ impl SymbolLocation {
 }
 
 fn should_log_lsp_request_failure(message: &str) -> bool {
-    // content modified is a weird failure mode of rust-analyzer
-    // where requests are denied before its loaded a project
-    message.ends_with("content modified") || message.ends_with("server cancelled the request")
+    // "content modified" and "server cancelled the request" are noisy failure
+    // modes of rust-analyzer where requests are denied before it has loaded a
+    // project.
+    !(message.ends_with("content modified") || message.ends_with("server cancelled the request"))
 }
 
 impl LspStore {
@@ -7209,27 +7293,19 @@ impl LspStore {
                         buffer.start_transaction();
 
                         for (range, text) in edits {
-                            let primary = &completion.replace_range;
-
-                            // Special case: if both ranges start at the very beginning of the file (line 0, column 0),
-                            // and the primary completion is just an insertion (empty range), then this is likely
-                            // an auto-import scenario and should not be considered overlapping
-                            // https://github.com/zed-industries/zed/issues/26136
-                            let is_file_start_auto_import = {
-                                let snapshot = buffer.snapshot();
-                                let primary_start_point = primary.start.to_point(&snapshot);
-                                let range_start_point = range.start.to_point(&snapshot);
-
-                                let result = primary_start_point.row == 0
-                                    && primary_start_point.column == 0
-                                    && range_start_point.row == 0
-                                    && range_start_point.column == 0;
-
-                                result
-                            };
-
-                            let has_overlap = if is_file_start_auto_import {
-                                false
+                            // Zero-width additional edits (e.g. auto-imports at file start, or
+                            // rust-analyzer's ref-match `&` insertions) only overlap the primary
+                            // edit when they fall strictly inside it. Touching its boundary is fine.
+                            //
+                            // Ref: https://github.com/zed-industries/zed/issues/26136
+                            // Ref: https://github.com/zed-industries/zed/issues/56973
+                            let is_insertion = range.start.cmp(&range.end, buffer).is_eq();
+                            let has_overlap = if is_insertion {
+                                let insert_offset = range.start.to_offset(buffer);
+                                all_commit_ranges.iter().any(|commit_range| {
+                                    commit_range.start.to_offset(buffer) < insert_offset
+                                        && insert_offset < commit_range.end.to_offset(buffer)
+                                })
                             } else {
                                 all_commit_ranges.iter().any(|commit_range| {
                                     let start_within =
@@ -7242,8 +7318,8 @@ impl LspStore {
                                 })
                             };
 
-                            //Skip additional edits which overlap with the primary completion edit
-                            //https://github.com/zed-industries/zed/pull/1871
+                            // Skip additional edits which overlap with the primary completion edit
+                            // https://github.com/zed-industries/zed/pull/1871
                             if !has_overlap {
                                 buffer.edit([(range, text)], None, cx);
                             }
@@ -14388,7 +14464,12 @@ impl LanguageServerWatchedPaths {
         cx.spawn({
             async move |_, cx| {
                 maybe!(async move {
-                    let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
+                    let mut push_updates = cx
+                        .background_spawn({
+                            let abs_path = abs_path.clone();
+                            async move { fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await }
+                        })
+                        .await;
                     while let Some(update) = push_updates.0.next().await {
                         let action = lsp_store
                             .update(cx, |this, _| {
@@ -15286,3 +15367,28 @@ fn extend_formatting_transaction(
         Ok(())
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn should_log_lsp_request_failure_suppresses_known_noise() {
+        // Suppressed: rust-analyzer's superseded/denied request signals.
+        assert!(!should_log_lsp_request_failure(
+            "Get diagnostics via rust-analyzer failed: content modified"
+        ));
+        assert!(!should_log_lsp_request_failure(
+            "Get diagnostics via rust-analyzer failed: server cancelled the request"
+        ));
+
+        // Logged: anything else is a real failure.
+        assert!(should_log_lsp_request_failure(
+            "Get diagnostics via rust-analyzer failed: server shut down"
+        ));
+        assert!(should_log_lsp_request_failure(
+            "Get diagnostics via rust-analyzer failed: Server reset the connection"
+        ));
+        assert!(should_log_lsp_request_failure("something else entirely"));
+    }
+}
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index 90b4a6a5997..6a08a291e31 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -2148,6 +2148,7 @@ impl Project {
                 visible: true,
                 abs_path: abs_path.to_string(),
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             },
             client,
             PathStyle::Posix,
@@ -3244,12 +3245,14 @@ impl Project {
             .update(cx, |git_store, cx| git_store.open_unstaged_diff(buffer, cx))
     }
 
+    /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with
+    /// the index text buffer that is the diff's main buffer.
     #[ztracing::instrument(skip_all)]
     pub fn open_staged_diff(
         &mut self,
         buffer: Entity,
         cx: &mut Context,
-    ) -> Task>> {
+    ) -> Task, Entity)>> {
         if self.is_disconnected(cx) {
             return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
         }
@@ -3271,6 +3274,59 @@ impl Project {
         })
     }
 
+    /// Stages the worktree changes covered by `worktree_ranges` (in the worktree
+    /// buffer's coordinates), acting on the given unstaged diff. Used by both the
+    /// unstaged-changes view and the uncommitted (gutter) controls.
+    pub fn stage_hunks(
+        &mut self,
+        buffer: Entity,
+        unstaged_diff: Entity,
+        worktree_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if self.is_disconnected(cx) {
+            return Err(anyhow!(ErrorCode::Disconnected));
+        }
+        self.git_store.update(cx, |git_store, cx| {
+            git_store.stage_hunks(buffer, unstaged_diff, worktree_ranges, cx)
+        })
+    }
+
+    /// Unstages the worktree changes covered by `worktree_ranges` (in the worktree
+    /// buffer's coordinates), acting on the given uncommitted diff. Used by the
+    /// uncommitted (gutter) controls.
+    pub fn unstage_uncommitted_hunks(
+        &mut self,
+        buffer: Entity,
+        uncommitted_diff: Entity,
+        worktree_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if self.is_disconnected(cx) {
+            return Err(anyhow!(ErrorCode::Disconnected));
+        }
+        self.git_store.update(cx, |git_store, cx| {
+            git_store.unstage_uncommitted_hunks(buffer, uncommitted_diff, worktree_ranges, cx)
+        })
+    }
+
+    /// Unstages the staged changes covered by `index_ranges` (in the index
+    /// buffer's coordinates), acting on the given staged diff. Used by the
+    /// staged-changes view.
+    pub fn unstage_staged_hunks(
+        &mut self,
+        staged_diff: Entity,
+        index_ranges: Vec>,
+        cx: &mut Context,
+    ) -> Result<()> {
+        if self.is_disconnected(cx) {
+            return Err(anyhow!(ErrorCode::Disconnected));
+        }
+        self.git_store.update(cx, |git_store, cx| {
+            git_store.unstage_staged_hunks(staged_diff, index_ranges, cx)
+        })
+    }
+
     pub fn open_buffer_by_id(
         &mut self,
         id: BufferId,
@@ -5681,7 +5737,7 @@ impl Project {
                 }
             });
 
-            while let Some(buffer) = new_matches.next().await {
+            while let Some((buffer, _)) = new_matches.next().await {
                 let buffer_id = this.update(cx, |this, cx| {
                     this.create_buffer_for_peer(&buffer, peer_id, cx).to_proto()
                 });
diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs
index 600e7aa5932..dd1d4575fe8 100644
--- a/crates/project/src/project_search.rs
+++ b/crates/project/src/project_search.rs
@@ -16,7 +16,7 @@ use fs::Fs;
 use futures::FutureExt as _;
 use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered};
 use gpui::{App, AppContext, AsyncApp, BackgroundExecutor, Entity, Priority, Task};
-use language::{Buffer, BufferSnapshot};
+use language::{Buffer, BufferSnapshot, Point};
 use parking_lot::Mutex;
 use postage::oneshot;
 use rpc::{AnyProtoClient, proto};
@@ -27,7 +27,7 @@ use worktree::{Entry, ProjectEntryId, Snapshot, Worktree, WorktreeSettings};
 use crate::{
     Project, ProjectItem, ProjectPath, RemotelyCreatedModels,
     buffer_store::BufferStore,
-    search::{SearchQuery, SearchResult},
+    search::{LineHint, SearchQuery, SearchResult},
     worktree_store::WorktreeStore,
 };
 
@@ -61,7 +61,7 @@ enum SearchKind {
 #[must_use]
 pub struct SearchResultsHandle {
     results: Receiver,
-    matching_buffers: Receiver>,
+    matching_buffers: Receiver<(Entity, LineHint)>,
     trigger_search: Box Task<()> + Send + Sync>,
 }
 
@@ -76,7 +76,7 @@ impl SearchResultsHandle {
             rx: self.results,
         }
     }
-    pub fn matching_buffers(self, cx: &mut App) -> SearchResults> {
+    pub fn matching_buffers(self, cx: &mut App) -> SearchResults<(Entity, LineHint)> {
         SearchResults {
             task_handle: (self.trigger_search)(cx),
             rx: self.matching_buffers,
@@ -152,7 +152,7 @@ impl Search {
     }
 
     pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000;
-    pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
+    pub const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
     /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers
     /// or full search results.
     pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {
@@ -179,12 +179,15 @@ impl Search {
         let open_buffers = Arc::new(open_buffers);
         let executor = cx.background_executor().clone();
         let (tx, rx) = unbounded();
-        let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = unbounded();
+        let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) =
+            unbounded::<(Entity, LineHint)>();
         let matching_buffers = grab_buffer_snapshot_rx.clone();
         let trigger_search = Box::new(move |cx: &mut App| {
             cx.spawn(async move |cx| {
                 for buffer in unnamed_buffers {
-                    _ = grab_buffer_snapshot_tx.send(buffer).await;
+                    _ = grab_buffer_snapshot_tx
+                        .send((buffer, LineHint::default()))
+                        .await;
                 }
 
                 let (find_all_matches_tx, find_all_matches_rx) =
@@ -196,7 +199,10 @@ impl Search {
                         let fill_requests = cx
                             .background_spawn(async move {
                                 for buffer in open_buffers {
-                                    if let Err(_) = grab_buffer_snapshot_tx.send(buffer).await {
+                                    if let Err(_) = grab_buffer_snapshot_tx
+                                        .send((buffer, LineHint::default()))
+                                        .await
+                                    {
                                         return;
                                     }
                                 }
@@ -304,8 +310,9 @@ impl Search {
 
                                     let forward_buffers = cx.background_spawn(async move {
                                         while let Ok(buffer) = buffer_rx.recv().await {
-                                            let _ =
-                                                grab_buffer_snapshot_tx.send(buffer.await?).await;
+                                            let _ = grab_buffer_snapshot_tx
+                                                .send((buffer.await?, LineHint::default()))
+                                                .await;
                                         }
                                         anyhow::Ok(())
                                     });
@@ -377,7 +384,6 @@ impl Search {
                     )
                 } else {
                     drop(find_all_matches_tx);
-
                     None
                 };
                 let ensure_matches_are_reported_in_order = if should_find_all_matches {
@@ -387,6 +393,7 @@ impl Search {
                     )
                 } else {
                     drop(tx);
+
                     None
                 };
 
@@ -412,7 +419,7 @@ impl Search {
         worktrees: Vec>,
         query: Arc,
         tx: Sender,
-        results: Sender>,
+        results: Sender>,
         results_tx: Sender,
     ) -> impl AsyncFnOnce(&mut AsyncApp) {
         async move |cx| {
@@ -495,18 +502,22 @@ impl Search {
     }
 
     async fn maintain_sorted_search_results(
-        rx: Receiver>,
-        paths_for_full_scan: Sender,
+        rx: Receiver>,
+        paths_for_full_scan: Sender<(ProjectPath, LineHint)>,
         limit: usize,
     ) {
         let mut rx = pin!(rx);
         let mut matched = 0;
         while let Some(mut next_path_result) = rx.next().await {
-            let Some(successful_path) = next_path_result.next().await else {
+            let Some((successful_path, line_hint)) = next_path_result.next().await else {
                 // This file did not produce a match, hence skip it.
                 continue;
             };
-            if paths_for_full_scan.send(successful_path).await.is_err() {
+            if paths_for_full_scan
+                .send((successful_path, line_hint))
+                .await
+                .is_err()
+            {
                 return;
             };
             matched += 1;
@@ -519,23 +530,26 @@ impl Search {
     /// Background workers cannot open buffers by themselves, hence main thread will do it on their behalf.
     async fn open_buffers(
         buffer_store: Entity,
-        rx: Receiver,
-        find_all_matches_tx: Sender>,
+        rx: Receiver<(ProjectPath, LineHint)>,
+        find_all_matches_tx: Sender<(Entity, LineHint)>,
         mut cx: AsyncApp,
     ) {
         let mut rx = pin!(rx.ready_chunks(64));
         _ = maybe!(async move {
             while let Some(requested_paths) = rx.next().await {
+                let line_hints: Vec =
+                    requested_paths.iter().map(|(_, line)| *line).collect();
                 let mut buffers = buffer_store.update(&mut cx, |this, cx| {
                     requested_paths
                         .into_iter()
-                        .map(|path| this.open_buffer(path, cx))
+                        .map(|(path, _)| this.open_buffer(path, cx))
                         .collect::>()
                 });
-
+                let mut line_hints = line_hints.into_iter();
                 while let Some(buffer) = buffers.next().await {
+                    let line_hint = line_hints.next().unwrap_or(LineHint::default());
                     if let Some(buffer) = buffer.log_err() {
-                        find_all_matches_tx.send(buffer).await?;
+                        find_all_matches_tx.send((buffer, line_hint)).await?;
                     }
                 }
             }
@@ -545,20 +559,23 @@ impl Search {
     }
 
     async fn grab_buffer_snapshots(
-        rx: Receiver>,
-        find_all_matches_tx: Sender<(
-            Entity,
-            BufferSnapshot,
-            oneshot::Sender<(Entity, Vec>)>,
-        )>,
+        rx: Receiver<(Entity, LineHint)>,
+        find_all_matches_tx: Sender,
         results: Sender, Vec>)>>,
         mut cx: AsyncApp,
     ) {
         _ = maybe!(async move {
-            while let Ok(buffer) = rx.recv().await {
+            while let Ok((buffer, line_hint)) = rx.recv().await {
                 let snapshot = buffer.read_with(&mut cx, |this, _| this.snapshot());
                 let (tx, rx) = oneshot::channel();
-                find_all_matches_tx.send((buffer, snapshot, tx)).await?;
+                find_all_matches_tx
+                    .send(FindAllMatchesRequest {
+                        buffer,
+                        snapshot,
+                        line_hint,
+                        report_matches: tx,
+                    })
+                    .await?;
                 results.send(rx).await?;
             }
             debug_assert!(rx.is_empty());
@@ -645,11 +662,7 @@ struct Worker {
     candidates: FindSearchCandidates,
     /// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot.
     /// Then, when you're done, share them via the channel you were given.
-    find_all_matches_rx: Receiver<(
-        Entity,
-        BufferSnapshot,
-        oneshot::Sender<(Entity, Vec>)>,
-    )>,
+    find_all_matches_rx: Receiver,
 }
 
 impl Worker {
@@ -730,20 +743,28 @@ struct RequestHandler<'worker> {
 }
 
 impl RequestHandler<'_> {
-    async fn handle_find_all_matches(
-        &self,
-        (buffer, snapshot, mut report_matches): (
-            Entity,
-            BufferSnapshot,
-            oneshot::Sender<(Entity, Vec>)>,
-        ),
-    ) {
+    async fn handle_find_all_matches(&self, request: FindAllMatchesRequest) {
+        let FindAllMatchesRequest {
+            buffer,
+            snapshot,
+            line_hint,
+            mut report_matches,
+        } = request;
+        let range_offset = if line_hint > 0 {
+            snapshot.point_to_offset(Point::new(line_hint, 0))
+        } else {
+            0
+        };
+        let subrange = (range_offset > 0).then(|| range_offset..snapshot.len());
         let ranges = self
             .query
-            .search(&snapshot, None)
+            .search(&snapshot, subrange)
             .await
             .iter()
-            .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
+            .map(|range| {
+                snapshot.anchor_before(range.start + range_offset)
+                    ..snapshot.anchor_after(range.end + range_offset)
+            })
             .collect::>();
 
         _ = report_matches.send((buffer, ranges)).await;
@@ -777,9 +798,9 @@ impl RequestHandler<'_> {
                 return Ok(());
             }
 
-            if self.query.detect(file).await.unwrap_or(false) {
+            if let Some(line_hint) = self.query.detect(file).await.ok().flatten() {
                 // Yes, we should scan the whole file.
-                entry.should_scan_tx.send(entry.path).await?;
+                entry.should_scan_tx.send((entry.path, line_hint)).await?;
             }
             Ok(())
         }
@@ -816,10 +837,13 @@ impl RequestHandler<'_> {
                 // The buffer is already in memory and that's the version we want to scan;
                 // hence skip the dilly-dally and look for all matches straight away.
                 should_scan_tx
-                    .send(ProjectPath {
-                        worktree_id: snapshot.id(),
-                        path: entry.path.clone(),
-                    })
+                    .send((
+                        ProjectPath {
+                            worktree_id: snapshot.id(),
+                            path: entry.path.clone(),
+                        },
+                        LineHint::default(),
+                    ))
                     .await?;
             } else {
                 self.confirm_contents_will_match_tx
@@ -843,13 +867,20 @@ impl RequestHandler<'_> {
 struct InputPath {
     entry: Entry,
     snapshot: Snapshot,
-    should_scan_tx: oneshot::Sender,
+    should_scan_tx: oneshot::Sender<(ProjectPath, LineHint)>,
 }
 
 struct MatchingEntry {
     worktree_root: Arc,
     path: ProjectPath,
-    should_scan_tx: oneshot::Sender,
+    should_scan_tx: oneshot::Sender<(ProjectPath, LineHint)>,
+}
+
+struct FindAllMatchesRequest {
+    buffer: Entity,
+    snapshot: BufferSnapshot,
+    line_hint: LineHint,
+    report_matches: oneshot::Sender<(Entity, Vec>)>,
 }
 
 /// This struct encapsulates the logic to decide whether a given gitignored directory should be
diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs
index 316bb673a9a..ccb38746a2b 100644
--- a/crates/project/src/search.rs
+++ b/crates/project/src/search.rs
@@ -1,5 +1,5 @@
 use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
-use anyhow::Result;
+use anyhow::{Ok, Result};
 use client::proto;
 use fancy_regex::{Captures, Regex, RegexBuilder};
 use gpui::Entity;
@@ -45,6 +45,8 @@ pub struct SearchInputs {
     buffers: Option>>,
 }
 
+pub type LineHint = u32;
+
 impl SearchInputs {
     pub fn as_str(&self) -> &str {
         self.query.as_ref()
@@ -249,6 +251,7 @@ impl SearchQuery {
 
         let regex = RegexBuilder::new(&pattern)
             .case_insensitive(!case_sensitive)
+            .crlf(true)
             .build()?;
         Ok(Self::Regex {
             regex,
@@ -390,10 +393,10 @@ impl SearchQuery {
     pub(crate) async fn detect(
         &self,
         mut reader: BufReader>,
-    ) -> Result {
+    ) -> Result> {
         let query_str = self.as_str();
         if query_str.is_empty() {
-            return Ok(false);
+            return Ok(None);
         }
 
         // Yield from this function every 20KB scanned.
@@ -405,12 +408,17 @@ impl SearchQuery {
                 if query_str.contains('\n') {
                     reader.read_to_string(&mut text)?;
                     text::LineEnding::normalize(&mut text);
-                    Ok(search.is_match(&text))
+                    if search.is_match(&text) {
+                        Ok(Some(LineHint::default()))
+                    } else {
+                        Ok(None)
+                    }
                 } else {
                     let mut bytes_read = 0;
+                    let mut line_number: LineHint = LineHint::default();
                     while reader.read_line(&mut text)? > 0 {
                         if search.is_match(&text) {
-                            return Ok(true);
+                            return Ok(Some(line_number));
                         }
                         bytes_read += text.len();
                         if bytes_read >= YIELD_THRESHOLD {
@@ -418,8 +426,9 @@ impl SearchQuery {
                             smol::future::yield_now().await;
                         }
                         text.clear();
+                        line_number += 1;
                     }
-                    Ok(false)
+                    Ok(None)
                 }
             }
             Self::Regex {
@@ -429,12 +438,17 @@ impl SearchQuery {
                 if *multiline {
                     reader.read_to_string(&mut text)?;
                     text::LineEnding::normalize(&mut text);
-                    Ok(regex.is_match(&text)?)
+                    if regex.is_match(&text)? {
+                        Ok(Some(LineHint::default()))
+                    } else {
+                        Ok(None)
+                    }
                 } else {
                     let mut bytes_read = 0;
+                    let mut line_number: LineHint = LineHint::default();
                     while reader.read_line(&mut text)? > 0 {
                         if regex.is_match(&text)? {
-                            return Ok(true);
+                            return Ok(Some(line_number));
                         }
                         bytes_read += text.len();
                         if bytes_read >= YIELD_THRESHOLD {
@@ -442,8 +456,9 @@ impl SearchQuery {
                             smol::future::yield_now().await;
                         }
                         text.clear();
+                        line_number += 1;
                     }
-                    Ok(false)
+                    Ok(None)
                 }
             }
         }
@@ -556,7 +571,7 @@ impl SearchQuery {
                             yield_now().await;
                         }
 
-                        if let Ok(mat) = mat {
+                        if let std::result::Result::Ok(mat) = mat {
                             matches.push(mat.start()..mat.end());
                         }
                     }
diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs
index fd63c972f1d..911f9f79f3b 100644
--- a/crates/project/src/worktree_store.rs
+++ b/crates/project/src/worktree_store.rs
@@ -827,6 +827,7 @@ impl WorktreeStore {
                         visible,
                         abs_path: response.canonicalized_path,
                         root_repo_common_dir: response.root_repo_common_dir,
+                        root_repo_is_linked_worktree: response.root_repo_is_linked_worktree,
                     },
                     client,
                     path_style,
@@ -1155,6 +1156,7 @@ impl WorktreeStore {
                     root_repo_common_dir: worktree
                         .root_repo_common_dir()
                         .map(|p| p.to_string_lossy().into_owned()),
+                    root_repo_is_linked_worktree: worktree.root_repo_is_linked_worktree(),
                 }
             })
             .collect()
@@ -1371,7 +1373,9 @@ impl WorktreeStore {
                     .root_repo_common_dir()
                     .map(|dir| crate::git_store::repo_identity_path(dir))
                     .filter(|repo_path| {
-                        *repo_path == folder_path.as_path() || !folder_path.starts_with(*repo_path)
+                        snapshot.root_repo_is_linked_worktree()
+                            || *repo_path == folder_path.as_path()
+                            || !folder_path.starts_with(*repo_path)
                     })
                     .map(Path::to_path_buf)
                     .unwrap_or_else(|| folder_path.clone());
diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs
index 090baacf032..2600708df09 100644
--- a/crates/project/tests/integration/context_server_store.rs
+++ b/crates/project/tests/integration/context_server_store.rs
@@ -914,6 +914,421 @@ async fn test_remote_context_server(cx: &mut TestAppContext) {
     cx.run_until_parked();
 }
 
+// A server may accept `initialize` unauthenticated (returns 200) yet only send
+// `WWW-Authenticate` on a later request such as `tools/list` / `tools/call`.
+// That post-initialize 401 must initiate the OAuth flow instead of surfacing as
+// an opaque request failure while the server stays "Running".
+#[gpui::test]
+async fn test_http_server_authenticates_on_post_init_401(cx: &mut TestAppContext) {
+    use context_server::transport::TransportError;
+
+    const SERVER_ID: &str = "auth-server";
+    let server_id = ContextServerId(SERVER_ID.into());
+
+    set_fake_mcp_http_client(cx, |message| {
+        if message.contains("\"method\":\"initialize\"") {
+            Ok(initialize_response())
+        } else if message.contains("notifications/initialized") {
+            Ok(notification_accepted_response())
+        } else {
+            Ok(unauthorized_response())
+        }
+    });
+
+    let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await;
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    set_http_context_server_configuration(&server_id, cx);
+
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::Running),
+            ],
+            cx,
+        );
+        cx.run_until_parked();
+    }
+
+    let client = store.read_with(cx, |store, _| {
+        store
+            .get_running_server(&server_id)
+            .expect("server should be running")
+            .client()
+            .expect("running server should have a client")
+    });
+
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::AuthRequired),
+            ],
+            cx,
+        );
+
+        // The 401 tears down the client, and the request that carried it fails
+        // with the typed error.
+        let error = client
+            .request::(())
+            .await
+            .expect_err("request challenged with a 401 should fail");
+        assert!(
+            matches!(
+                error.downcast_ref::(),
+                Some(TransportError::AuthRequired { .. })
+            ),
+            "expected an AuthRequired error, got: {error}"
+        );
+
+        cx.run_until_parked();
+    }
+
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::AuthRequired),
+            "server should require authentication after a post-initialize 401"
+        );
+    });
+}
+
+// The first 401 may even arrive on a notification (`notifications/initialized`
+// here): the send fails with no request in flight to carry a typed error back,
+// and the client is dead by the time anything notices. Watching the transport
+// shutdown must still move the server into `AuthRequired`.
+#[gpui::test]
+async fn test_http_server_authenticates_on_notification_401(cx: &mut TestAppContext) {
+    const SERVER_ID: &str = "auth-server";
+    let server_id = ContextServerId(SERVER_ID.into());
+
+    set_fake_mcp_http_client(cx, |message| {
+        if message.contains("\"method\":\"initialize\"") {
+            Ok(initialize_response())
+        } else {
+            Ok(unauthorized_response())
+        }
+    });
+
+    let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await;
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    set_http_context_server_configuration(&server_id, cx);
+
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::Running),
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::AuthRequired),
+            ],
+            cx,
+        );
+        cx.run_until_parked();
+    }
+
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::AuthRequired),
+            "server should require authentication after a 401 on a notification"
+        );
+    });
+}
+
+// A transport failure that is not an authentication challenge must not touch
+// the server's state: no spurious auth flow, and (as before the transport
+// watch existed) the server stays `Running`.
+#[gpui::test]
+async fn test_http_server_ignores_non_auth_transport_failure(cx: &mut TestAppContext) {
+    const SERVER_ID: &str = "flaky-server";
+    let server_id = ContextServerId(SERVER_ID.into());
+
+    set_fake_mcp_http_client(cx, |message| {
+        if message.contains("\"method\":\"initialize\"") {
+            Ok(initialize_response())
+        } else if message.contains("notifications/initialized") {
+            Ok(notification_accepted_response())
+        } else {
+            Err(anyhow::anyhow!("connection reset"))
+        }
+    });
+
+    let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await;
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    set_http_context_server_configuration(&server_id, cx);
+
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::Running),
+            ],
+            cx,
+        );
+        cx.run_until_parked();
+
+        let client = store.read_with(cx, |store, _| {
+            store
+                .get_running_server(&server_id)
+                .expect("server should be running")
+                .client()
+                .expect("running server should have a client")
+        });
+
+        client
+            .request::(())
+            .await
+            .expect_err("request should fail when the transport errors");
+
+        cx.run_until_parked();
+        // Dropping the events guard asserts no further status change happened.
+    }
+
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::Running),
+            "a non-auth transport failure should not change the server state"
+        );
+    });
+}
+
+// A server may also require authentication on `initialize` itself. The
+// challenge is read from the transport slot rather than the returned error, so
+// the 401 is recognized even if another error (e.g. the request timeout) wins
+// the race to become the reported startup failure.
+#[gpui::test]
+async fn test_http_server_authenticates_on_initialize_401(cx: &mut TestAppContext) {
+    const SERVER_ID: &str = "auth-server";
+    let server_id = ContextServerId(SERVER_ID.into());
+
+    set_fake_mcp_http_client(cx, |_message| Ok(unauthorized_response()));
+
+    let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await;
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    set_http_context_server_configuration(&server_id, cx);
+
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_id.clone(), ContextServerStatus::Starting),
+                (server_id.clone(), ContextServerStatus::AuthRequired),
+            ],
+            cx,
+        );
+        cx.run_until_parked();
+    }
+
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::AuthRequired),
+            "server should require authentication after a 401 on initialize"
+        );
+    });
+}
+
+// Restarting a server reuses its transport (e.g. via the MCP settings page),
+// so a challenge recorded by a previous client generation must not leak into
+// the next one: after a successful restart, a non-auth transport failure must
+// not trip a spurious auth flow on the stale challenge.
+#[gpui::test]
+async fn test_http_server_restart_clears_stale_auth_challenge(cx: &mut TestAppContext) {
+    use std::sync::atomic::{AtomicBool, Ordering};
+
+    const SERVER_ID: &str = "auth-server";
+    let server_id = ContextServerId(SERVER_ID.into());
+
+    let restarted = Arc::new(AtomicBool::new(false));
+    set_fake_mcp_http_client(cx, {
+        let restarted = restarted.clone();
+        move |message| {
+            if message.contains("\"method\":\"initialize\"") {
+                Ok(initialize_response())
+            } else if message.contains("notifications/initialized") {
+                Ok(notification_accepted_response())
+            } else if restarted.load(Ordering::SeqCst) {
+                Err(anyhow::anyhow!("connection reset"))
+            } else {
+                Ok(unauthorized_response())
+            }
+        }
+    });
+
+    let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await;
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    set_http_context_server_configuration(&server_id, cx);
+    cx.run_until_parked();
+
+    // A post-initialize 401 records a challenge on the transport and moves the
+    // server into AuthRequired.
+    let client = store.read_with(cx, |store, _| {
+        store
+            .get_running_server(&server_id)
+            .expect("server should be running")
+            .client()
+            .expect("running server should have a client")
+    });
+    client
+        .request::(())
+        .await
+        .expect_err("request challenged with a 401 should fail");
+    // Drop our handle so the dead client fully goes away, as it does in
+    // production once the store has stopped it: a lingering client would
+    // compete with its successor for the reused transport's response channel.
+    drop(client);
+    cx.run_until_parked();
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::AuthRequired),
+        );
+    });
+
+    // The user restarts the same server instance instead of authenticating,
+    // and the server no longer challenges.
+    restarted.store(true, Ordering::SeqCst);
+    store.update(cx, |store, cx| {
+        let server = store.get_server(&server_id).expect("server should exist");
+        store.start_server(server, cx);
+    });
+    cx.run_until_parked();
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::Running),
+        );
+    });
+
+    let client = store.read_with(cx, |store, _| {
+        store
+            .get_running_server(&server_id)
+            .expect("server should be running")
+            .client()
+            .expect("running server should have a client")
+    });
+    client
+        .request::(())
+        .await
+        .expect_err("request should fail when the transport errors");
+    cx.run_until_parked();
+
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_id),
+            Some(ContextServerStatus::Running),
+            "a stale challenge from a previous client generation must not trigger auth"
+        );
+    });
+}
+
+fn set_http_context_server_configuration(server_id: &ContextServerId, cx: &mut TestAppContext) {
+    set_context_server_configuration(
+        vec![(
+            server_id.0.clone(),
+            settings::ContextServerSettingsContent::Http {
+                enabled: true,
+                url: "https://mcp.example.com/mcp".to_string(),
+                headers: Default::default(),
+                timeout: None,
+                oauth: None,
+            },
+        )],
+        cx,
+    );
+}
+
+/// A fake HTTP client that serves the OAuth discovery documents (CIMD-capable,
+/// so no dynamic client registration is needed) and routes MCP endpoint POSTs
+/// to `respond_to_mcp_message` by the JSON-RPC message in the request body.
+fn set_fake_mcp_http_client(
+    cx: &mut TestAppContext,
+    respond_to_mcp_message: impl Fn(&str) -> Result>
+    + Send
+    + Sync
+    + 'static,
+) {
+    let respond_to_mcp_message = Arc::new(respond_to_mcp_message);
+    let client = FakeHttpClient::create(move |request| {
+        let respond_to_mcp_message = respond_to_mcp_message.clone();
+        async move {
+            let uri = request.uri().to_string();
+            let discovery_document = if uri.contains("oauth-protected-resource") {
+                Some(json!({
+                    "resource": "https://mcp.example.com",
+                    "authorization_servers": ["https://auth.example.com"],
+                    "scopes_supported": ["mcp:read"]
+                }))
+            } else if uri.contains("oauth-authorization-server") {
+                Some(json!({
+                    "issuer": "https://auth.example.com",
+                    "authorization_endpoint": "https://auth.example.com/authorize",
+                    "token_endpoint": "https://auth.example.com/token",
+                    "code_challenge_methods_supported": ["S256"],
+                    "client_id_metadata_document_supported": true
+                }))
+            } else {
+                None
+            };
+            if let Some(document) = discovery_document {
+                return Ok(json_response(document));
+            }
+
+            let mut body = request.into_body();
+            let mut message = String::new();
+            futures::AsyncReadExt::read_to_string(&mut body, &mut message).await?;
+            respond_to_mcp_message(&message)
+        }
+    });
+    cx.update(|cx| cx.set_http_client(client));
+}
+
+fn json_response(body: serde_json::Value) -> Response {
+    Response::builder()
+        .status(200)
+        .header("Content-Type", "application/json")
+        .body(http_client::AsyncBody::from(body.to_string()))
+        .unwrap()
+}
+
+fn initialize_response() -> Response {
+    json_response(json!({
+        "jsonrpc": "2.0",
+        "id": 0,
+        "result": {
+            "protocolVersion": "2024-11-05",
+            "capabilities": {},
+            "serverInfo": { "name": "test-server", "version": "1.0.0" }
+        }
+    }))
+}
+
+fn notification_accepted_response() -> Response {
+    Response::builder()
+        .status(202)
+        .body(http_client::AsyncBody::empty())
+        .unwrap()
+}
+
+fn unauthorized_response() -> Response {
+    Response::builder()
+        .status(401)
+        .header("WWW-Authenticate", "Bearer")
+        .body(http_client::AsyncBody::empty())
+        .unwrap()
+}
+
 struct ServerEvents {
     received_event_count: Rc>,
     expected_event_count: usize,
@@ -1073,6 +1488,176 @@ async fn test_context_server_stdio_timeout(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_multi_worktree_context_server_settings(cx: &mut TestAppContext) {
+    const SERVER_A: &str = "server-from-project-a";
+    const SERVER_B: &str = "server-from-project-b";
+
+    let server_a_id = ContextServerId(SERVER_A.into());
+    let server_b_id = ContextServerId(SERVER_B.into());
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project_a"),
+        json!({
+            ".zed": {
+                "settings.json": serde_json::to_string(&json!({
+                    "context_servers": {
+                        "server-from-project-a": {
+                            "command": "server-a-binary",
+                            "args": []
+                        }
+                    }
+                })).unwrap()
+            },
+            "code.rs": ""
+        }),
+    )
+    .await;
+    fs.insert_tree(
+        path!("/project_b"),
+        json!({
+            ".zed": {
+                "settings.json": serde_json::to_string(&json!({
+                    "context_servers": {
+                        "server-from-project-b": {
+                            "command": "server-b-binary",
+                            "args": []
+                        }
+                    }
+                })).unwrap()
+            },
+            "code.rs": ""
+        }),
+    )
+    .await;
+
+    cx.update(|cx| {
+        let settings_store = SettingsStore::test(cx);
+        cx.set_global(settings_store);
+    });
+
+    // Create project with only project_a initially
+    let project = Project::test(fs.clone(), [path!("/project_a").as_ref()], cx).await;
+
+    let executor = cx.executor();
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+    store.update(cx, |store, _| {
+        store.set_context_server_factory(Box::new(move |id, _| {
+            Arc::new(ContextServer::new(
+                id.clone(),
+                Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
+            ))
+        }));
+    });
+
+    cx.run_until_parked();
+
+    // Only server-a should be configured
+    cx.update(|cx| {
+        let configured = store.read(cx).configured_server_ids();
+        assert!(
+            configured.contains(&server_a_id),
+            "server-a should be configured from project_a"
+        );
+        assert!(
+            !configured.contains(&server_b_id),
+            "server-b should not be configured yet"
+        );
+    });
+
+    // Add project_b as a second worktree
+    project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/project_b"), true, cx)
+        })
+        .await
+        .expect("Failed to add second worktree");
+
+    cx.run_until_parked();
+
+    // Both servers should now be configured
+    cx.update(|cx| {
+        let configured = store.read(cx).configured_server_ids();
+        assert!(
+            configured.contains(&server_a_id),
+            "server-a should still be configured from project_a"
+        );
+        assert!(
+            configured.contains(&server_b_id),
+            "server-b should now be configured from project_b"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_multi_worktree_duplicate_server_first_wins(cx: &mut TestAppContext) {
+    const SHARED_SERVER: &str = "shared-server";
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project_a"),
+        json!({
+            ".zed": {
+                "settings.json": serde_json::to_string(&json!({
+                    "context_servers": {
+                        "shared-server": {
+                            "command": "binary-from-a",
+                            "args": ["arg-a"]
+                        }
+                    }
+                })).unwrap()
+            },
+            "code.rs": ""
+        }),
+    )
+    .await;
+    fs.insert_tree(
+        path!("/project_b"),
+        json!({
+            ".zed": {
+                "settings.json": serde_json::to_string(&json!({
+                    "context_servers": {
+                        "shared-server": {
+                            "command": "binary-from-b",
+                            "args": ["arg-b"]
+                        }
+                    }
+                })).unwrap()
+            },
+            "code.rs": ""
+        }),
+    )
+    .await;
+
+    cx.update(|cx| {
+        let settings_store = SettingsStore::test(cx);
+        cx.set_global(settings_store);
+    });
+
+    // Create project with both worktrees
+    let project = Project::test(
+        fs.clone(),
+        [path!("/project_a").as_ref(), path!("/project_b").as_ref()],
+        cx,
+    )
+    .await;
+
+    cx.run_until_parked();
+
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+
+    // The server should appear exactly once
+    cx.update(|cx| {
+        let configured = store.read(cx).configured_server_ids();
+        let count = configured
+            .iter()
+            .filter(|id| id.0.as_ref() == SHARED_SERVER)
+            .count();
+        assert_eq!(count, 1, "duplicate server ID should appear exactly once");
+    });
+}
+
 fn assert_server_events(
     store: &Entity,
     expected_events: Vec<(ContextServerId, ContextServerStatus)>,
diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs
index 16e948f3a73..2b2ba5dece6 100644
--- a/crates/project/tests/integration/project_tests.rs
+++ b/crates/project/tests/integration/project_tests.rs
@@ -21,12 +21,11 @@ mod yarn;
 use anyhow::Result;
 use async_trait::async_trait;
 use buffer_diff::{
-    BufferDiffEvent, DiffChanged, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind,
-    assert_hunks,
+    BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks,
 };
 use collections::{BTreeSet, HashMap, HashSet};
 use encoding_rs;
-use fs::{FakeFs, PathEventKind};
+use fs::{FakeFs, PathEventKind, RealFs};
 use futures::{StreamExt, future};
 use git::{
     GitHostingProviderRegistry,
@@ -90,7 +89,7 @@ use task::{ResolvedTask, ShellKind, TaskContext};
 use text::{Anchor, PointUtf16, ReplicaId, ToOffset, Unclipped};
 use unindent::Unindent as _;
 use util::{
-    TryFutureExt as _, assert_set_eq, maybe, path,
+    RandomCharIter, TryFutureExt as _, assert_set_eq, maybe, path,
     paths::{PathMatcher, PathStyle},
     rel_path::{RelPath, rel_path},
     test::{TempTree, marked_text_offsets},
@@ -7674,6 +7673,110 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
     );
 }
 
+// Regression test for https://github.com/zed-industries/zed/issues/59077:
+// a "rename symbol" whose workspace edit also renames the file used to swap the
+// two files' contents. The edited content must end up in the renamed file.
+#[gpui::test]
+async fn test_rename_that_also_renames_file(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            "one.rs": "const ONE: usize = 1;",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
+                    prepare_provider: Some(true),
+                    work_done_progress_options: Default::default(),
+                })),
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    );
+
+    let (buffer, _handle) = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let fake_server = fake_servers.next().await.unwrap();
+    cx.executor().run_until_parked();
+
+    let response = project.update(cx, |project, cx| {
+        project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx)
+    });
+    fake_server
+        .set_request_handler::(|_params, _| async move {
+            Ok(Some(lsp::WorkspaceEdit {
+                changes: None,
+                document_changes: Some(DocumentChanges::Operations(vec![
+                    lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit {
+                        text_document: lsp::OptionalVersionedTextDocumentIdentifier {
+                            uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(),
+                            version: None,
+                        },
+                        edits: vec![lsp::Edit::Plain(lsp::TextEdit::new(
+                            lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)),
+                            "THREE".to_string(),
+                        ))],
+                    }),
+                    lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Rename(lsp::RenameFile {
+                        old_uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(),
+                        new_uri: Uri::from_file_path(path!("/dir/three.rs")).unwrap(),
+                        options: None,
+                        annotation_id: None,
+                    })),
+                ])),
+                change_annotations: None,
+            }))
+        })
+        .next()
+        .await
+        .unwrap();
+    response.await.unwrap();
+    cx.executor().run_until_parked();
+
+    // The renamed file must contain the edited content, not the stale pre-edit
+    // content, and the old file must be gone (no content swap).
+    assert_eq!(
+        fs.load(Path::new(path!("/dir/three.rs"))).await.unwrap(),
+        "const THREE: usize = 1;"
+    );
+    assert!(
+        fs.load(Path::new(path!("/dir/one.rs"))).await.is_err(),
+        "old file should not exist after the rename"
+    );
+
+    // The buffer should follow the rename to the new path, hold the edited
+    // content, and be saved (so it can't be written back to the old path).
+    buffer.read_with(cx, |buffer, _cx| {
+        assert_eq!(buffer.text(), "const THREE: usize = 1;");
+        assert_eq!(
+            buffer.file().unwrap().path().as_ref(),
+            rel_path("three.rs"),
+            "buffer should be associated with the renamed file"
+        );
+        assert!(
+            !buffer.is_dirty(),
+            "buffer should be saved after the rename"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_search(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -9671,10 +9774,13 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
         .unwrap();
     cx.run_until_parked();
     unstaged_diff.read_with(cx, |diff, cx| {
-        assert_eq!(diff.base_text(cx).language().cloned(), None);
+        assert_eq!(
+            diff.base_text(cx).language().cloned(),
+            Some(language.clone())
+        );
     });
 
-    let staged_diff = project
+    let (staged_diff, index_text_buffer) = project
         .update(cx, |project, cx| {
             project.open_staged_diff(buffer.clone(), cx)
         })
@@ -9682,6 +9788,14 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
         .unwrap();
 
     cx.run_until_parked();
+    index_text_buffer.read_with(cx, |buffer, cx| {
+        let file = buffer.file().unwrap();
+        assert_eq!(file.file_name(cx), "main.rs");
+        assert_eq!(
+            file.disk_state(),
+            DiskState::Historic { was_deleted: false }
+        );
+    });
     unstaged_diff.read_with(cx, |diff, cx| {
         assert_eq!(
             diff.base_text(cx).language().cloned(),
@@ -9870,7 +9984,7 @@ async fn test_staged_diff_without_unstaged_diff(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap();
 
-    let staged_diff = project
+    let (staged_diff, _) = project
         .update(cx, |project, cx| {
             project.open_staged_diff(buffer.clone(), cx)
         })
@@ -10157,7 +10271,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
         })
         .await
         .unwrap();
-    let mut diff_events = cx.events(&uncommitted_diff);
 
     // The hunks are initially unstaged.
     uncommitted_diff.read_with(cx, |diff, cx| {
@@ -10189,15 +10302,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
     });
 
     // Stage a hunk. It appears as optimistically staged.
-    uncommitted_diff.update(cx, |diff, cx| {
-        let range =
-            snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0));
-        let hunks = diff
-            .snapshot(cx)
-            .hunks_intersecting_range(range, &snapshot)
-            .collect::>();
-        diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
-
+    let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0));
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10225,28 +10337,9 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
         );
     });
 
-    // The diff emits a change event for the range of the staged hunk.
-    assert!(matches!(
-        diff_events.next().await.unwrap(),
-        BufferDiffEvent::HunksStagedOrUnstaged(_)
-    ));
-    let event = diff_events.next().await.unwrap();
-    if let BufferDiffEvent::DiffChanged(DiffChanged {
-        changed_range: Some(changed_range),
-        base_text_changed_range: _,
-        extended_range: _,
-        base_text_changed: _,
-    }) = event
-    {
-        let changed_range = changed_range.to_point(&snapshot);
-        assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0));
-    } else {
-        panic!("Unexpected event {event:?}");
-    }
-
     // When the write to the index completes, it appears as staged.
     cx.run_until_parked();
-    uncommitted_diff.update(cx, |diff, cx| {
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10274,21 +10367,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
         );
     });
 
-    // The diff emits a change event for the changed index text.
-    let event = diff_events.next().await.unwrap();
-    if let BufferDiffEvent::DiffChanged(DiffChanged {
-        changed_range: Some(changed_range),
-        base_text_changed_range: _,
-        extended_range: _,
-        base_text_changed: _,
-    }) = event
-    {
-        let changed_range = changed_range.to_point(&snapshot);
-        assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0));
-    } else {
-        panic!("Unexpected event {event:?}");
-    }
-
     // Simulate a problem writing to the git index.
     fs.set_error_message_for_index_write(
         "/dir/.git".as_ref(),
@@ -10296,15 +10374,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
     );
 
     // Stage another hunk.
-    uncommitted_diff.update(cx, |diff, cx| {
-        let range =
-            snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0));
-        let hunks = diff
-            .snapshot(cx)
-            .hunks_intersecting_range(range, &snapshot)
-            .collect::>();
-        diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
-
+    let range = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0));
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10331,27 +10408,10 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
             ],
         );
     });
-    assert!(matches!(
-        diff_events.next().await.unwrap(),
-        BufferDiffEvent::HunksStagedOrUnstaged(_)
-    ));
-    let event = diff_events.next().await.unwrap();
-    if let BufferDiffEvent::DiffChanged(DiffChanged {
-        changed_range: Some(changed_range),
-        base_text_changed_range: _,
-        extended_range: _,
-        base_text_changed: _,
-    }) = event
-    {
-        let changed_range = changed_range.to_point(&snapshot);
-        assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0));
-    } else {
-        panic!("Unexpected event {event:?}");
-    }
 
     // When the write fails, the hunk returns to being unstaged.
     cx.run_until_parked();
-    uncommitted_diff.update(cx, |diff, cx| {
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10379,32 +10439,29 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
         );
     });
 
-    let event = diff_events.next().await.unwrap();
-    if let BufferDiffEvent::DiffChanged(DiffChanged {
-        changed_range: Some(changed_range),
-        base_text_changed_range: _,
-        extended_range: _,
-        base_text_changed: _,
-    }) = event
-    {
-        let changed_range = changed_range.to_point(&snapshot);
-        assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0));
-    } else {
-        panic!("Unexpected event {event:?}");
-    }
-
     // Allow writing to the git index to succeed again.
     fs.set_error_message_for_index_write("/dir/.git".as_ref(), None);
 
     // Stage two hunks with separate operations.
-    uncommitted_diff.update(cx, |diff, cx| {
+    let (range0, range2) = uncommitted_diff.read_with(cx, |diff, cx| {
         let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>();
-        diff.stage_or_unstage_hunks(true, &hunks[0..1], &snapshot, true, cx);
-        diff.stage_or_unstage_hunks(true, &hunks[2..3], &snapshot, true, cx);
+        (hunks[0].buffer_range.clone(), hunks[2].buffer_range.clone())
     });
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range0], cx)
+        })
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range2], cx)
+        })
+        .unwrap();
 
     // Both staged hunks appear as pending.
-    uncommitted_diff.update(cx, |diff, cx| {
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10434,7 +10491,7 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
 
     // Both staging operations take effect.
     cx.run_until_parked();
-    uncommitted_diff.update(cx, |diff, cx| {
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10631,9 +10688,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext)
     fs.pause_events();
 
     // Stage the first hunk.
-    uncommitted_diff.update(cx, |diff, cx| {
-        let hunk = diff.snapshot(cx).hunks(&snapshot).next().unwrap();
-        diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx);
+    let range = uncommitted_diff.read_with(cx, |diff, cx| {
+        diff.snapshot(cx)
+            .hunks(&snapshot)
+            .next()
+            .unwrap()
+            .buffer_range
+    });
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10663,9 +10731,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext)
 
     // Stage the second hunk *before* receiving the FS event for the first hunk.
     cx.run_until_parked();
-    uncommitted_diff.update(cx, |diff, cx| {
-        let hunk = diff.snapshot(cx).hunks(&snapshot).nth(1).unwrap();
-        diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx);
+    let range = uncommitted_diff.read_with(cx, |diff, cx| {
+        diff.snapshot(cx)
+            .hunks(&snapshot)
+            .nth(1)
+            .unwrap()
+            .buffer_range
+    });
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10698,10 +10777,19 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext)
     cx.run_until_parked();
 
     // Stage the third hunk before receiving the second FS event.
-    uncommitted_diff.update(cx, |diff, cx| {
-        let hunk = diff.snapshot(cx).hunks(&snapshot).nth(2).unwrap();
-        diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx);
+    let range = uncommitted_diff.read_with(cx, |diff, cx| {
+        diff.snapshot(cx)
+            .hunks(&snapshot)
+            .nth(2)
+            .unwrap()
+            .buffer_range
     });
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+        })
+        .unwrap();
 
     // Wait for all remaining IO.
     cx.run_until_parked();
@@ -10709,7 +10797,7 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext)
 
     // Now all hunks are staged.
     cx.run_until_parked();
-    uncommitted_diff.update(cx, |diff, cx| {
+    uncommitted_diff.read_with(cx, |diff, cx| {
         assert_hunks(
             diff.snapshot(cx).hunks(&snapshot),
             &snapshot,
@@ -10733,6 +10821,167 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext)
     });
 }
 
+#[gpui::test]
+async fn test_restaging_hunk_after_optimistic_unstage(cx: &mut gpui::TestAppContext) {
+    use DiffHunkSecondaryStatus::*;
+    init_test(cx);
+
+    let committed_contents = r#"
+        one
+        two
+        three
+    "#
+    .unindent();
+    let file_contents = r#"
+        one
+        TWO
+        three
+    "#
+    .unindent();
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            ".git": {},
+            "file.txt": file_contents.clone()
+        }),
+    )
+    .await;
+
+    // The hunk starts out staged: the index already matches the worktree.
+    fs.set_head_for_repo(
+        path!("/dir/.git").as_ref(),
+        &[("file.txt", committed_contents.clone())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        path!("/dir/.git").as_ref(),
+        &[("file.txt", file_contents.clone())],
+    );
+    let repo = fs
+        .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref()))
+        .unwrap();
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/dir/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+    let uncommitted_diff = project
+        .update(cx, |project, cx| {
+            project.open_uncommitted_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let hunk_range = uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.snapshot(cx).hunks(&snapshot),
+            &snapshot,
+            &diff.base_text_string(cx).unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(NoSecondaryHunk),
+            )],
+        );
+        diff.snapshot(cx)
+            .hunks(&snapshot)
+            .next()
+            .unwrap()
+            .buffer_range
+    });
+
+    // Hold back FS events so that the index writes below stay un-reconciled:
+    // the second operation must run while the first one's optimistic state is
+    // still pending.
+    fs.pause_events();
+
+    // Unstage the hunk. It appears as optimistically unstaged.
+    project
+        .update(cx, |project, cx| {
+            project.unstage_uncommitted_hunks(
+                buffer.clone(),
+                uncommitted_diff.clone(),
+                vec![hunk_range.clone()],
+                cx,
+            )
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.snapshot(cx).hunks(&snapshot),
+            &snapshot,
+            &diff.base_text_string(cx).unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(SecondaryHunkAdditionPending),
+            )],
+        );
+    });
+
+    // Re-stage the same hunk before the unstage write has been reconciled.
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, vec![hunk_range.clone()], cx)
+        })
+        .unwrap();
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.snapshot(cx).hunks(&snapshot),
+            &snapshot,
+            &diff.base_text_string(cx).unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(SecondaryHunkRemovalPending),
+            )],
+        );
+    });
+
+    cx.run_until_parked();
+    assert_eq!(
+        repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt")))
+            .await
+            .unwrap(),
+        file_contents,
+        "the re-stage should have overridden the in-flight unstage"
+    );
+
+    fs.unpause_events_and_flush();
+    cx.run_until_parked();
+
+    // Once everything settles, the hunk is staged again.
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.snapshot(cx).hunks(&snapshot),
+            &snapshot,
+            &diff.base_text_string(cx).unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(NoSecondaryHunk),
+            )],
+        );
+    });
+    assert_eq!(
+        repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt")))
+            .await
+            .unwrap(),
+        file_contents
+    );
+}
+
 #[gpui::test(iterations = 25)]
 async fn test_staging_random_hunks(
     mut rng: StdRng,
@@ -10746,11 +10995,12 @@ async fn test_staging_random_hunks(
     use DiffHunkSecondaryStatus::*;
     init_test(cx);
 
-    let committed_text = (0..30).map(|i| format!("line {i}\n")).collect::();
+    let committed_text = (0..40).map(|i| format!("line {i}\n")).collect::();
     let index_text = committed_text.clone();
-    let buffer_text = (0..30)
-        .map(|i| match i % 5 {
-            0 => format!("line {i} (modified)\n"),
+    let buffer_text = (0..40)
+        .map(|i| match i {
+            35 => format!("line {i}\ninserted after {i}\n"),
+            _ if i < 30 && i % 5 == 0 => format!("line {i} (modified)\n"),
             _ => format!("line {i}\n"),
         })
         .collect::();
@@ -10795,24 +11045,75 @@ async fn test_staging_random_hunks(
     let mut hunks = uncommitted_diff.update(cx, |diff, cx| {
         diff.snapshot(cx).hunks(&snapshot).collect::>()
     });
-    assert_eq!(hunks.len(), 6);
+    assert_eq!(hunks.len(), 7);
+    let insertion_hunk = hunks
+        .iter()
+        .find(|hunk| hunk.diff_base_byte_range.is_empty())
+        .unwrap()
+        .clone();
+    fs.pause_events();
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(
+                buffer.clone(),
+                unstaged_diff,
+                vec![insertion_hunk.buffer_range.clone()],
+                cx,
+            )
+        })
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.unstage_uncommitted_hunks(
+                buffer.clone(),
+                uncommitted_diff.clone(),
+                vec![insertion_hunk.buffer_range],
+                cx,
+            )
+        })
+        .unwrap();
+    cx.executor().run_until_parked();
+    assert_eq!(
+        repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt")))
+            .await
+            .unwrap(),
+        index_text
+    );
+    fs.unpause_events_and_flush();
+    cx.run_until_parked();
+    hunks = uncommitted_diff.update(cx, |diff, cx| {
+        diff.snapshot(cx).hunks(&snapshot).collect::>()
+    });
+    assert_eq!(hunks.len(), 7);
 
     for _i in 0..operations {
         let hunk_ix = rng.random_range(0..hunks.len());
         let hunk = &mut hunks[hunk_ix];
         let row = hunk.range.start.row;
 
+        let range = hunk.buffer_range.clone();
         if hunk.status().has_secondary_hunk() {
             log::info!("staging hunk at {row}");
-            uncommitted_diff.update(cx, |diff, cx| {
-                diff.stage_or_unstage_hunks(true, std::slice::from_ref(hunk), &snapshot, true, cx);
-            });
+            project
+                .update(cx, |project, cx| {
+                    let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+                    project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx)
+                })
+                .unwrap();
             hunk.secondary_status = SecondaryHunkRemovalPending;
         } else {
             log::info!("unstaging hunk at {row}");
-            uncommitted_diff.update(cx, |diff, cx| {
-                diff.stage_or_unstage_hunks(false, std::slice::from_ref(hunk), &snapshot, true, cx);
-            });
+            project
+                .update(cx, |project, cx| {
+                    project.unstage_uncommitted_hunks(
+                        buffer.clone(),
+                        uncommitted_diff.clone(),
+                        vec![range],
+                        cx,
+                    )
+                })
+                .unwrap();
             hunk.secondary_status = SecondaryHunkAdditionPending;
         }
 
@@ -10853,6 +11154,508 @@ async fn test_staging_random_hunks(
     });
 }
 
+#[gpui::test(iterations = 25)]
+async fn test_staging_random_hunks_with_edits(
+    mut rng: StdRng,
+    _executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    let operations = env::var("OPERATIONS")
+        .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
+        .unwrap_or(30);
+
+    init_test(cx);
+
+    fn disk_index_text(fs: &FakeFs) -> String {
+        fs.with_git_state(path!("/dir/.git").as_ref(), false, |state| {
+            state
+                .index_contents
+                .get(&repo_path("file.txt"))
+                .cloned()
+                .expect("file is always present in the index")
+        })
+        .unwrap()
+    }
+
+    fn random_row_range(
+        snapshot: &text::BufferSnapshot,
+        rng: &mut StdRng,
+    ) -> (Range, Range) {
+        let max_row = snapshot.max_point().row;
+        let start_row = rng.random_range(0..=max_row);
+        let end_row = rng.random_range(start_row..=max_row.min(start_row + 5));
+        let start = Point::new(start_row, 0);
+        let end = Point::new(end_row, snapshot.line_len(end_row));
+        (
+            snapshot.anchor_before(start)..snapshot.anchor_after(end),
+            start_row..end_row,
+        )
+    }
+
+    #[allow(clippy::type_complexity)]
+    fn capture_diff_state(
+        diff: &Entity,
+        cx: &mut gpui::TestAppContext,
+    ) -> (
+        String,
+        Option,
+        Vec<(Range, Range, DiffHunkSecondaryStatus)>,
+    ) {
+        diff.read_with(cx, |diff, cx| {
+            let snapshot = diff.snapshot(cx);
+            let buffer_snapshot = snapshot.buffer_snapshot().clone();
+            let hunks = snapshot
+                .hunks(&buffer_snapshot)
+                .map(|hunk| {
+                    (
+                        hunk.range.clone(),
+                        hunk.diff_base_byte_range.clone(),
+                        hunk.secondary_status,
+                    )
+                })
+                .collect();
+            (buffer_snapshot.text(), snapshot.base_text_string(), hunks)
+        })
+    }
+
+    fn assert_settled(diff: &Entity, name: &str, cx: &mut gpui::TestAppContext) {
+        diff.read_with(cx, |diff, cx| {
+            let snapshot = diff.snapshot(cx);
+            let buffer_snapshot = snapshot.buffer_snapshot().clone();
+            let cooked = snapshot
+                .hunks(&buffer_snapshot)
+                .map(|hunk| (hunk.range.clone(), hunk.secondary_status))
+                .collect::>();
+            for (range, status) in &cooked {
+                assert!(
+                    !matches!(
+                        status,
+                        DiffHunkSecondaryStatus::SecondaryHunkAdditionPending
+                            | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending
+                    ),
+                    "{name} hunk at {range:?} still has pending status {status:?} after settling"
+                );
+            }
+            let full_range = Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id());
+            let raw_ranges = snapshot
+                .raw_hunks_intersecting_range(full_range, &buffer_snapshot)
+                .map(|hunk| hunk.range)
+                .collect::>();
+            let cooked_ranges = cooked
+                .into_iter()
+                .map(|(range, _)| range)
+                .collect::>();
+            assert_eq!(
+                raw_ranges, cooked_ranges,
+                "{name} still has suppressed hunks after settling"
+            );
+        })
+    }
+
+    let mut committed_text = (0..40)
+        .map(|i| match i {
+            _ if i % 5 == 0 => format!("line {i} αβ🍐\n"),
+            _ => format!("line {i}\n"),
+        })
+        .collect::();
+    let index_text = (0..40)
+        .map(|i| match i {
+            10 => "line 10 (modified ✅)\n".to_string(),
+            20 => "line 20 (staged-only ⭐)\n".to_string(),
+            _ if i % 5 == 0 => format!("line {i} αβ🍐\n"),
+            _ => format!("line {i}\n"),
+        })
+        .collect::();
+    let buffer_text = (0..40)
+        .map(|i| match i {
+            35 => format!("line {i} αβ🍐\ninserted after {i}\n"),
+            _ if i < 30 && i % 5 == 0 => format!("line {i} (modified ✅)\n"),
+            _ if i % 5 == 0 => format!("line {i} αβ🍐\n"),
+            _ => format!("line {i}\n"),
+        })
+        .collect::();
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            ".git": {},
+            "file.txt": buffer_text.clone()
+        }),
+    )
+    .await;
+    fs.set_head_for_repo(
+        path!("/dir/.git").as_ref(),
+        &[("file.txt", committed_text.clone())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(path!("/dir/.git").as_ref(), &[("file.txt", index_text)]);
+    let repo = fs
+        .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref()))
+        .unwrap();
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/dir/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+    let unstaged_diff = project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    let (staged_diff, index_buffer) = project
+        .update(cx, |project, cx| {
+            project.open_staged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    let uncommitted_diff = project
+        .update(cx, |project, cx| {
+            project.open_uncommitted_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    let mut events_paused = false;
+    let mut commit_count = 0;
+    for _ in 0..operations {
+        match rng.random_range(0..100) {
+            0..20 => {
+                let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
+                let (range, rows) = random_row_range(&snapshot, &mut rng);
+                log::info!("staging rows {rows:?}");
+                project
+                    .update(cx, |project, cx| {
+                        project.stage_hunks(buffer.clone(), unstaged_diff.clone(), vec![range], cx)
+                    })
+                    .unwrap();
+            }
+            20..35 => {
+                let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
+                let (range, rows) = random_row_range(&snapshot, &mut rng);
+                log::info!("unstaging rows {rows:?} via the uncommitted diff");
+                project
+                    .update(cx, |project, cx| {
+                        project.unstage_uncommitted_hunks(
+                            buffer.clone(),
+                            uncommitted_diff.clone(),
+                            vec![range],
+                            cx,
+                        )
+                    })
+                    .unwrap();
+            }
+            35..50 => {
+                let snapshot = index_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
+                let (range, rows) = random_row_range(&snapshot, &mut rng);
+                log::info!("unstaging index rows {rows:?} via the staged diff");
+                project
+                    .update(cx, |project, cx| {
+                        project.unstage_staged_hunks(staged_diff.clone(), vec![range], cx)
+                    })
+                    .unwrap();
+            }
+            // Line-oriented edits keep the file hunk-rich; uniform random
+            // splices (`randomly_edit`) delete a quarter of the file on
+            // average and quickly collapse it into a single hunk.
+            50..75 => {
+                let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
+                let max_row = snapshot.max_point().row;
+                let row = rng.random_range(0..=max_row);
+                let row_start = snapshot.point_to_offset(Point::new(row, 0));
+                let row_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row)));
+                let new_text_len = rng.random_range(0..8);
+                let new_text = RandomCharIter::new(&mut rng)
+                    .take(new_text_len)
+                    .collect::();
+                let (range, new_text) = match rng.random_range(0..10) {
+                    0..3 => (row_start..row_start, format!("{new_text}\n")),
+                    3..5 if max_row > 0 => {
+                        let end = if row < max_row {
+                            snapshot.point_to_offset(Point::new(row + 1, 0))
+                        } else {
+                            snapshot.len()
+                        };
+                        (row_start..end, String::new())
+                    }
+                    5..8 => (row_start..row_end, new_text),
+                    _ => {
+                        let start = snapshot
+                            .clip_offset(rng.random_range(0..=snapshot.len()), text::Bias::Left);
+                        let end = snapshot.clip_offset(
+                            (start + rng.random_range(0..10)).min(snapshot.len()),
+                            text::Bias::Right,
+                        );
+                        (start..end, new_text)
+                    }
+                };
+                log::info!("editing buffer: {range:?} -> {new_text:?}");
+                buffer.update(cx, |buffer, cx| buffer.edit([(range, new_text)], None, cx));
+            }
+            // Commits and whole-file round-trip checkpoints. The round trips
+            // settle first so the resulting index bytes are exactly
+            // predictable, which catches wrong-content writes that the
+            // settled-consistency checks can't see (a corrupted write becomes
+            // the on-disk truth that everything else then agrees with).
+            75..82 => {
+                let buffer_full_range = Anchor::min_max_range_for_buffer(
+                    buffer.read_with(cx, |buffer, _| buffer.remote_id()),
+                );
+                let index_full_range = Anchor::min_max_range_for_buffer(
+                    index_buffer.read_with(cx, |buffer, _| buffer.remote_id()),
+                );
+                match rng.random_range(0..4) {
+                    // Commit: HEAD becomes whatever the index currently
+                    // contains on disk, which may lag in-flight optimistic
+                    // writes, just like running `git commit` in a terminal.
+                    0 => {
+                        let new_head = disk_index_text(&fs);
+                        commit_count += 1;
+                        log::info!("committing (commit {commit_count})");
+                        fs.set_head_for_repo(
+                            path!("/dir/.git").as_ref(),
+                            &[("file.txt", new_head.clone())],
+                            format!("sha-{commit_count}"),
+                        );
+                        committed_text = new_head;
+                    }
+                    // Stage everything and commit it, like `git add -A &&
+                    // git commit`.
+                    1 => {
+                        log::info!("checkpoint: stage-all, then commit");
+                        if events_paused {
+                            fs.unpause_events_and_flush();
+                            events_paused = false;
+                        }
+                        cx.run_until_parked();
+                        project
+                            .update(cx, |project, cx| {
+                                project.stage_hunks(
+                                    buffer.clone(),
+                                    unstaged_diff.clone(),
+                                    vec![buffer_full_range],
+                                    cx,
+                                )
+                            })
+                            .unwrap();
+                        cx.run_until_parked();
+                        let disk = disk_index_text(&fs);
+                        let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
+                        assert_eq!(
+                            disk, buffer_text,
+                            "after staging everything, the index must equal the worktree"
+                        );
+                        commit_count += 1;
+                        log::info!("committing (commit {commit_count})");
+                        fs.set_head_for_repo(
+                            path!("/dir/.git").as_ref(),
+                            &[("file.txt", disk.clone())],
+                            format!("sha-{commit_count}"),
+                        );
+                        committed_text = disk;
+                    }
+                    // Unstage everything via the staged diff, which by
+                    // definition covers every index-vs-HEAD difference.
+                    2 => {
+                        log::info!("checkpoint: unstage-all");
+                        if events_paused {
+                            fs.unpause_events_and_flush();
+                            events_paused = false;
+                        }
+                        cx.run_until_parked();
+                        project
+                            .update(cx, |project, cx| {
+                                project.unstage_staged_hunks(
+                                    staged_diff.clone(),
+                                    vec![index_full_range],
+                                    cx,
+                                )
+                            })
+                            .unwrap();
+                        cx.run_until_parked();
+                        let disk = disk_index_text(&fs);
+                        assert_eq!(
+                            disk, committed_text,
+                            "after unstaging everything, the index must equal HEAD"
+                        );
+                    }
+                    // Unstage everything and immediately re-stage everything
+                    // with no settling in between: the re-stage must supersede
+                    // the in-flight unstage (the footprint mechanism), leaving
+                    // the index equal to the worktree.
+                    _ => {
+                        log::info!("checkpoint: unstage-all then stage-all");
+                        if events_paused {
+                            fs.unpause_events_and_flush();
+                            events_paused = false;
+                        }
+                        cx.run_until_parked();
+                        project
+                            .update(cx, |project, cx| {
+                                project.unstage_staged_hunks(
+                                    staged_diff.clone(),
+                                    vec![index_full_range],
+                                    cx,
+                                )
+                            })
+                            .unwrap();
+                        project
+                            .update(cx, |project, cx| {
+                                project.stage_hunks(
+                                    buffer.clone(),
+                                    unstaged_diff.clone(),
+                                    vec![buffer_full_range],
+                                    cx,
+                                )
+                            })
+                            .unwrap();
+                        cx.run_until_parked();
+                        let disk = disk_index_text(&fs);
+                        let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
+                        assert_eq!(
+                            disk, buffer_text,
+                            "re-staging everything must supersede the in-flight unstage"
+                        );
+                    }
+                }
+            }
+            82..90 => {
+                if events_paused {
+                    log::info!("unpausing fs events");
+                    fs.unpause_events_and_flush();
+                    events_paused = false;
+                } else {
+                    log::info!("pausing fs events");
+                    fs.pause_events();
+                    events_paused = true;
+                }
+            }
+            _ => {
+                log::info!("settling");
+                if events_paused {
+                    fs.unpause_events_and_flush();
+                    events_paused = false;
+                }
+                cx.run_until_parked();
+            }
+        }
+
+        for _ in 0..rng.random_range(0..5) {
+            cx.executor().simulate_random_delay().await;
+        }
+    }
+
+    if events_paused {
+        fs.unpause_events_and_flush();
+    }
+    cx.run_until_parked();
+
+    assert_settled(&unstaged_diff, "unstaged diff", cx);
+    assert_settled(&staged_diff, "staged diff", cx);
+    assert_settled(&uncommitted_diff, "uncommitted diff", cx);
+
+    let old_unstaged_state = capture_diff_state(&unstaged_diff, cx);
+    let old_staged_state = capture_diff_state(&staged_diff, cx);
+    let old_uncommitted_state = capture_diff_state(&uncommitted_diff, cx);
+
+    let disk_index_text = repo
+        .load_index_text(RepoPath::from_rel_path(rel_path("file.txt")))
+        .await
+        .unwrap();
+    assert_eq!(
+        old_unstaged_state.1.as_deref(),
+        Some(disk_index_text.as_str()),
+        "unstaged diff base text differs from the index on disk"
+    );
+    assert_eq!(
+        old_staged_state.0, disk_index_text,
+        "staged diff buffer differs from the index on disk"
+    );
+    assert_eq!(
+        old_staged_state.1.as_deref(),
+        Some(committed_text.as_str()),
+        "staged diff base text differs from HEAD"
+    );
+    assert_eq!(
+        old_uncommitted_state.1.as_deref(),
+        Some(committed_text.as_str()),
+        "uncommitted diff base text differs from HEAD"
+    );
+
+    // Differential check: a from-scratch computation over (HEAD, index,
+    // worktree) must agree with the incrementally-maintained state that just
+    // settled.
+    let old_entity_ids = [
+        unstaged_diff.entity_id(),
+        staged_diff.entity_id(),
+        uncommitted_diff.entity_id(),
+    ];
+    drop(unstaged_diff);
+    drop(staged_diff);
+    drop(uncommitted_diff);
+    // An empty update flushes effects so that the dropped entities are
+    // actually released before we reopen.
+    cx.update(|_| {});
+    cx.run_until_parked();
+
+    let unstaged_diff = project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    let (staged_diff, _) = project
+        .update(cx, |project, cx| {
+            project.open_staged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    let uncommitted_diff = project
+        .update(cx, |project, cx| {
+            project.open_uncommitted_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    assert_ne!(
+        unstaged_diff.entity_id(),
+        old_entity_ids[0],
+        "the unstaged diff was not actually closed"
+    );
+    assert_ne!(
+        staged_diff.entity_id(),
+        old_entity_ids[1],
+        "the staged diff was not actually closed"
+    );
+    assert_ne!(
+        uncommitted_diff.entity_id(),
+        old_entity_ids[2],
+        "the uncommitted diff was not actually closed"
+    );
+
+    assert_eq!(
+        capture_diff_state(&unstaged_diff, cx),
+        old_unstaged_state,
+        "reopened unstaged diff differs from the settled one"
+    );
+    assert_eq!(
+        capture_diff_state(&staged_diff, cx),
+        old_staged_state,
+        "reopened staged diff differs from the settled one"
+    );
+    assert_eq!(
+        capture_diff_state(&uncommitted_diff, cx),
+        old_uncommitted_state,
+        "reopened uncommitted diff differs from the settled one"
+    );
+}
+
 #[gpui::test]
 async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -10975,10 +11778,18 @@ async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestApp
         .await
         .unwrap();
 
-    uncommitted_diff.update(cx, |diff, cx| {
-        let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>();
-        diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
+    let ranges = uncommitted_diff.read_with(cx, |diff, cx| {
+        diff.snapshot(cx)
+            .hunks(&snapshot)
+            .map(|hunk| hunk.buffer_range)
+            .collect::>()
     });
+    project
+        .update(cx, |project, cx| {
+            let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap();
+            project.stage_hunks(buffer.clone(), unstaged_diff, ranges, cx)
+        })
+        .unwrap();
 
     cx.run_until_parked();
 
@@ -12070,6 +12881,188 @@ async fn test_project_group_keys_remain_distinct_for_sibling_repo_subdirectories
     );
 }
 
+fn project_group_key_paths(project: &Entity, cx: &TestAppContext) -> Vec {
+    project.read_with(cx, |project, cx| {
+        ProjectGroupKey::from_project(project, cx)
+            .path_list()
+            .ordered_paths()
+            .cloned()
+            .collect()
+    })
+}
+
+fn project_worktree_paths(
+    project: &Entity,
+    cx: &TestAppContext,
+) -> (Vec, Vec) {
+    project.read_with(cx, |project, cx| {
+        let paths = project.worktree_paths(cx);
+        (
+            paths.folder_path_list().ordered_paths().cloned().collect(),
+            paths
+                .main_worktree_path_list()
+                .ordered_paths()
+                .cloned()
+                .collect(),
+        )
+    })
+}
+
+#[gpui::test]
+async fn test_project_group_keys_match_for_bare_repo_linked_worktrees(
+    executor: gpui::BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor);
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "monty": {
+                ".git": "gitdir: ./.bare\n",
+                ".bare": {
+                    "HEAD": "ref: refs/heads/main\n",
+                    "worktrees": {
+                        "main": {
+                            "commondir": "../..",
+                            "gitdir": "/root/monty/main/.git",
+                        },
+                        "feature-a": {
+                            "commondir": "../..",
+                            "gitdir": "/root/monty/feature-a/.git",
+                        },
+                        "feature-b": {
+                            "commondir": "../..",
+                            "gitdir": "/root/monty/feature-b/.git",
+                        },
+                    },
+                },
+                "main": {
+                    ".git": "gitdir: /root/monty/.bare/worktrees/main\n",
+                    "file.txt": "main",
+                },
+                "feature-a": {
+                    ".git": "gitdir: /root/monty/.bare/worktrees/feature-a\n",
+                    "file.txt": "a",
+                },
+                "feature-b": {
+                    ".git": "gitdir: /root/monty/.bare/worktrees/feature-b\n",
+                    "file.txt": "b",
+                },
+            },
+        }),
+    )
+    .await;
+
+    let project_root = Project::test(fs.clone(), [path!("/root/monty").as_ref()], cx).await;
+    let project_main = Project::test(fs.clone(), [path!("/root/monty/main").as_ref()], cx).await;
+    let project_a = Project::test(fs.clone(), [path!("/root/monty/feature-a").as_ref()], cx).await;
+    let project_b = Project::test(fs, [path!("/root/monty/feature-b").as_ref()], cx).await;
+
+    project_root
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+    project_main
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+    project_a
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+    project_b
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+    cx.run_until_parked();
+
+    for project in [&project_root, &project_main, &project_a, &project_b] {
+        assert_eq!(
+            project_group_key_paths(project, cx),
+            vec![PathBuf::from(path!("/root/monty"))]
+        );
+    }
+
+    assert_eq!(
+        project_worktree_paths(&project_root, cx),
+        (
+            vec![PathBuf::from(path!("/root/monty"))],
+            vec![PathBuf::from(path!("/root/monty"))],
+        )
+    );
+    assert_eq!(
+        project_worktree_paths(&project_main, cx),
+        (
+            vec![PathBuf::from(path!("/root/monty/main"))],
+            vec![PathBuf::from(path!("/root/monty"))],
+        )
+    );
+    assert_eq!(
+        project_worktree_paths(&project_a, cx),
+        (
+            vec![PathBuf::from(path!("/root/monty/feature-a"))],
+            vec![PathBuf::from(path!("/root/monty"))],
+        )
+    );
+    assert_eq!(
+        project_worktree_paths(&project_b, cx),
+        (
+            vec![PathBuf::from(path!("/root/monty/feature-b"))],
+            vec![PathBuf::from(path!("/root/monty"))],
+        )
+    );
+}
+
+#[gpui::test]
+async fn test_project_group_key_groups_nested_linked_worktree_under_main_repo(
+    executor: gpui::BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor);
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "my-repo": {
+                ".git": {},
+                "file.txt": "content",
+            },
+        }),
+    )
+    .await;
+
+    let linked_worktree_path = PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature"));
+    fs.add_linked_worktree_for_repo(
+        Path::new(path!("/root/my-repo/.git")),
+        false,
+        git::repository::Worktree {
+            path: linked_worktree_path.clone(),
+            ref_name: Some("refs/heads/feature".into()),
+            sha: "abc123".into(),
+            is_main: false,
+            is_bare: false,
+        },
+    )
+    .await;
+
+    let project = Project::test(fs, [linked_worktree_path.as_path()], cx).await;
+    project
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+    cx.run_until_parked();
+
+    assert_eq!(
+        project_group_key_paths(&project, cx),
+        vec![PathBuf::from(path!("/root/my-repo"))]
+    );
+    assert_eq!(
+        project_worktree_paths(&project, cx),
+        (
+            vec![PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature"))],
+            vec![PathBuf::from(path!("/root/my-repo"))],
+        )
+    );
+}
+
 #[gpui::test]
 async fn test_repository_subfolder_git_status(
     executor: gpui::BackgroundExecutor,
@@ -14474,6 +15467,44 @@ async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+#[cfg(not(windows))]
+async fn test_os_read_only_files_open_as_read_only(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    cx.executor().allow_parking();
+
+    let root = TempTree::new(json!({
+        "project": {
+            "test.txt": "hello",
+        },
+    }));
+    let file_path = root.path().join("project/test.txt");
+    let mut permissions = std::fs::metadata(&file_path).unwrap().permissions();
+    permissions.set_readonly(true);
+    std::fs::set_permissions(&file_path, permissions).unwrap();
+
+    let project = Project::test(
+        Arc::new(RealFs::new(None, cx.executor())),
+        [root.path()],
+        cx,
+    )
+    .await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(file_path.as_path(), cx)
+        })
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert!(
+            buffer.read_only(),
+            "OS read-only files should open as read-only"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) {
     init_test(cx);
diff --git a/crates/project/tests/integration/search.rs b/crates/project/tests/integration/search.rs
index 79266405084..98cf7dc90ab 100644
--- a/crates/project/tests/integration/search.rs
+++ b/crates/project/tests/integration/search.rs
@@ -1,3 +1,4 @@
+use language::Buffer;
 use project::search::SearchQuery;
 use text::Rope;
 use util::{
@@ -130,6 +131,30 @@ fn test_case_sensitive_pattern_items() {
     );
 }
 
+#[gpui::test]
+async fn test_multiline_regex_crlf(cx: &mut gpui::TestAppContext) {
+    let search_query = SearchQuery::regex(
+        "^hello$\r?\n",
+        false,
+        false,
+        false,
+        false,
+        Default::default(),
+        Default::default(),
+        false,
+        None,
+    )
+    .expect("Should be able to create a regex SearchQuery");
+
+    let text = Rope::from("hello\r\nworld\r\nhello\r\nworld");
+    let snapshot = cx
+        .update(|app| Buffer::build_snapshot(text, None, None, None, app))
+        .await;
+
+    let results = search_query.search(&snapshot, None).await;
+    assert_eq!(results, vec![0..7, 14..21]);
+}
+
 #[gpui::test]
 async fn test_multiline_regex(cx: &mut gpui::TestAppContext) {
     let search_query = SearchQuery::regex(
@@ -145,7 +170,6 @@ async fn test_multiline_regex(cx: &mut gpui::TestAppContext) {
     )
     .expect("Should be able to create a regex SearchQuery");
 
-    use language::Buffer;
     let text = Rope::from("hello\nworld\nhello\nworld");
     let snapshot = cx
         .update(|app| Buffer::build_snapshot(text, None, None, None, app))
diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml
index eac58314f08..89e97ee9c78 100644
--- a/crates/project_panel/Cargo.toml
+++ b/crates/project_panel/Cargo.toml
@@ -51,6 +51,7 @@ telemetry.workspace = true
 notifications.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
+log.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs
index ef1981db60c..e8c4bf2f63d 100644
--- a/crates/project_panel/src/project_panel.rs
+++ b/crates/project_panel/src/project_panel.rs
@@ -24,10 +24,10 @@ use gpui::{
     ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter,
     ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext,
     ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
-    Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle,
-    WeakEntity, Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient,
-    point, px, size, transparent_white, uniform_list,
+    MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point,
+    PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task,
+    UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla,
+    linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list,
 };
 use language::DiagnosticSeverity;
 use markdown_preview::markdown_preview_view::MarkdownPreviewView;
@@ -640,6 +640,16 @@ fn get_item_color(is_sticky: bool, cx: &App) -> ItemColors {
     }
 }
 
+enum DeleteEntryOutcome {
+    /// Entry was successfully trashed, returning the `Change` that can be
+    /// recorded to the undo stack.
+    Trashed(Change),
+    /// Entry was successfully deleted, or there was nothing to delete.
+    Deleted,
+    /// Deleting or trashing the entry failed at the OS level.
+    Failed,
+}
+
 impl ProjectPanel {
     fn new(
         workspace: &mut Workspace,
@@ -2062,6 +2072,32 @@ impl ProjectPanel {
         }))
     }
 
+    async fn resolve_delete_entry(
+        task: Option>>>,
+        worktree_id: WorktreeId,
+        trash: bool,
+    ) -> DeleteEntryOutcome {
+        let Some(task) = task else {
+            // If there's no task to be awaited on, it means that the entry, or
+            // worktree, were likely already deleted.
+            return DeleteEntryOutcome::Deleted;
+        };
+
+        match task.await {
+            Ok(Some(trashed_entry)) if trash => {
+                DeleteEntryOutcome::Trashed(Change::Trashed(worktree_id, trashed_entry))
+            }
+            Err(err) => {
+                log::error!(
+                    "Failed to {} an entry: {err:#}",
+                    if trash { "trash" } else { "delete" }
+                );
+                DeleteEntryOutcome::Failed
+            }
+            Ok(_) => DeleteEntryOutcome::Deleted,
+        }
+    }
+
     fn discard_edit_state(&mut self, window: &mut Window, cx: &mut Context) {
         if let Some(edit_state) = self.state.edit_state.take() {
             self.state.temporarily_unfolded_pending_state = edit_state
@@ -2092,8 +2128,7 @@ impl ProjectPanel {
 
     fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) {
         if cx.stop_active_drag(window) {
-            self.drag_target_entry.take();
-            self.hover_expand_task.take();
+            self.clear_drag_state(cx);
             return;
         }
         self.marked_entries.clear();
@@ -2350,7 +2385,7 @@ impl ProjectPanel {
             let file_name = entry.path.file_name()?.to_string();
 
             let answer = if !action.skip_prompt {
-                let prompt = format!("Discard changes to {}?", file_name);
+                let prompt = format!("Discard changes to {}?", MarkdownInlineCode(&file_name));
                 Some(window.prompt(PromptLevel::Info, &prompt, None, &["Restore", "Cancel"], cx))
             } else {
                 None
@@ -2518,7 +2553,7 @@ impl ProjectPanel {
         cx: &mut Context,
     ) {
         maybe!({
-            let items_to_delete = self.disjoint_effective_entries(cx);
+            let items_to_delete = self.disjoint_effective_entries_excluding_roots(cx);
             if items_to_delete.is_empty() {
                 return None;
             }
@@ -2570,7 +2605,7 @@ impl ProjectPanel {
                                 .iter()
                                 .map(|(_, _, path)| MarkdownInlineCode(path).to_string())
                                 .take(CUTOFF_POINT)
-                                .collect::>();
+                                .collect::>();
                             paths.truncate(CUTOFF_POINT);
                             if truncated_path_counts == 1 {
                                 paths.push(".. 1 file not shown".into());
@@ -2621,29 +2656,37 @@ impl ProjectPanel {
                 }
 
                 let mut changes = Vec::new();
+                let total_count = file_paths.len();
+                let mut failed_count = 0;
 
-                for (entry_id, worktree_id, _) in file_paths {
-                    let trashed_entry = panel
-                        .update(cx, |panel, cx| {
-                            panel
-                                .project
-                                .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
-                                .context("no such entry")
-                        })??
-                        .await?;
+                let tasks = panel.update(cx, |panel, cx| {
+                    panel.project.update(cx, |project, cx| {
+                        file_paths
+                            .into_iter()
+                            .map(|(entry_id, worktree_id, _)| {
+                                (worktree_id, project.delete_entry(entry_id, trash, cx))
+                            })
+                            .collect::>()
+                    })
+                })?;
 
-                    // Keep track of trashed change so that we can then record
-                    // all of the changes at once, such that undoing and redoing
-                    // restores or trashes all files in batch.
-                    if trash && let Some(trashed_entry) = trashed_entry {
-                        changes.push(Change::Trashed(worktree_id, trashed_entry));
+                for (worktree_id, task) in tasks {
+                    match Self::resolve_delete_entry(task, worktree_id, trash).await {
+                        DeleteEntryOutcome::Deleted => {}
+                        DeleteEntryOutcome::Trashed(change) => changes.push(change),
+                        DeleteEntryOutcome::Failed => failed_count += 1,
                     }
                 }
+
                 panel.update_in(cx, |panel, window, cx| {
                     if trash {
                         panel.undo_manager.record(changes).log_err();
                     }
 
+                    if failed_count > 0 {
+                        panel.show_remove_failure_toast(trash, total_count, failed_count, cx);
+                    }
+
                     if let Some(next_selection) = next_selection {
                         panel.update_visible_entries(
                             Some((next_selection.worktree_id, next_selection.entry_id)),
@@ -2663,6 +2706,39 @@ impl ProjectPanel {
         });
     }
 
+    /// Displays a toast notification, informing that the application was not
+    /// able to delete/trash some of the files the user intended to
+    /// delete/trash.
+    fn show_remove_failure_toast(
+        &self,
+        trash: bool,
+        total_count: usize,
+        failed_count: usize,
+        cx: &mut Context,
+    ) {
+        let message = match (trash, total_count) {
+            (true, 1) => format!("Failed to trash {failed_count} of {total_count} file."),
+            (true, _) => format!("Failed to trash {failed_count} of {total_count} files."),
+            (false, 1) => format!("Failed to delete {failed_count} of {total_count} file."),
+            (false, _) => format!("Failed to delete {failed_count} of {total_count} files."),
+        };
+
+        let toast = StatusToast::new(message, cx, |this, _| {
+            this.icon(
+                Icon::new(IconName::XCircle)
+                    .size(IconSize::Small)
+                    .color(Color::Error),
+            )
+            .dismiss_button(true)
+        });
+
+        self.workspace
+            .update(cx, |workspace, cx| {
+                workspace.toggle_status_toast(toast, cx);
+            })
+            .ok();
+    }
+
     fn find_next_selection_after_deletion(
         &self,
         sanitized_entries: BTreeSet,
@@ -3190,7 +3266,7 @@ impl ProjectPanel {
     }
 
     fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) {
-        let entries = self.disjoint_effective_entries(cx);
+        let entries = self.disjoint_effective_entries_excluding_roots(cx);
         if !entries.is_empty() {
             self.write_entries_to_system_clipboard(&entries, cx);
             self.clipboard = Some(ClipboardEntry::Cut(entries));
@@ -3199,7 +3275,7 @@ impl ProjectPanel {
     }
 
     fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) {
-        let entries = self.disjoint_effective_entries(cx);
+        let entries = self.disjoint_effective_entries_excluding_roots(cx);
         if !entries.is_empty() {
             self.write_entries_to_system_clipboard(&entries, cx);
             self.clipboard = Some(ClipboardEntry::Copied(entries));
@@ -3794,25 +3870,6 @@ impl ProjectPanel {
         }
     }
 
-    fn move_entry(
-        &mut self,
-        entry_to_move: ProjectEntryId,
-        destination: ProjectEntryId,
-        destination_is_file: bool,
-        cx: &mut Context,
-    ) -> Option>> {
-        if self
-            .project
-            .read(cx)
-            .entry_is_worktree_root(entry_to_move, cx)
-        {
-            self.move_worktree_root(entry_to_move, destination, cx);
-            None
-        } else {
-            self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
-        }
-    }
-
     fn move_worktree_root(
         &mut self,
         entry_to_move: ProjectEntryId,
@@ -3895,8 +3952,14 @@ impl ProjectPanel {
         self.index_for_entry(selection.entry_id, selection.worktree_id)
     }
 
-    fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet {
-        self.disjoint_entries(self.effective_entries(), cx)
+    fn disjoint_effective_entries_excluding_roots(&self, cx: &App) -> BTreeSet {
+        let project = self.project.read(cx);
+        let entries = self
+            .effective_entries()
+            .into_iter()
+            .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
+            .collect();
+        self.disjoint_entries(entries, cx)
     }
 
     fn disjoint_entries(
@@ -3912,7 +3975,6 @@ impl ProjectPanel {
         let project = self.project.read(cx);
         let entries_by_worktree: HashMap> = entries
             .into_iter()
-            .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
             .fold(HashMap::default(), |mut map, entry| {
                 map.entry(entry.worktree_id).or_default().push(entry);
                 map
@@ -4541,7 +4603,7 @@ impl ProjectPanel {
                             "already exists in the destination folder. ",
                             "Do you want to replace it?"
                         ),
-                        filename
+                        MarkdownInlineCode(filename)
                     );
                     let answer = cx
                         .update(|window, cx| {
@@ -4636,6 +4698,17 @@ impl ProjectPanel {
         }
     }
 
+    fn clear_drag_state(&mut self, cx: &mut Context) {
+        let had_drag_state = self.drag_target_entry.take().is_some()
+            | self.folded_directory_drag_target.take().is_some()
+            | self.hover_scroll_task.take().is_some()
+            | self.hover_expand_task.take().is_some()
+            | self.previous_drag_position.take().is_some();
+        if had_drag_state {
+            cx.notify();
+        }
+    }
+
     fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
         cfg!(target_os = "macos") && modifiers.alt
             || cfg!(not(target_os = "macos")) && modifiers.control
@@ -4658,6 +4731,21 @@ impl ProjectPanel {
             .collect::>();
         let entries = self.disjoint_entries(resolved_selections, cx);
 
+        let root_entries: Vec = {
+            let project = self.project.read(cx);
+            entries
+                .iter()
+                .filter(|entry| project.entry_is_worktree_root(entry.entry_id, cx))
+                .map(|entry| entry.entry_id)
+                .collect()
+        };
+        if !root_entries.is_empty() {
+            for entry_id in root_entries {
+                self.move_worktree_root(entry_id, target_entry_id, cx);
+            }
+            return;
+        }
+
         if Self::is_copy_modifier_set(&window.modifiers()) {
             let _ = maybe!({
                 let project = self.project.read(cx);
@@ -4775,7 +4863,9 @@ impl ProjectPanel {
             // results with folded selections that need refreshing.
             let mut move_tasks: Vec<(ProjectEntryId, Task>)> = Vec::new();
             for entry in entries {
-                if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) {
+                if let Some(task) =
+                    self.move_worktree_entry(entry.entry_id, target_entry_id, is_file, cx)
+                {
                     move_tasks.push((entry.entry_id, task));
                 }
             }
@@ -5679,8 +5769,7 @@ impl ProjectPanel {
                     ))
                     .on_drop(cx.listener(
                         move |this, external_paths: &ExternalPaths, window, cx| {
-                            this.drag_target_entry = None;
-                            this.hover_scroll_task.take();
+                            this.clear_drag_state(cx);
                             this.drop_external_files(external_paths.paths(), entry_id, window, cx);
                             cx.stop_propagation();
                         },
@@ -5809,9 +5898,7 @@ impl ProjectPanel {
                     })
                     .on_drop(cx.listener(
                         move |this, selections: &DraggedSelection, window, cx| {
-                            this.drag_target_entry = None;
-                            this.hover_scroll_task.take();
-                            this.hover_expand_task.take();
+                            this.clear_drag_state(cx);
                             if folded_directory_drag_target.is_some() {
                                 return;
                             }
@@ -5931,6 +6018,16 @@ impl ProjectPanel {
                     }
                 }),
             )
+            .on_aux_click(
+                cx.listener(move |project_panel, event: &gpui::ClickEvent, _, cx| {
+                    if !event.is_middle_click() || show_editor || !kind.is_file() {
+                        return;
+                    }
+
+                    project_panel.open_entry(entry_id, true, false, cx);
+                    cx.stop_propagation();
+                }),
+            )
             .child(
                 ListItem::new(id)
                     .indent_level(depth)
@@ -6204,9 +6301,7 @@ impl ProjectPanel {
                                     ))
                                     .on_drop(cx.listener(
                                         move |this, selections: &DraggedSelection, window, cx| {
-                                            this.hover_scroll_task.take();
-                                            this.drag_target_entry = None;
-                                            this.folded_directory_drag_target = None;
+                                            this.clear_drag_state(cx);
                                             if let Some(target_entry_id) = target_entry_id {
                                                 this.drag_onto(
                                                     selections,
@@ -6303,9 +6398,7 @@ impl ProjectPanel {
                 div.when(drag_and_drop_enabled, |div| {
                     div.on_drop(cx.listener(
                         move |this, selections: &DraggedSelection, window, cx| {
-                            this.hover_scroll_task.take();
-                            this.drag_target_entry = None;
-                            this.folded_directory_drag_target = None;
+                            this.clear_drag_state(cx);
                             if let Some(target_entry_id) = target_entry_id {
                                 this.drag_onto(selections, target_entry_id, is_file, window, cx);
                             }
@@ -6778,7 +6871,7 @@ impl Render for ProjectPanel {
                 this.previous_drag_position = Some(e.event.position);
 
                 if !e.bounds.contains(&e.event.position) {
-                    this.drag_target_entry = None;
+                    this.clear_drag_state(cx);
                     return;
                 }
                 this.hover_scroll_task.take();
@@ -6835,6 +6928,10 @@ impl Render for ProjectPanel {
                 .when(panel_settings.drag_and_drop, |this| {
                     this.on_drag_move(cx.listener(handle_drag_move::))
                         .on_drag_move(cx.listener(handle_drag_move::))
+                        .on_mouse_exit(cx.listener(|this, _: &MouseExitEvent, window, cx| {
+                            cx.stop_active_drag(window);
+                            this.clear_drag_state(cx);
+                        }))
                 })
                 .size_full()
                 .relative()
@@ -6999,7 +7096,8 @@ impl Render for ProjectPanel {
                                     .with_render_fn(
                                         cx.entity(),
                                         move |this, params, _, cx| {
-                                            const LEFT_OFFSET: Pixels = px(14.);
+                                            const LEFT_OFFSET: Pixels =
+                                                ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET;
                                             const PADDING_Y: Pixels = px(4.);
                                             const HITBOX_OVERDRAW: Pixels = px(3.);
 
@@ -7095,7 +7193,8 @@ impl Render for ProjectPanel {
                                         .with_render_fn(
                                             cx.entity(),
                                             move |_, params, _, _| {
-                                                const LEFT_OFFSET: Pixels = px(14.);
+                                                const LEFT_OFFSET: Pixels =
+                                                    ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET;
 
                                                 let indent_size = params.indent_size;
                                                 let item_height = params.item_height;
@@ -7221,8 +7320,7 @@ impl Render for ProjectPanel {
                                 ))
                                 .on_drop(cx.listener(
                                     move |this, external_paths: &ExternalPaths, window, cx| {
-                                        this.drag_target_entry = None;
-                                        this.hover_scroll_task.take();
+                                        this.clear_drag_state(cx);
                                         if let Some(entry_id) = this.state.last_worktree_root_id {
                                             this.drop_external_files(
                                                 external_paths.paths(),
@@ -7236,8 +7334,7 @@ impl Render for ProjectPanel {
                                 ))
                                 .on_drop(cx.listener(
                                     move |this, selections: &DraggedSelection, window, cx| {
-                                        this.drag_target_entry = None;
-                                        this.hover_scroll_task.take();
+                                        this.clear_drag_state(cx);
                                         if let Some(entry_id) = this.state.last_worktree_root_id {
                                             this.drag_onto(selections, entry_id, false, window, cx);
                                         }
@@ -7362,8 +7459,7 @@ impl Render for ProjectPanel {
                         })
                         .on_drop(cx.listener(
                             move |this, external_paths: &ExternalPaths, window, cx| {
-                                this.drag_target_entry = None;
-                                this.hover_scroll_task.take();
+                                this.clear_drag_state(cx);
                                 if let Some(task) = this
                                     .workspace
                                     .update(cx, |workspace, cx| {
diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs
index d1a0f3b209e..efc5a191b1c 100644
--- a/crates/project_panel/src/project_panel_tests.rs
+++ b/crates/project_panel/src/project_panel_tests.rs
@@ -4428,6 +4428,141 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
     }
 }
 
+async fn setup_three_worktree_panel(
+    cx: &mut gpui::TestAppContext,
+) -> (Entity, VisualTestContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root1", json!({ "a.txt": "" })).await;
+    fs.insert_tree("/root2", json!({ "b.txt": "" })).await;
+    fs.insert_tree("/root3", json!({ "c.txt": "" })).await;
+
+    let project = Project::test(
+        fs.clone(),
+        ["/root1".as_ref(), "/root2".as_ref(), "/root3".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 panel = workspace.update_in(&mut cx, ProjectPanel::new);
+    cx.run_until_parked();
+    (panel, cx)
+}
+
+#[gpui::test]
+async fn test_drag_worktree_root_reorders_worktrees(cx: &mut gpui::TestAppContext) {
+    let (panel, mut cx) = setup_three_worktree_panel(cx).await;
+    let cx = &mut cx;
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root1",
+            "      a.txt",
+            "v root2",
+            "      b.txt",
+            "v root3",
+            "      c.txt",
+        ],
+        "worktrees should start in insertion order"
+    );
+
+    // [r1, r2, r3] -> [r2, r1, r3].
+    drag_entries_onto(&panel, &["root1"], "root2", false, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root2",
+            "      b.txt",
+            "v root1",
+            "      a.txt",
+            "v root3",
+            "      c.txt",
+        ],
+        "dragging root1 onto root2 should swap their positions"
+    );
+
+    // [r2, r1, r3] -> [r3, r2, r1].
+    drag_entries_onto(&panel, &["root3"], "root2", false, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root3",
+            "      c.txt",
+            "v root2",
+            "      b.txt",
+            "v root1",
+            "      a.txt",
+        ],
+        "dragging the last root onto the first should move it to the front"
+    );
+}
+
+#[gpui::test]
+async fn test_drag_including_worktree_root_only_reorders(cx: &mut gpui::TestAppContext) {
+    let (panel, mut cx) = setup_three_worktree_panel(cx).await;
+    let cx = &mut cx;
+
+    // Drag {root1, root2/b.txt} onto root3's root entry: only the worktree
+    // reorder should happen and b.txt must stay in root2.
+    drag_entries_onto(&panel, &["root1", "root2/b.txt"], "root3", false, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root2",
+            "      b.txt",
+            "v root3",
+            "      c.txt",
+            "v root1",
+            "      a.txt",
+        ],
+        "dropping a mixed selection on a root should only reorder worktrees"
+    );
+
+    // Drag {root2, root3/c.txt} onto root1/a.txt (a non-root entry): the root
+    // still reorders to root1's position and c.txt must stay in root3.
+    drag_entries_onto(&panel, &["root2", "root3/c.txt"], "root1/a.txt", true, cx);
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root3",
+            "      c.txt",
+            "v root1",
+            "      a.txt",
+            "v root2",
+            "      b.txt",
+        ],
+        "dropping a mixed selection on a non-root entry should only reorder worktrees"
+    );
+
+    // With the copy modifier held, a selection containing a root should still
+    // only reorder worktrees and copy nothing.
+    cx.simulate_modifiers_change(gpui::Modifiers {
+        alt: true,
+        control: true,
+        ..Default::default()
+    });
+    drag_entries_onto(&panel, &["root3", "root1/a.txt"], "root2", false, cx);
+    cx.simulate_modifiers_change(Default::default());
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "v root1",
+            "      a.txt",
+            "v root2",
+            "      b.txt",
+            "v root3",
+            "      c.txt",
+        ],
+        "copy-dragging a mixed selection should only reorder worktrees and copy nothing"
+    );
+}
+
 #[gpui::test]
 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);
@@ -10069,6 +10204,48 @@ pub(crate) fn drag_selection_to(
     cx.executor().run_until_parked();
 }
 
+/// Drags the entries at `source_paths` onto `target_path`. Paths are worktree
+/// root names optionally followed by a path inside the worktree, e.g. "root1"
+/// or "root1/dir/file.txt". The first source path is the active selection.
+pub(crate) fn drag_entries_onto(
+    panel: &Entity,
+    source_paths: &[&str],
+    target_path: &str,
+    target_is_file: bool,
+    cx: &mut VisualTestContext,
+) {
+    let target_entry_id = find_project_entry(panel, target_path, cx)
+        .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
+    let selections: Vec = source_paths
+        .iter()
+        .map(|path| {
+            let entry_id = find_project_entry(panel, path, cx)
+                .unwrap_or_else(|| panic!("no entry for source path {path:?}"));
+            let worktree_id = panel
+                .update(cx, |panel, cx| {
+                    panel.project.read(cx).worktree_id_for_entry(entry_id, cx)
+                })
+                .unwrap_or_else(|| panic!("no worktree for source path {path:?}"));
+            SelectedEntry {
+                worktree_id,
+                entry_id,
+            }
+        })
+        .collect();
+    let active_selection = *selections
+        .first()
+        .expect("at least one source path is required");
+
+    panel.update_in(cx, |panel, window, cx| {
+        let drag = DraggedSelection {
+            active_selection,
+            marked_selections: Arc::from(selections),
+        };
+        panel.drag_onto(&drag, target_entry_id, target_is_file, window, cx);
+    });
+    cx.executor().run_until_parked();
+}
+
 pub(crate) fn find_project_entry(
     panel: &Entity,
     path: &str,
@@ -11060,3 +11237,41 @@ async fn test_delete_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestApp
         "Are you sure you want to permanently delete `__somefile__`?"
     );
 }
+
+#[gpui::test]
+async fn test_restore_file_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            ".git": {},
+            "__init__.py": "modified contents",
+        }),
+    )
+    .await;
+    fs.set_head_and_index_for_repo(
+        path!("/root/.git").as_ref(),
+        &[("__init__.py", "original contents".into())],
+    );
+
+    let project = Project::test(fs.clone(), [path!("/root").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 cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root/__init__.py", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.restore_file(&git::RestoreFile { skip_prompt: false }, window, cx)
+    });
+    let (message, _detail) = cx
+        .pending_prompt()
+        .expect("restore should show a confirmation prompt");
+
+    assert_eq!(message, "Discard changes to `__init__.py`?");
+}
diff --git a/crates/project_panel/src/tests/undo.rs b/crates/project_panel/src/tests/undo.rs
index 4315a6ecb4c..1e69c7443ae 100644
--- a/crates/project_panel/src/tests/undo.rs
+++ b/crates/project_panel/src/tests/undo.rs
@@ -382,3 +382,25 @@ async fn trash_undo_redo(cx: &mut gpui::TestAppContext) {
     cx.redo().await;
     cx.assert_fs_state_is(&[]);
 }
+
+#[gpui::test]
+async fn trash_continues_when_one_entry_fails(cx: &mut gpui::TestAppContext) {
+    let mut cx = TestContext::new_with_tree(
+        cx,
+        json!({
+            "0_dir": {},
+            "a.txt": "",
+            "b.txt": "",
+        }),
+    )
+    .await;
+
+    cx.fs
+        .set_remove_dir_error(path("/workspace/0_dir"), "simulated failure".into());
+
+    cx.trash(&["0_dir", "a.txt", "b.txt"]).await;
+    cx.assert_fs_state_is(&["0_dir/"]);
+
+    cx.undo().await;
+    cx.assert_fs_state_is(&["0_dir/", "a.txt", "b.txt"]);
+}
diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml
index da23116e83b..62074471e14 100644
--- a/crates/project_symbols/Cargo.toml
+++ b/crates/project_symbols/Cargo.toml
@@ -19,6 +19,7 @@ fuzzy.workspace = true
 gpui.workspace = true
 ordered-float.workspace = true
 picker.workspace = true
+picker_preview.workspace = true
 project.workspace = true
 serde_json.workspace = true
 settings.workspace = true
diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs
index 5e4422a6fb7..533913a654a 100644
--- a/crates/project_symbols/src/project_symbols.rs
+++ b/crates/project_symbols/src/project_symbols.rs
@@ -5,7 +5,7 @@ use gpui::{
     TextStyle, WeakEntity, Window, relative,
 };
 use ordered_float::OrderedFloat;
-use picker::{Picker, PickerDelegate};
+use picker::{Picker, PickerDelegate, PreviewUpdate};
 use project::{Project, Symbol, lsp_store::SymbolLocation};
 use settings::Settings;
 use std::{cmp::Reverse, sync::Arc};
@@ -25,8 +25,9 @@ pub fn init(cx: &mut App) {
                     let project = workspace.project().clone();
                     let handle = cx.entity().downgrade();
                     workspace.toggle_modal(window, cx, move |window, cx| {
-                        let delegate = ProjectSymbolsDelegate::new(handle, project);
-                        Picker::uniform_list(delegate, window, cx)
+                        let delegate = ProjectSymbolsDelegate::new(handle, project.clone());
+                        let preview = picker_preview::editor_preview(project, window, cx);
+                        Picker::uniform_list_with_preview(delegate, preview, window, cx)
                     })
                 },
             );
@@ -187,6 +188,12 @@ impl PickerDelegate for ProjectSymbolsDelegate {
         self.selected_match_index = ix;
     }
 
+    fn try_get_preview_data_for_match(&self, _cx: &App) -> Option {
+        let candidate_id = self.matches.get(self.selected_match_index)?.candidate_id;
+        let symbol = self.symbols.get(candidate_id)?.clone();
+        Some(PreviewUpdate::from_symbol(symbol))
+    }
+
     fn update_matches(
         &mut self,
         query: String,
diff --git a/crates/proto/proto/call.proto b/crates/proto/proto/call.proto
index aa79014959a..180d1e5659f 100644
--- a/crates/proto/proto/call.proto
+++ b/crates/proto/proto/call.proto
@@ -231,6 +231,7 @@ message UpdateWorktree {
   bool is_last_update = 9;
   string abs_path = 10;
   optional string root_repo_common_dir = 11;
+  bool root_repo_is_linked_worktree = 12;
 }
 
 // deprecated
diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto
index c4c838042c0..1592237cda8 100644
--- a/crates/proto/proto/git.proto
+++ b/crates/proto/proto/git.proto
@@ -25,6 +25,11 @@ message UpdateDiffBases {
     // and the contents of the index and HEAD differ for this path,
     // where None means the path doesn't exist in that state of the repo.
     INDEX_AND_HEAD = 3;
+    // The contents of the index and HEAD are unchanged. Sent so that
+    // remote clients still recalculate their diffs in response to git
+    // events, just as the host does, even when an index write ends up
+    // changing nothing (e.g. writing content identical to what's there).
+    UNCHANGED = 4;
   }
 
   optional string staged_text = 3;
@@ -507,6 +512,7 @@ message BlameBufferResponse {
 message GetDefaultBranch {
   uint64 project_id = 1;
   uint64 repository_id = 2;
+  bool include_remote_name = 3;
 }
 
 message GetDefaultBranchResponse {
diff --git a/crates/proto/proto/worktree.proto b/crates/proto/proto/worktree.proto
index 08a1317f6ac..285b4c9056d 100644
--- a/crates/proto/proto/worktree.proto
+++ b/crates/proto/proto/worktree.proto
@@ -41,6 +41,7 @@ message AddWorktreeResponse {
   uint64 worktree_id = 1;
   string canonicalized_path = 2;
   optional string root_repo_common_dir = 3;
+  bool root_repo_is_linked_worktree = 4;
 }
 
 message RemoveWorktree {
@@ -64,6 +65,7 @@ message WorktreeMetadata {
   bool visible = 3;
   string abs_path = 4;
   optional string root_repo_common_dir = 5;
+  bool root_repo_is_linked_worktree = 6;
 }
 
 message ProjectPath {
diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs
index b998300f065..3fbd2352757 100644
--- a/crates/proto/src/proto.rs
+++ b/crates/proto/src/proto.rs
@@ -924,6 +924,7 @@ pub fn split_worktree_update(mut message: UpdateWorktree) -> impl Iterator process,
             Err(error) => {
-                return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+                return Task::ready(Err(
+                    anyhow::Error::new(error).context("failed to spawn remote server")
+                ));
             }
         };
 
diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs
index 21ebdcbd049..9b8e7763944 100644
--- a/crates/remote/src/transport/wsl.rs
+++ b/crates/remote/src/transport/wsl.rs
@@ -210,7 +210,7 @@ impl WslRemoteConnection {
             let mkdir = self.shell_kind.prepend_command_prefix("mkdir");
             self.run_wsl_command(&mkdir, &["-p", &parent])
                 .await
-                .map_err(|e| anyhow!("Failed to create directory: {}", e))?;
+                .map_err(|e| e.context("Failed to create directory"))?;
         }
 
         let binary_exists_on_server = self
@@ -346,7 +346,7 @@ impl WslRemoteConnection {
 
         self.run_wsl_command("sh", &["-c", &script])
             .await
-            .map_err(|e| anyhow!("Failed to extract server binary: {}", e))?;
+            .map_err(|e| e.context("Failed to extract server binary"))?;
         Ok(())
     }
 }
@@ -395,7 +395,9 @@ impl RemoteConnection for WslRemoteConnection {
             {
                 Ok(process) => process,
                 Err(error) => {
-                    return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+                    return Task::ready(Err(
+                        anyhow::Error::new(error).context("failed to spawn remote server")
+                    ));
                 }
             };
 
diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs
index c4897cd4d05..db9ff6524c2 100644
--- a/crates/remote_server/src/headless_project.rs
+++ b/crates/remote_server/src/headless_project.rs
@@ -254,7 +254,7 @@ impl HeadlessProject {
 
         cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
         language_extension::init(
-            language_extension::LspAccess::ViaLspStore(lsp_store.clone()),
+            language_extension::LspAccess::ViaLspStore(lsp_store.downgrade()),
             proxy.clone(),
             languages.clone(),
         );
@@ -537,6 +537,7 @@ impl HeadlessProject {
                 root_repo_common_dir: worktree
                     .root_repo_common_dir()
                     .map(|p| p.to_string_lossy().into_owned()),
+                root_repo_is_linked_worktree: worktree.root_repo_is_linked_worktree(),
             }
         });
 
@@ -1107,7 +1108,7 @@ impl HeadlessProject {
                 }
             });
 
-            while let Some(buffer) = new_matches.next().await {
+            while let Some((buffer, _)) = new_matches.next().await {
                 let _ = buffer_store
                     .update(cx, |this, cx| {
                         this.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs
index cd70c9c2914..53aec1f5673 100644
--- a/crates/remote_server/src/remote_editing_tests.rs
+++ b/crates/remote_server/src/remote_editing_tests.rs
@@ -2161,13 +2161,17 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m
         .unwrap();
     cx.executor().run_until_parked();
 
-    let common_dir = worktree_main.read_with(cx, |worktree, _| {
-        worktree.snapshot().root_repo_common_dir().cloned()
+    let (common_dir, is_linked_worktree) = worktree_main.read_with(cx, |worktree, _| {
+        (
+            worktree.snapshot().root_repo_common_dir().cloned(),
+            worktree.snapshot().root_repo_is_linked_worktree(),
+        )
     });
     assert_eq!(
         common_dir.as_deref(),
         Some(Path::new("/code/main_repo/.git")),
     );
+    assert!(!is_linked_worktree);
 
     // Linked worktree: root_repo_common_dir should point to the main repo's .git.
     let (worktree_linked, _) = project
@@ -2178,13 +2182,33 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m
         .unwrap();
     cx.executor().run_until_parked();
 
-    let common_dir = worktree_linked.read_with(cx, |worktree, _| {
-        worktree.snapshot().root_repo_common_dir().cloned()
+    let (common_dir, is_linked_worktree) = worktree_linked.read_with(cx, |worktree, _| {
+        (
+            worktree.snapshot().root_repo_common_dir().cloned(),
+            worktree.snapshot().root_repo_is_linked_worktree(),
+        )
     });
     assert_eq!(
         common_dir.as_deref(),
         Some(Path::new("/code/main_repo/.git")),
     );
+    assert!(is_linked_worktree);
+
+    let main_worktree_paths = project.read_with(cx, |project, cx| {
+        project
+            .worktree_paths(cx)
+            .main_worktree_path_list()
+            .ordered_paths()
+            .map(|path| path.to_path_buf())
+            .collect::>()
+    });
+    assert_eq!(
+        main_worktree_paths,
+        vec![
+            PathBuf::from("/code/main_repo"),
+            PathBuf::from("/code/main_repo"),
+        ]
+    );
 
     // No git repo: root_repo_common_dir should be None.
     let (worktree_no_git, _) = project
@@ -2195,10 +2219,14 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m
         .unwrap();
     cx.executor().run_until_parked();
 
-    let common_dir = worktree_no_git.read_with(cx, |worktree, _| {
-        worktree.snapshot().root_repo_common_dir().cloned()
+    let (common_dir, is_linked_worktree) = worktree_no_git.read_with(cx, |worktree, _| {
+        (
+            worktree.snapshot().root_repo_common_dir().cloned(),
+            worktree.snapshot().root_repo_is_linked_worktree(),
+        )
     });
     assert_eq!(common_dir, None);
+    assert!(!is_linked_worktree);
 }
 
 #[gpui::test]
@@ -2772,6 +2800,20 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
     });
 
     assert_eq!(server_branch.name(), "totally-new-branch");
+
+    let default_branch = cx
+        .update(|cx| repository.update(cx, |repository, _cx| repository.default_branch(false)))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(default_branch.as_deref(), Some("main"));
+
+    let default_branch_with_remote = cx
+        .update(|cx| repository.update(cx, |repository, _cx| repository.default_branch(true)))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(default_branch_with_remote.as_deref(), Some("origin/main"));
 }
 
 #[gpui::test]
diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs
index 60908d8a8d4..066e95fc655 100644
--- a/crates/reqwest_client/src/reqwest_client.rs
+++ b/crates/reqwest_client/src/reqwest_client.rs
@@ -156,7 +156,11 @@ impl futures::Stream for StreamReader {
         }
 
         match poll_read_buf(&mut reader, cx, &mut this.buf) {
-            Poll::Pending => Poll::Pending,
+            Poll::Pending => {
+                self.reader = Some(reader);
+
+                Poll::Pending
+            }
             Poll::Ready(Err(err)) => {
                 self.reader = None;
 
@@ -177,7 +181,7 @@ impl futures::Stream for StreamReader {
 
 /// Implementation from 
 /// Specialized for this use case
-pub fn poll_read_buf(
+fn poll_read_buf(
     io: &mut Pin>,
     cx: &mut std::task::Context<'_>,
     buf: &mut BytesMut,
@@ -190,22 +194,22 @@ pub fn poll_read_buf(
         let dst = buf.chunk_mut();
 
         // Safety: `chunk_mut()` returns a `&mut UninitSlice`, and `UninitSlice` is a
-        // transparent wrapper around `[MaybeUninit]`.
+        // transparent wrapper around `[std::mem::MaybeUninit]`.
         let dst = unsafe { &mut *(dst as *mut _ as *mut [std::mem::MaybeUninit]) };
-        let mut buf = tokio::io::ReadBuf::uninit(dst);
-        let ptr = buf.filled().as_ptr();
-        let unfilled_portion = buf.initialize_unfilled();
+        let mut read_buf = tokio::io::ReadBuf::uninit(dst);
+        let unfilled_portion = read_buf.initialize_unfilled();
         // SAFETY: Pin projection
         let io_pin = unsafe { Pin::new_unchecked(io) };
-        std::task::ready!(io_pin.poll_read(cx, unfilled_portion)?);
-
-        // Ensure the pointer does not change from under us
-        assert_eq!(ptr, buf.filled().as_ptr());
-        buf.filled().len()
+        // `futures::AsyncRead` reports the byte count as the poll's return
+        // value; `read_buf.filled()` stays empty because the reader writes
+        // through the initialized slice without advancing the `ReadBuf`.
+        std::task::ready!(io_pin.poll_read(cx, unfilled_portion)?)
     };
 
-    // Safety: This is guaranteed to be the number of initialized (and read)
-    // bytes due to the invariants provided by `ReadBuf::filled`.
+    // Safety: `initialize_unfilled()` zero-initialized the entire spare
+    // capacity, so the first `n` bytes are initialized no matter how many the
+    // reader actually wrote, and `advance_mut` panics rather than exceeding
+    // the capacity if `n` overstates the slice length.
     unsafe {
         buf.advance_mut(n);
     }
@@ -286,10 +290,90 @@ impl http_client::HttpClient for ReqwestClient {
 
 #[cfg(test)]
 mod tests {
-    use http_client::{HttpClient, Url};
+    use std::io::{Read as _, Write as _};
+    use std::net::TcpListener;
+
+    use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, Url};
 
     use crate::ReqwestClient;
 
+    /// Regression test: `StreamReader::poll_next` used to drop the reader it
+    /// `take()`s whenever the reader returned `Poll::Pending`, so the next
+    /// poll reported end-of-stream and streamed request bodies were silently
+    /// truncated. Readers backed by real I/O (e.g. `async_fs::File`) return
+    /// `Pending` on their very first read, so their uploads sent zero bytes.
+    #[test]
+    fn test_streamed_body_survives_pending_reader() {
+        let payload: Vec = (0..30_000usize).map(|byte| (byte % 251) as u8).collect();
+
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let address = listener.local_addr().unwrap();
+        let expected_payload = payload.clone();
+        let server = std::thread::spawn(move || {
+            let (mut stream, _) = listener.accept().unwrap();
+            let mut request = Vec::new();
+            let mut buffer = [0u8; 8192];
+            loop {
+                let read = stream.read(&mut buffer).unwrap();
+                assert_ne!(read, 0, "client closed the connection mid-request");
+                request.extend_from_slice(&buffer[..read]);
+                if let Some(position) = request.windows(4).position(|w| w == b"\r\n\r\n") {
+                    let body_start = position + 4;
+                    while request.len() - body_start < expected_payload.len() {
+                        let read = stream.read(&mut buffer).unwrap();
+                        assert_ne!(read, 0, "client closed the connection mid-body");
+                        request.extend_from_slice(&buffer[..read]);
+                    }
+                    assert_eq!(&request[body_start..], &expected_payload);
+                    break;
+                }
+            }
+            stream
+                .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
+                .unwrap();
+        });
+
+        // A reader that returns `Pending` before every chunk, like a reader
+        // backed by real I/O would.
+        struct PendingFirstReader {
+            data: std::io::Cursor>,
+            ready: bool,
+        }
+
+        impl futures::AsyncRead for PendingFirstReader {
+            fn poll_read(
+                mut self: std::pin::Pin<&mut Self>,
+                cx: &mut std::task::Context<'_>,
+                buf: &mut [u8],
+            ) -> std::task::Poll> {
+                if self.ready {
+                    self.ready = false;
+                    std::task::Poll::Ready(self.data.read(buf))
+                } else {
+                    self.ready = true;
+                    cx.waker().wake_by_ref();
+                    std::task::Poll::Pending
+                }
+            }
+        }
+
+        let reader = PendingFirstReader {
+            data: std::io::Cursor::new(payload.clone()),
+            ready: false,
+        };
+
+        let client = ReqwestClient::new();
+        let request = HttpRequest::builder()
+            .method(Method::PUT)
+            .uri(format!("http://{address}/upload"))
+            .header("Content-Length", payload.len().to_string())
+            .body(AsyncBody::from_reader(reader))
+            .unwrap();
+        let response = futures::executor::block_on(client.send(request)).unwrap();
+        assert!(response.status().is_success());
+        server.join().unwrap();
+    }
+
     #[test]
     fn test_proxy_uri() {
         let client = ReqwestClient::new();
diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml
index c9211dfa99f..58951c16317 100644
--- a/crates/sandbox/Cargo.toml
+++ b/crates/sandbox/Cargo.toml
@@ -58,6 +58,10 @@ libc.workspace = true
 # Safe wrappers for the SCM_RIGHTS fd-passing and `fstat` the bind validator
 # needs, so that code doesn't hand-roll `msghdr`/`CMSG_*`/`mem::zeroed` unsafe.
 nix = { workspace = true, features = ["fs", "socket", "uio"] }
+# Builds the in-sandbox seccomp-BPF filter that blocks the untrusted command from
+# creating `AF_UNIX` (and other non-IP) sockets, `io_uring`, `ptrace`, etc. — the
+# syscall-level half of preventing session-IPC-socket sandbox escapes.
+seccompiler.workspace = true
 
 [target.'cfg(target_os = "linux")'.dev-dependencies]
 tempfile.workspace = true
diff --git a/crates/sandbox/README.md b/crates/sandbox/README.md
index ab23ecad891..d2b24249737 100644
--- a/crates/sandbox/README.md
+++ b/crates/sandbox/README.md
@@ -228,6 +228,47 @@ If the attacker managed to change a path to point to a different inode to when
 the FD was captured, the check will fail, and we don't run the untrusted
 command.
 
+#### Blocking IPC-socket escapes (seccomp)
+
+A read-only bind mount does **not** stop a process from `connect()`-ing to a
+Unix-domain socket: the kernel deliberately exempts sockets (and FIFOs, and
+device nodes) from the read-only-filesystem write check, because connecting
+modifies no filesystem data. So even with `--ro-bind / /`, a sandboxed command
+could reach a session IPC socket in `$XDG_RUNTIME_DIR` (a Wayland compositor,
+the D-Bus session bus, ...) or a system socket like the Docker daemon, and use
+it to run a process *outside* the sandbox — defeating both the filesystem and
+network restrictions, regardless of the read/write grant. `--unshare-net` does
+not help: it isolates abstract sockets and TCP/IP, but these are pathname
+sockets on the bound filesystem.
+
+The fix is a seccomp-BPF filter (built with `seccompiler`) installed on the
+untrusted command just before it runs. Rather than trying to hide every socket,
+it stops the command from *obtaining* one it could escape through:
+
+- `socket()` is allowed only for `AF_INET`/`AF_INET6`/`AF_NETLINK`; every other
+  family — notably `AF_UNIX` (session IPC) and `AF_VSOCK` (the VM host) — is
+  denied with `EPERM`.
+- `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that can't
+  reach anything outside the sandbox).
+- `io_uring_*` is denied, so its ring operations can't create/connect a socket
+  without going through the filtered syscalls; `ptrace`/`process_vm_*` are denied.
+- `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed. With no
+  way to create a forbidden socket — and, by fd hygiene, none inherited — there
+  is nothing dangerous for them to act on, and blocking `connect` would break
+  legitimate loopback/proxy use. `seccompiler`'s architecture check kills
+  foreign-arch syscalls, closing the 32-bit (`socketcall`) bypass.
+
+The filter must apply to the command but **not** to the launcher/bridge process,
+which keeps using `AF_UNIX` to reach the host proxy for every request. So it is
+installed inline right before `exec` in the direct case, and via the child's
+`pre_exec` in the restricted-network bridge case. Because the filter lives in the
+in-sandbox launcher, the launcher is now **always** run (even when there are no
+writable binds to validate and no bridge), so the filter is always installed.
+
+This is Linux/WSL-specific. On macOS, Seatbelt gates Unix-socket `connect` as a
+separate `network-outbound` capability that is denied by default, so the same
+escape is already closed there without a seccomp filter.
+
 ### Windows
 
 > [!NOTE] The Windows implementation depends heavily on the details of the Linux
@@ -241,6 +282,27 @@ To work around this, we launch `zed --wsl-sandbox-helper` in WSL, which is a
 shim that captures the FDs and sets up the socket. We download this to
 `~/.local/libexec/zed`, so that it does not conflict with the Windows `zed.exe`
 binary that WSL will inject into the Linux `$PATH` (yes the `.exe` is stripped).
+
+### MacOS
+
+MacOS uses seatbelt, which enforces a rules file. This generally makes
+enforcement more straightforward. Unlike Linux, paths are resolved and checked
+at *syscall time*, meaning the symlink swap attack will not succeed. 
+
+However, care has to be taken with various parts of the rules file, specifically
+when it comes to `mach-lookup`. This controls access to, among other things,
+Launch Services, which allows unsandboxed code execution. 
+
+The exact policy is defined in `src/macos_seatbelt.rs`, and is inspired by a
+mixture of Codex and Chromium's rules. 
+
+Some of the denied services are somewhat questionable (i.e.
+`com.apple.FontObjectsServer`) - there are legitimate uses for an application to
+use this, but on the other hand, fonts can contain executable code, and have
+historically been exploited to achieve RCE. Given that, in the Zed agent, it is
+easy to opt-out of the sandbox, denying seems like a good choice. But we may
+want to revisit this.
+
 ## Code design
 
 ### `HostFilesystemLocation`
diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs
index f6aa0cb6f29..68ef38659b2 100644
--- a/crates/sandbox/src/bwrap_test_helper.rs
+++ b/crates/sandbox/src/bwrap_test_helper.rs
@@ -46,6 +46,12 @@ mod imp {
     /// successful round-trip, non-zero otherwise. Run *inside* the sandbox.
     const SUBCOMMAND_ECHO_CHECK: &str = "__echo_check";
 
+    /// Internal subcommand: connect to the unix-domain socket at the given path
+    /// and round-trip a byte through it. Exits 0 on a successful round-trip,
+    /// non-zero on any failure (including `socket(AF_UNIX)` being blocked once
+    /// the seccomp guard lands). Run *inside* the sandbox.
+    const SUBCOMMAND_UNIX_CONNECT_CHECK: &str = "__unix_connect_check";
+
     /// Default port for echo targets given as a bare hostname (e.g. `echo1`).
     const DEFAULT_ECHO_PORT: &str = "7000";
 
@@ -57,6 +63,9 @@ mod imp {
         let args: Vec = std::env::args().collect();
         let result = match args.get(1).map(String::as_str) {
             Some(SUBCOMMAND_ECHO_CHECK) => run_echo_check(args.get(2).map(String::as_str)),
+            Some(SUBCOMMAND_UNIX_CONNECT_CHECK) => {
+                run_unix_connect_check(args.get(2).map(String::as_str))
+            }
             _ => run_checks(),
         };
 
@@ -93,8 +102,8 @@ mod imp {
     /// One declarative check: a sandbox policy, an operation, and the expected
     /// result. Deserialized from the JSON the Nix test produces.
     ///
-    /// Exactly one operation field (`read`, `write`, `network`, or `canCreate`)
-    /// must be set. Policy fields default to the most-confined policy
+    /// Exactly one operation field (`read`, `write`, `network`, `socketPath`, or
+    /// `canCreate`) must be set. Policy fields default to the most-confined policy
     /// (restricted filesystem with no writable paths, blocked network).
     #[derive(Debug, Default, Deserialize)]
     #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -127,6 +136,9 @@ mod imp {
         /// the sandbox.
         #[serde(default)]
         network: Option,
+        /// Connect to this unix-domain socket path from inside the sandbox.
+        #[serde(default)]
+        socket_path: Option,
         /// Assert that `Sandbox::can_create` for this policy matches the value:
         /// `true` => the sandbox can be created, `false` => it cannot.
         #[serde(default)]
@@ -239,6 +251,8 @@ mod imp {
             format!("write {path}")
         } else if let Some(host) = &check.network {
             format!("network {host}")
+        } else if let Some(path) = &check.socket_path {
+            format!("socket_connect {path}")
         } else if let Some(expected) = check.can_create {
             format!("can_create == {expected}")
         } else {
@@ -277,6 +291,8 @@ mod imp {
             run_write(check, path)?
         } else if let Some(host) = &check.network {
             run_network(check, host, echo_port)?
+        } else if let Some(path) = &check.socket_path {
+            run_socket_connect(check, path)?
         } else {
             bail!("check {label:?} has no operation");
         };
@@ -354,6 +370,21 @@ mod imp {
         run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target])
     }
 
+    /// Attempt to connect to the unix-domain socket at `path` from inside the
+    /// sandbox via the `__unix_connect_check` subcommand, returning whether the
+    /// round-trip succeeded. A read-only bind mount of `/` leaves the socket
+    /// reachable, so a sandboxed command can currently `connect()` to a session
+    /// IPC socket owned by a process *outside* the sandbox — the escape a
+    /// `socket(AF_UNIX)` seccomp filter is meant to block. When that guard lands,
+    /// `socket(AF_UNIX)` returns `EPERM`, the subcommand fails, and this returns
+    /// `false`.
+    fn run_socket_connect(check: &Check, path: &str) -> Result {
+        let exe = current_exe_str()?;
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
+        run_command(&mut sandbox, &exe, &[SUBCOMMAND_UNIX_CONNECT_CHECK, path])
+    }
+
     fn error_matches(error: &SandboxError, expected: &str) -> bool {
         matches!(
             (error, expected),
@@ -429,6 +460,35 @@ mod imp {
         }
     }
 
+    /// Inner command: connect to the unix-domain socket at `path` and round-trip
+    /// a byte through it.
+    ///
+    /// Any failure — `socket(AF_UNIX)` being denied (how the seccomp guard will
+    /// manifest, as `EPERM`), `connect()` failing, or a bad round-trip — exits
+    /// non-zero, so the caller reads it as "not connected". A clean round-trip
+    /// (exit 0) means the socket outside the sandbox was reachable.
+    fn run_unix_connect_check(path: Option<&str>) -> Result<()> {
+        use std::os::unix::net::UnixStream;
+
+        let path = path.context("unix connect check requires a socket path argument")?;
+        let mut stream = UnixStream::connect(path)
+            .with_context(|| format!("failed to connect to unix socket {path}"))?;
+        stream.set_read_timeout(Some(Duration::from_secs(10)))?;
+        stream
+            .write_all(b"ping\n")
+            .context("failed to write to unix socket")?;
+        let mut buffer = [0u8; 32];
+        let read = stream
+            .read(&mut buffer)
+            .context("failed to read from unix socket")?;
+        let echoed = String::from_utf8_lossy(&buffer[..read]);
+        if echoed.contains("ping") {
+            Ok(())
+        } else {
+            bail!("unix socket returned unexpected data: {echoed:?}");
+        }
+    }
+
     /// Read an HTTP status line (up to the first CRLF), then drain the rest of
     /// the header block (up to the blank line) so the stream is positioned at
     /// the tunneled body.
diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs
index 21a4602523b..373f3520786 100644
--- a/crates/sandbox/src/linux_bubblewrap.rs
+++ b/crates/sandbox/src/linux_bubblewrap.rs
@@ -610,15 +610,20 @@ pub fn wrap_invocation(
 
     // Create the requested writable directories up front, with the agent's
     // ambient permissions, so each can be bind-mounted at its exact path (see
-    // `build_bwrap_args`). Without this a not-yet-existing writable path could
-    // not be bound, and the command could not create it either (its parent is
-    // read-only inside the sandbox). Best-effort: a directory we can't create is
-    // left unbound rather than widening the sandbox to an existing ancestor.
+    // `build_bwrap_args`): `bwrap` can't bind a nonexistent source, and the
+    // command can't create it either (its parent is read-only inside the
+    // sandbox). If a path still doesn't exist afterwards we can't grant the
+    // write access the agent asked for, and running anyway would give the
+    // command silently less access than it believes it has — so fail closed with
+    // a clear error instead. (An existing *file* makes `create_dir_all` error
+    // but is fine: it exists and the `--bind` below handles it.)
     if !permissions.allow_fs_write {
         for directory in writable_dirs {
-            if let Err(error) = std::fs::create_dir_all(directory) {
-                log::warn!(
-                    "[sandbox] could not create writable directory {}: {error}",
+            if let Err(error) = std::fs::create_dir_all(directory)
+                && !directory.exists()
+            {
+                bail!(
+                    "failed to provide writable sandbox path {}: {error}",
                     directory.display()
                 );
             }
@@ -652,41 +657,41 @@ pub fn wrap_invocation(
         NetworkAccess::None | NetworkAccess::All => None,
     };
 
-    // The launcher is only needed when there is something for it to do: validate
-    // writable binds, and/or run the restricted-network bridge. Otherwise the
-    // command runs directly under bwrap.
-    if validation_socket.is_some() || bridge.is_some() {
-        bwrap_args.push(bridge_program.to_string());
-        bwrap_args.push(LAUNCHER_FLAG.to_string());
-        // Field 1: validation socket (in-sandbox path) or sentinel.
-        bwrap_args.push(match validation_socket {
-            Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(),
-            None => LAUNCHER_NONE.to_string(),
-        });
-        // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels.
-        match &bridge {
-            Some((socket, port)) => {
-                bwrap_args.push(socket.to_string_lossy().into_owned());
-                bwrap_args.push(port.to_string());
-            }
-            None => {
-                bwrap_args.push(LAUNCHER_NONE.to_string());
-                bwrap_args.push(LAUNCHER_NONE.to_string());
-            }
+    // Always route through the in-sandbox launcher, even when there are no
+    // writable binds to validate and no restricted-network bridge: the launcher
+    // is where the seccomp filter is installed on the untrusted command (see
+    // `exec_command` / `run_bridge`). Absent fields are passed as the `-`
+    // sentinel, and `run_launcher` then just installs the filter and `exec`s.
+    bwrap_args.push(bridge_program.to_string());
+    bwrap_args.push(LAUNCHER_FLAG.to_string());
+    // Field 1: validation socket (in-sandbox path) or sentinel.
+    bwrap_args.push(match validation_socket {
+        Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(),
+        None => LAUNCHER_NONE.to_string(),
+    });
+    // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels.
+    match &bridge {
+        Some((socket, port)) => {
+            bwrap_args.push(socket.to_string_lossy().into_owned());
+            bwrap_args.push(port.to_string());
         }
-        // Field 4: the writable bind-destination paths to validate (count, then
-        // the paths), in the same order the host sends their fds.
-        let validation_paths: &[&Path] = if validation_socket.is_some() {
-            writable_dirs
-        } else {
-            &[]
-        };
-        bwrap_args.push(validation_paths.len().to_string());
-        for path in validation_paths {
-            bwrap_args.push(path.to_string_lossy().into_owned());
+        None => {
+            bwrap_args.push(LAUNCHER_NONE.to_string());
+            bwrap_args.push(LAUNCHER_NONE.to_string());
         }
-        bwrap_args.push("--".to_string());
     }
+    // Field 4: the writable bind-destination paths to validate (count, then
+    // the paths), in the same order the host sends their fds.
+    let validation_paths: &[&Path] = if validation_socket.is_some() {
+        writable_dirs
+    } else {
+        &[]
+    };
+    bwrap_args.push(validation_paths.len().to_string());
+    for path in validation_paths {
+        bwrap_args.push(path.to_string_lossy().into_owned());
+    }
+    bwrap_args.push("--".to_string());
 
     bwrap_args.push(program.to_string());
     bwrap_args.extend(args.iter().cloned());
@@ -859,9 +864,126 @@ fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> {
     Ok(())
 }
 
+/// Build the seccomp-BPF program installed on the untrusted command before it
+/// runs. This is the syscall-level half of preventing session-IPC-socket
+/// sandbox escapes: a read-only bind mount does not stop `connect()` to a unix
+/// socket, so instead we stop the command from ever *obtaining* a non-IP socket.
+///
+/// The program (default action: allow):
+/// - `socket()` is denied (`EPERM`) unless the family is `AF_INET`/`AF_INET6`/
+///   `AF_NETLINK` — so no `AF_UNIX` (session IPC) or `AF_VSOCK` (VM host) sockets.
+/// - `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that
+///   cannot reach anything outside the sandbox).
+/// - `io_uring_*` is denied, so its ring ops can't create/connect sockets
+///   without going through the filtered syscalls.
+/// - `ptrace`/`process_vm_*` are denied.
+///
+/// `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed: with no
+/// way to create a forbidden socket (and — by fd hygiene — no forbidden fd
+/// inherited), there is nothing dangerous for them to act on, and blocking
+/// `connect` would break legitimate loopback/proxy use. Foreign-architecture
+/// syscalls are killed by seccompiler's arch check, closing the 32-bit-ABI
+/// (`socketcall`) bypass.
+///
+/// Returns `None` on an architecture seccompiler can't target (we ship on
+/// x86_64/aarch64, so that is not a configuration we run in practice).
+fn build_command_seccomp_program() -> Result> {
+    use seccompiler::{
+        BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
+        SeccompRule, TargetArch,
+    };
+    use std::collections::BTreeMap;
+
+    let Ok(target_arch) = TargetArch::try_from(std::env::consts::ARCH) else {
+        return Ok(None);
+    };
+
+    // `socket(domain, ...)`: deny unless `domain` (arg0, an `int` — compare the
+    // low 32 bits) is an allowed IP/netlink family. The rule matches when the
+    // family is none of the allowed ones, and a matched rule takes the deny
+    // action; an allowed family matches no rule and falls through to `Allow`.
+    let socket_deny = SeccompRule::new(vec![
+        SeccompCondition::new(
+            0,
+            SeccompCmpArgLen::Dword,
+            SeccompCmpOp::Ne,
+            libc::AF_INET as u64,
+        )?,
+        SeccompCondition::new(
+            0,
+            SeccompCmpArgLen::Dword,
+            SeccompCmpOp::Ne,
+            libc::AF_INET6 as u64,
+        )?,
+        SeccompCondition::new(
+            0,
+            SeccompCmpArgLen::Dword,
+            SeccompCmpOp::Ne,
+            libc::AF_NETLINK as u64,
+        )?,
+    ])?;
+    // `socketpair(domain, ...)`: allow only `AF_UNIX`.
+    let socketpair_deny = SeccompRule::new(vec![SeccompCondition::new(
+        0,
+        SeccompCmpArgLen::Dword,
+        SeccompCmpOp::Ne,
+        libc::AF_UNIX as u64,
+    )?])?;
+
+    let mut rules: BTreeMap> = BTreeMap::new();
+    rules.insert(libc::SYS_socket, vec![socket_deny]);
+    rules.insert(libc::SYS_socketpair, vec![socketpair_deny]);
+    // Unconditional denials (an empty rule chain always takes the match action).
+    for syscall in [
+        libc::SYS_io_uring_setup,
+        libc::SYS_io_uring_enter,
+        libc::SYS_io_uring_register,
+        libc::SYS_ptrace,
+        libc::SYS_process_vm_readv,
+        libc::SYS_process_vm_writev,
+    ] {
+        rules.insert(syscall, Vec::new());
+    }
+
+    let filter = SeccompFilter::new(
+        rules,
+        SeccompAction::Allow,
+        SeccompAction::Errno(libc::EPERM as u32),
+        target_arch,
+    )
+    .context("building sandbox command seccomp filter")?;
+    let program =
+        BpfProgram::try_from(filter).context("compiling sandbox command seccomp filter")?;
+    Ok(Some(program))
+}
+
+/// Install [`build_command_seccomp_program`] on the calling thread, which is
+/// about to become (or `exec` into) the untrusted command; the filter survives
+/// `exec`. `apply_filter` also sets `PR_SET_NO_NEW_PRIVS`. On an unsupported
+/// architecture there is no filter to install — log and proceed rather than
+/// break the sandbox on a platform we don't ship.
+fn install_command_seccomp_filter() -> Result<()> {
+    match build_command_seccomp_program()? {
+        Some(program) => seccompiler::apply_filter(&program)
+            .context("installing sandbox command seccomp filter")?,
+        None => log::warn!(
+            "[sandbox] seccomp is unavailable on {}; the unix-socket syscall guard \
+             was not installed",
+            std::env::consts::ARCH
+        ),
+    }
+    Ok(())
+}
+
 /// Replace this process with the sandboxed command. Only returns (after logging)
 /// if `exec` itself fails.
 fn exec_command(program: &OsStr, args: &[OsString]) -> ! {
+    // Lock down socket/io_uring/ptrace syscalls right before handing control to
+    // the untrusted command; the filter survives `exec`.
+    if let Err(error) = install_command_seccomp_filter() {
+        eprintln!("zed: failed to install sandbox seccomp filter: {error:#}");
+        std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+    }
     let error = Command::new(program).args(args).exec();
     eprintln!("zed: failed to exec sandboxed command: {error}");
     std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
@@ -889,7 +1011,32 @@ fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[
         std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
     }
 
-    let mut child = match Command::new(program).args(program_args).spawn() {
+    // The command runs under the syscall filter, installed in the child via
+    // `pre_exec` — *this* bridge process must NOT be filtered, since it keeps
+    // using `AF_UNIX` to reach the host proxy for every request the command
+    // makes. Build the program before the fork; the child only applies it.
+    let seccomp_program = match build_command_seccomp_program() {
+        Ok(program) => program,
+        Err(error) => {
+            eprintln!("zed: failed to build sandbox seccomp filter: {error:#}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    };
+    let mut command = Command::new(program);
+    command.args(program_args);
+    // SAFETY: the closure runs in the forked child after `fork` and before
+    // `exec`. It only calls `seccompiler::apply_filter` (a `prctl` on a program
+    // built before the fork) — async-signal-safe and allocation-free.
+    unsafe {
+        command.pre_exec(move || {
+            if let Some(program) = &seccomp_program {
+                seccompiler::apply_filter(program)
+                    .map_err(|error| std::io::Error::other(format!("seccomp: {error}")))?;
+            }
+            Ok(())
+        });
+    }
+    let mut child = match command.spawn() {
         Ok(child) => child,
         Err(error) => {
             eprintln!("zed: failed to spawn sandboxed command: {error}");
@@ -1134,29 +1281,42 @@ fn run_wsl_helper(invocation: WslHelperInvocation) -> ! {
     };
 
     let mut args = invocation.base_args.clone();
+    // Always re-exec ourselves as the in-sandbox launcher, even when there is
+    // nothing to validate: the launcher is where the seccomp filter is installed
+    // on the untrusted command (see `exec_command`). When there are writable
+    // binds, also bind the validation socket so the launcher can verify them.
+    // WSL has no restricted-network bridge, so both bridge fields are the absent
+    // sentinel.
     if let Some(sender) = &validation {
-        // Bind the validation socket in (after the base args' tmpfs and writable
-        // binds so it isn't shadowed), then re-exec ourselves inside the sandbox
-        // as the validator before the real command. WSL has no restricted-network
-        // bridge, so both bridge fields are the absent sentinel.
+        // Bind the validation socket in, after the base args' tmpfs and writable
+        // binds so it isn't shadowed.
         args.push(OsString::from("--bind"));
         args.push(sender.host_socket_path().as_os_str().to_os_string());
         args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
-        args.push(OsString::from("--"));
-        args.push(current_exe.into_os_string());
-        args.push(OsString::from(LAUNCHER_FLAG));
-        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
-        args.push(OsString::from(LAUNCHER_NONE));
-        args.push(OsString::from(LAUNCHER_NONE));
-        args.push(OsString::from(invocation.writable_paths.len().to_string()));
-        for path in &invocation.writable_paths {
-            args.push(path.clone().into_os_string());
-        }
-        args.push(OsString::from("--"));
-    } else {
-        // Nothing to validate — run the command directly under bwrap.
-        args.push(OsString::from("--"));
     }
+    args.push(OsString::from("--"));
+    args.push(current_exe.into_os_string());
+    args.push(OsString::from(LAUNCHER_FLAG));
+    // Field 1: validation socket (in-sandbox path) or sentinel.
+    match &validation {
+        Some(sender) => args.push(sender.sandbox_socket_path().as_os_str().to_os_string()),
+        None => args.push(OsString::from(LAUNCHER_NONE)),
+    }
+    // Fields 2-3: bridge socket + port (WSL has no bridge).
+    args.push(OsString::from(LAUNCHER_NONE));
+    args.push(OsString::from(LAUNCHER_NONE));
+    // Field 4: writable bind-destination paths to validate (count, then paths);
+    // empty when there is nothing to validate.
+    let validation_paths: &[PathBuf] = if validation.is_some() {
+        &invocation.writable_paths
+    } else {
+        &[]
+    };
+    args.push(OsString::from(validation_paths.len().to_string()));
+    for path in validation_paths {
+        args.push(path.clone().into_os_string());
+    }
+    args.push(OsString::from("--"));
     args.push(invocation.program.clone());
     args.extend(invocation.args.iter().cloned());
 
@@ -1664,4 +1824,100 @@ mod tests {
             "unexpected error: {error:#}"
         );
     }
+
+    // The filter compiles to a non-empty BPF program on architectures
+    // seccompiler can target (the ones we ship). On others it's `None`, which is
+    // acceptable — no filter is installed there.
+    #[test]
+    fn test_command_seccomp_program_builds() {
+        let program = build_command_seccomp_program().expect("build seccomp program");
+        if let Some(program) = program {
+            assert!(!program.is_empty(), "seccomp program must not be empty");
+        }
+    }
+
+    // Actually enforce the filter: in a child process, apply it and confirm that
+    // `socket(AF_UNIX)` is denied while `socket(AF_INET)` and
+    // `socketpair(AF_UNIX)` still work — the exact guarantee that closes the
+    // unix-socket sandbox escape.
+    #[test]
+    fn test_command_seccomp_filter_blocks_unix_but_allows_ip() {
+        // Build in the parent (this allocates); the child only applies the
+        // prebuilt program and makes raw syscalls.
+        let Some(program) = build_command_seccomp_program().expect("build seccomp program") else {
+            return; // unsupported arch: nothing to enforce
+        };
+
+        // SAFETY: after `fork`, the child calls only async-signal-safe
+        // libc/`prctl` (via `apply_filter` on a program built before the fork)
+        // and `_exit`; it never returns to Rust or allocates.
+        let pid = unsafe { libc::fork() };
+        assert!(pid >= 0, "fork failed");
+        if pid == 0 {
+            if seccompiler::apply_filter(&program).is_err() {
+                unsafe { libc::_exit(10) };
+            }
+            // AF_UNIX socket creation must be denied.
+            if unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) } >= 0 {
+                unsafe { libc::_exit(11) };
+            }
+            // AF_INET socket creation must still work.
+            let inet = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) };
+            if inet < 0 {
+                unsafe { libc::_exit(12) };
+            }
+            // AF_UNIX socketpair (process-local) must still work.
+            let mut fds = [0i32; 2];
+            if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) }
+                != 0
+            {
+                unsafe { libc::_exit(13) };
+            }
+            unsafe { libc::_exit(0) };
+        }
+
+        let mut status = 0i32;
+        let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
+        assert_eq!(waited, pid, "waitpid failed");
+        assert!(
+            libc::WIFEXITED(status),
+            "child did not exit normally: {status:#x}"
+        );
+        let code = libc::WEXITSTATUS(status);
+        assert_eq!(
+            code, 0,
+            "child reported a seccomp mismatch (exit {code}): 10=apply failed, \
+             11=AF_UNIX allowed, 12=AF_INET blocked, 13=socketpair blocked"
+        );
+    }
+
+    // A requested writable path that can't be created (here, under an existing
+    // file, so `create_dir_all` errors and the path never exists) must fail the
+    // whole invocation — not run the command with silently less write access
+    // than the agent asked for. This check runs before `resolve_bwrap`, so the
+    // test needs no real `bwrap`.
+    #[test]
+    fn test_wrap_invocation_fails_when_writable_path_cannot_be_provided() {
+        let file = tempfile::NamedTempFile::new().unwrap();
+        let unbindable = file.path().join("subdir");
+        let result = wrap_invocation(
+            "/proc/self/exe",
+            SandboxPermissions {
+                network: NetworkAccess::None,
+                allow_fs_write: false,
+            },
+            &[unbindable.as_path()],
+            &[],
+            None,
+            "/bin/true",
+            &[],
+            None,
+            None,
+        );
+        let error = result.expect_err("must fail closed when a writable path can't be provided");
+        assert!(
+            error.to_string().contains("writable sandbox path"),
+            "unexpected error: {error:#}"
+        );
+    }
 }
diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs
index 17a2db9928e..a62803a84f5 100644
--- a/crates/sandbox/src/macos_seatbelt.rs
+++ b/crates/sandbox/src/macos_seatbelt.rs
@@ -243,8 +243,44 @@ fn generate_seatbelt_config(
 ; Allow sysctl reads (needed for many system calls)
 (allow sysctl-read)
 
-; Allow mach lookups (needed for IPC)
-(allow mach-lookup)
+; Mach service lookups. This is an ALLOWLIST, not a blanket `(allow mach-lookup)`.
+; An unrestricted mach-lookup lets a sandboxed command reach LaunchServices /
+; launchd and have a process spawned *outside* the sandbox (e.g. `open -a
+; Terminal`, or opening a crafted `.app`) — the launched process does not inherit
+; this profile, so that is a full sandbox escape. So we allow only the services
+; ordinary dev tooling needs and deliberately EXCLUDE the LaunchServices/launchd
+; endpoints (closing that escape), the pasteboard (silent clipboard theft), and
+; audio (mic/privacy). Curated from Codex's and Chromium's Seatbelt policies; add
+; an entry here (with a comment) if a legitimate toolchain needs another service.
+; A non-existent name is simply never matched, so erring toward including
+; plausible infrastructure services is safe.
+(allow mach-lookup
+    ; identity: user & group resolution (getpwuid, id, whoami, perm checks)
+    (global-name "com.apple.system.opendirectoryd.libinfo")
+    (global-name "com.apple.system.opendirectoryd.membership")
+    (global-name "com.apple.system.DirectoryService.libinfo_v1")
+    ; per-user temp/cache dir resolution ($TMPDIR, /var/folders/...)
+    (global-name "com.apple.bsd.dirhelper")
+    ; CFPreferences (pervasive in Apple frameworks linked by dev tools).
+    ; Chromium denies cfprefsd.daemon to force in-process prefs; we follow Codex
+    ; and allow it since dev commands legitimately use many prefs domains — it's
+    ; a prefs read/write, not an escape.
+    (global-name "com.apple.cfprefsd.daemon")
+    (global-name "com.apple.cfprefsd.agent")
+    (local-name "com.apple.cfprefsd.agent")
+    ; logging / diagnostics (os_log, ASL, Darwin notifications)
+    (global-name "com.apple.logd")
+    (global-name "com.apple.logd.events")
+    (global-name "com.apple.system.logger")
+    (global-name "com.apple.diagnosticd")
+    (global-name "com.apple.system.notification_center")
+    ; Apple telemetry (data goes to Apple only; harmless, avoids init latency)
+    (global-name "com.apple.analyticsd")
+    (global-name "com.apple.analyticsd.messagetracer")
+    ; power assertions (caffeinate / prevent idle sleep during long builds)
+    (global-name "com.apple.PowerManagement.control")
+    ; developer-tools automation-mode flag (our workload is dev tooling)
+    (global-name "com.apple.dt.automationmode.reader"))
 
 ; Allow pseudo-terminal operations
 (allow pseudo-tty)
@@ -345,6 +381,33 @@ fn generate_seatbelt_config(
         }
     }
 
+    // When outbound network is permitted at all, tools that do their own DNS
+    // resolution, TLS trust evaluation, and network-configuration lookups need a
+    // few more Mach services. Kept out of the base allowlist so a no-network
+    // command can't reach them. Still an allowlist (mirrors Codex's Seatbelt
+    // network policy) that excludes LaunchServices/launchd.
+    if !matches!(permissions.network, NetworkAccess::None) {
+        config.push_str(
+            r#"
+; Extra Mach services for DNS / TLS-trust / network configuration, needed only
+; when outbound network is permitted. Still an allowlist that excludes
+; LaunchServices/launchd. If hostname resolution fails, add
+; `com.apple.mDNSResponder`; if offline code-signature verification of loaded
+; dylibs/plugins fails, move the trust services into the base block above.
+(allow mach-lookup
+    ; network / DNS configuration
+    (global-name "com.apple.SystemConfiguration.configd")
+    (global-name "com.apple.SystemConfiguration.DNSConfiguration")
+    (global-name "com.apple.networkd")
+    ; TLS certificate trust / keychain / revocation
+    (global-name "com.apple.SecurityServer")
+    (global-name "com.apple.trustd")
+    (global-name "com.apple.trustd.agent")
+    (global-name "com.apple.ocspd"))
+"#,
+        );
+    }
+
     if !allowed_unix_socket_paths.is_empty() {
         config.push_str(
             r#"
@@ -396,6 +459,19 @@ mod tests {
     use super::*;
     use std::path::PathBuf;
 
+    /// Strip SBPL comment lines (`;`-prefixed) from a generated Seatbelt profile
+    /// so assertions match on the actual rules rather than on documentation.
+    /// Several comments legitimately mention rule syntax (for example the
+    /// blanket `(allow mach-lookup)` form they explain we avoid), which would
+    /// otherwise cause a naive substring check to spuriously match.
+    fn seatbelt_rules_only(config: &str) -> String {
+        config
+            .lines()
+            .filter(|line| !line.trim_start().starts_with(';'))
+            .collect::>()
+            .join("\n")
+    }
+
     #[test]
     fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() {
         let dir = PathBuf::from("/Users/test/projects/myproject");
@@ -443,6 +519,57 @@ mod tests {
         assert!(config.contains("^/dev/ttys[0-9]+"));
     }
 
+    #[test]
+    fn test_generate_seatbelt_config_scopes_mach_lookup_and_excludes_escape_services() {
+        let dir = PathBuf::from("/Users/test/projects/myproject");
+        let config =
+            generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default())
+                .unwrap();
+        // Assert on the rules only: the mach-lookup comment intentionally spells
+        // out the blanket `(allow mach-lookup)` form it avoids, which a raw
+        // `config.contains` would match.
+        let rules = seatbelt_rules_only(&config);
+
+        // A scoped allowlist, never the blanket form — a blanket `(allow
+        // mach-lookup)` would let a command reach LaunchServices/launchd and
+        // escape the sandbox via `open`.
+        assert!(rules.contains("(allow mach-lookup"));
+        assert!(!rules.contains("(allow mach-lookup)"));
+        assert!(rules.contains("com.apple.cfprefsd.daemon"));
+
+        // The escape/abuse endpoints must fall through to `(deny default)`.
+        assert!(!rules.contains("launchservicesd"));
+        assert!(!rules.contains("com.apple.lsd"));
+        assert!(!rules.contains("com.apple.pasteboard"));
+
+        // Network-only services must not be granted without network.
+        assert!(!rules.contains("com.apple.SecurityServer"));
+        assert!(!rules.contains("com.apple.SystemConfiguration.configd"));
+    }
+
+    #[test]
+    fn test_generate_seatbelt_config_adds_network_mach_services_when_network_allowed() {
+        let dir = PathBuf::from("/Users/test/projects/myproject");
+        let config = generate_seatbelt_config(
+            &[dir.as_path()],
+            &[],
+            &[],
+            SandboxPermissions {
+                network: NetworkAccess::All,
+                allow_fs_write: false,
+            },
+        )
+        .unwrap();
+
+        // DNS / TLS / network-config services appear only when network is allowed.
+        assert!(config.contains("com.apple.SystemConfiguration.configd"));
+        assert!(config.contains("com.apple.SecurityServer"));
+        assert!(config.contains("com.apple.trustd"));
+        assert!(config.contains("com.apple.trustd.agent"));
+        // ...but never the escape endpoints.
+        assert!(!config.contains("launchservicesd"));
+    }
+
     #[test]
     fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() {
         let dir = PathBuf::from("/Users/test/projects/myproject");
diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs
index 067a4c50c1a..466ced18c05 100644
--- a/crates/sandbox/src/sandbox.rs
+++ b/crates/sandbox/src/sandbox.rs
@@ -1487,6 +1487,184 @@ mod tests {
     }
 }
 
+/// A directory that is about to be granted as a sandbox write path but may not
+/// exist yet. Preparing one resolves the platform difference in how a
+/// not-yet-existing write grant is materialized, while keeping the same security
+/// property: the caller shows [`Self::canonical_path`] to the user and records
+/// *that* as the grant, so approval is always against the real, symlink-resolved
+/// target.
+///
+/// - **Linux/WSL**: bubblewrap can only bind an existing inode, so the missing
+///   directory (and any missing parents) is created **eagerly**, per component,
+///   and the leaf's inode is pinned to read back its canonical path. If the
+///   grant is denied, [`Self::discard`] removes exactly the directories that were
+///   created, deepest-first, following no symlinks (`rmdir` only removes empty
+///   dirs and never traverses a swapped-in symlink).
+/// - **macOS**: Seatbelt resolves paths at syscall time and can grant a missing
+///   path, so nothing is created here; the directory is materialized only after
+///   approval via [`Self::finalize`].
+///
+/// The eventual bind is still protected by the usual capture-and-revalidate path
+/// (`HostFilesystemLocation`), which re-pins the inode when the command runs.
+pub struct GrantableWriteDir {
+    canonical_path: PathBuf,
+    /// Directories created eagerly to pin the inode, shallowest-first. Empty on
+    /// platforms that defer creation to [`Self::finalize`].
+    eagerly_created: Vec,
+}
+
+impl GrantableWriteDir {
+    /// Prepare `path` for use as a sandbox write grant. `path` must be absolute.
+    pub fn prepare(path: &Path) -> std::io::Result {
+        #[cfg(target_os = "linux")]
+        {
+            let mut eagerly_created = Vec::new();
+            if let Err(error) = create_missing_dirs(path, &mut eagerly_created) {
+                // Roll back any partial creation so a failure leaves no litter.
+                for dir in eagerly_created.iter().rev() {
+                    let _ = std::fs::remove_dir(dir);
+                }
+                return Err(error);
+            }
+            let canonical_path = pinned_canonical_path(path)?;
+            Ok(Self {
+                canonical_path,
+                eagerly_created,
+            })
+        }
+        #[cfg(target_os = "macos")]
+        {
+            Ok(Self {
+                canonical_path: canonicalize_allowing_missing_leaf(path),
+                eagerly_created: Vec::new(),
+            })
+        }
+        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+        {
+            let _ = path;
+            Err(std::io::Error::new(
+                std::io::ErrorKind::Unsupported,
+                "granting a not-yet-existing write directory is not supported on this platform",
+            ))
+        }
+    }
+
+    /// The canonical, symlink-resolved path to show the user and record as the
+    /// grant.
+    pub fn canonical_path(&self) -> &Path {
+        &self.canonical_path
+    }
+
+    /// Materialize the directory once the grant is approved. A no-op on platforms
+    /// that already created it eagerly.
+    pub fn finalize(&self) -> std::io::Result<()> {
+        #[cfg(target_os = "macos")]
+        {
+            std::fs::create_dir_all(&self.canonical_path)?;
+        }
+        Ok(())
+    }
+
+    /// Remove exactly the directories we created (deepest-first) when the grant
+    /// is denied. Best-effort; `rmdir` leaves non-empty dirs and swapped-in
+    /// symlinks untouched.
+    pub fn discard(self) {
+        for dir in self.eagerly_created.iter().rev() {
+            let _ = std::fs::remove_dir(dir);
+        }
+    }
+}
+
+/// Create each missing component of `path` with `create_dir` (never
+/// `create_dir_all`), recording exactly the directories created so they can be
+/// removed again if the grant is denied. Components that already exist are left
+/// alone.
+#[cfg(target_os = "linux")]
+fn create_missing_dirs(path: &Path, created: &mut Vec) -> std::io::Result<()> {
+    let mut cur = PathBuf::new();
+    for component in path.components() {
+        cur.push(component);
+        match std::fs::create_dir(&cur) {
+            Ok(()) => created.push(cur.clone()),
+            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+            Err(error) => return Err(error),
+        }
+    }
+    Ok(())
+}
+
+/// Open an `O_PATH` handle to `path` and read back the canonical path of the
+/// inode it pins, so the value shown to the user reflects the real target even
+/// when a component is a symlink.
+#[cfg(target_os = "linux")]
+fn pinned_canonical_path(path: &Path) -> std::io::Result {
+    use std::os::fd::AsRawFd as _;
+    use std::os::unix::fs::OpenOptionsExt as _;
+    let handle = std::fs::OpenOptions::new()
+        .read(true)
+        .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
+        .open(path)?;
+    std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
+}
+
+#[cfg(all(test, target_os = "linux"))]
+mod grantable_write_dir_tests {
+    use super::GrantableWriteDir;
+    use std::fs;
+
+    #[test]
+    fn creates_missing_dirs_and_discard_removes_only_those() {
+        let root = tempfile::tempdir().unwrap();
+        let existing = root.path().join("existing");
+        fs::create_dir(&existing).unwrap();
+        let target = existing.join("a").join("b").join("c");
+
+        let prepared = GrantableWriteDir::prepare(&target).unwrap();
+        assert!(target.is_dir());
+        assert_eq!(prepared.canonical_path(), target.canonicalize().unwrap());
+
+        prepared.discard();
+        // Everything we created is gone...
+        assert!(!existing.join("a").exists());
+        // ...but the pre-existing ancestor is untouched.
+        assert!(existing.is_dir());
+    }
+
+    #[test]
+    fn existing_dir_is_left_alone_and_not_removed_on_discard() {
+        let root = tempfile::tempdir().unwrap();
+        let target = root.path().join("already");
+        fs::create_dir(&target).unwrap();
+
+        let prepared = GrantableWriteDir::prepare(&target).unwrap();
+        assert!(target.is_dir());
+        // We created nothing, so discard removes nothing.
+        prepared.discard();
+        assert!(target.is_dir());
+    }
+
+    #[test]
+    fn canonical_path_resolves_a_symlinked_parent() {
+        let root = tempfile::tempdir().unwrap();
+        let real = root.path().join("real");
+        fs::create_dir(&real).unwrap();
+        let link = root.path().join("link");
+        std::os::unix::fs::symlink(&real, &link).unwrap();
+
+        // Granting `link/child` where `link` legitimately points at `real` must
+        // succeed and show the user the *resolved* `real/child`, not fail.
+        let prepared = GrantableWriteDir::prepare(&link.join("child")).unwrap();
+        assert_eq!(
+            prepared.canonical_path(),
+            real.canonicalize().unwrap().join("child")
+        );
+        assert!(real.join("child").is_dir());
+
+        prepared.discard();
+        assert!(!real.join("child").exists());
+    }
+}
+
 /// Canonicalize `path`, resolving symlinks, even when its final component
 /// doesn't exist yet.
 ///
diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs
index 2979a98852b..b5cc8694b12 100644
--- a/crates/sandbox/src/windows_wsl.rs
+++ b/crates/sandbox/src/windows_wsl.rs
@@ -81,7 +81,9 @@ const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
 /// (`~/.local/libexec/zed/`, the conventional spot for executables run
 /// by other programs rather than directly by the user). One managed copy per
 /// channel is kept, tracked by a marker file so an exact channel+version match
-/// is reused rather than re-downloaded.
+/// is reused rather than re-downloaded. The floating `latest` version (dev
+/// builds) is the exception: it always re-downloads so it tracks the newest
+/// nightly rather than pinning to the first copy fetched.
 ///
 /// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on
 /// demand. A missing `curl`/`wget` (or a failed download) is a hard error the
@@ -94,9 +96,11 @@ dest="$HOME/.local/libexec/zed/$channel"
 marker="$dest/.zed-wsl-helper-version"
 want="$channel $version"
 
-# Reuse an exact, already-installed channel+version.
-if [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
-    helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+# Reuse an exact, already-installed channel+version — but never for the floating
+# "latest" tag (dev builds), which must always re-fetch so they track the most
+# recent nightly instead of pinning to whatever was downloaded first.
+if [ "$version" != "latest" ] && [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
+    helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
     if [ -n "$helper" ] && [ -x "$helper" ]; then
         printf 'zed-wsl-helper: %s\n' "$helper"
         exit 0
@@ -125,9 +129,9 @@ fi
 
 mkdir -p "$tmp/unpacked"
 tar -xzf "$tarball" -C "$tmp/unpacked"
-helper_src=$(find "$tmp/unpacked" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+helper_src=$(find "$tmp/unpacked" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
 if [ -z "$helper_src" ]; then
-    echo 'the downloaded zed tarball did not contain a bin/zed binary' >&2
+    echo 'the downloaded zed tarball did not contain a libexec/zed-editor binary' >&2
     exit 1
 fi
 app=$(dirname "$(dirname "$helper_src")")
@@ -144,7 +148,7 @@ mv "$dest.new" "$dest"
 rm -rf "$dest.old"
 printf '%s' "$want" > "$marker"
 
-helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true)
+helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
 if [ -z "$helper" ] || [ ! -x "$helper" ]; then
     echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
     exit 1
diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs
index 5e20f2e7ecf..2c51c97a0eb 100644
--- a/crates/search/src/buffer_search.rs
+++ b/crates/search/src/buffer_search.rs
@@ -14,15 +14,15 @@ use crate::{
 use any_vec::AnyVec;
 use collections::HashMap;
 use editor::{
-    Editor, EditorSettings, MultiBufferOffset, SplittableEditor, ToggleSplitDiff,
+    DiffStyleControls, Editor, EditorSettings, MultiBufferOffset, SplittableEditor,
     actions::{Backtab, FoldAll, Tab, ToggleFoldAll, UnfoldAll},
     scroll::Autoscroll,
 };
 use futures::channel::oneshot;
 use gpui::{
-    Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable,
-    InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle,
-    Styled, Subscription, Task, TaskExt, WeakEntity, Window, div,
+    App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _,
+    IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task,
+    TaskExt, WeakEntity, Window, div,
 };
 use language::{Language, LanguageRegistry};
 use project::{
@@ -30,17 +30,11 @@ use project::{
     search_history::{SearchHistory, SearchHistoryCursor},
 };
 
-use fs::Fs;
-use settings::{DiffViewStyle, SeedQuerySetting, Settings, update_settings_file};
+use settings::{SeedQuerySetting, Settings};
 use std::{any::TypeId, sync::Arc};
-use zed_actions::{
-    OpenSettingsAt, outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath,
-};
+use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath};
 
-use ui::{
-    BASE_REM_SIZE_IN_PX, IconButtonShape, PlatformStyle, TextSize, Tooltip, prelude::*,
-    render_modifiers, utils::SearchInputWidth,
-};
+use ui::{BASE_REM_SIZE_IN_PX, IconButtonShape, Tooltip, prelude::*, utils::SearchInputWidth};
 use util::{ResultExt, paths::PathMatcher};
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
@@ -107,129 +101,12 @@ impl EventEmitter for BufferSearchBar {}
 impl Render for BufferSearchBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx);
-
         let has_splittable_editor = self.splittable_editor.is_some();
-        let split_buttons = if has_splittable_editor {
-            self.splittable_editor
-                .as_ref()
-                .and_then(|weak| weak.upgrade())
-                .map(|splittable_editor| {
-                    let editor_ref = splittable_editor.read(cx);
-                    let diff_view_style = editor_ref.diff_view_style();
-
-                    let is_split_set = diff_view_style == DiffViewStyle::Split;
-                    let is_split_active = editor_ref.is_split();
-                    let min_columns =
-                        EditorSettings::get_global(cx).minimum_split_diff_width as u32;
-
-                    let split_icon = if is_split_set && !is_split_active {
-                        IconName::DiffSplitAuto
-                    } else {
-                        IconName::DiffSplit
-                    };
-
-                    h_flex()
-                        .gap_1()
-                        .child(
-                            IconButton::new("diff-unified", IconName::DiffUnified)
-                                .icon_size(IconSize::Small)
-                                .toggle_state(diff_view_style == DiffViewStyle::Unified)
-                                .tooltip(Tooltip::text("Unified"))
-                                .on_click({
-                                    let splittable_editor = splittable_editor.downgrade();
-                                    move |_, window, cx| {
-                                        update_settings_file(
-                                            ::global(cx),
-                                            cx,
-                                            |settings, _| {
-                                                settings.editor.diff_view_style =
-                                                    Some(DiffViewStyle::Unified);
-                                            },
-                                        );
-                                        if diff_view_style == DiffViewStyle::Split {
-                                            splittable_editor
-                                                .update(cx, |editor, cx| {
-                                                    editor.toggle_split(
-                                                        &ToggleSplitDiff,
-                                                        window,
-                                                        cx,
-                                                    );
-                                                })
-                                                .ok();
-                                        }
-                                    }
-                                }),
-                        )
-                        .child(
-                            IconButton::new("diff-split", split_icon)
-                                .toggle_state(diff_view_style == DiffViewStyle::Split)
-                                .icon_size(IconSize::Small)
-                                .tooltip(Tooltip::element(move |_, cx| {
-                                    let message = if is_split_set && !is_split_active {
-                                        format!("Split when wider than {} columns", min_columns)
-                                            .into()
-                                    } else {
-                                        SharedString::from("Split")
-                                    };
-
-                                    v_flex()
-                                        .child(message)
-                                        .child(
-                                            h_flex()
-                                                .gap_0p5()
-                                                .text_ui_sm(cx)
-                                                .text_color(Color::Muted.color(cx))
-                                                .children(render_modifiers(
-                                                    &gpui::Modifiers::secondary_key(),
-                                                    PlatformStyle::platform(),
-                                                    None,
-                                                    Some(TextSize::Small.rems(cx).into()),
-                                                    false,
-                                                ))
-                                                .child("click to change min width"),
-                                        )
-                                        .into_any()
-                                }))
-                                .on_click({
-                                    let splittable_editor = splittable_editor.downgrade();
-                                    move |_, window, cx| {
-                                        if window.modifiers().secondary() {
-                                            window.dispatch_action(
-                                                OpenSettingsAt {
-                                                    path: "minimum_split_diff_width".to_string(),
-                                                    target: None,
-                                                }
-                                                .boxed_clone(),
-                                                cx,
-                                            );
-                                        } else {
-                                            update_settings_file(
-                                                ::global(cx),
-                                                cx,
-                                                |settings, _| {
-                                                    settings.editor.diff_view_style =
-                                                        Some(DiffViewStyle::Split);
-                                                },
-                                            );
-                                            if diff_view_style == DiffViewStyle::Unified {
-                                                splittable_editor
-                                                    .update(cx, |editor, cx| {
-                                                        editor.toggle_split(
-                                                            &ToggleSplitDiff,
-                                                            window,
-                                                            cx,
-                                                        );
-                                                    })
-                                                    .ok();
-                                            }
-                                        }
-                                    }
-                                }),
-                        )
-                })
-        } else {
-            None
-        };
+        let split_buttons = self
+            .splittable_editor
+            .as_ref()
+            .and_then(|weak| weak.upgrade())
+            .map(DiffStyleControls::new);
 
         let collapse_expand_button = if self.needs_expand_collapse_option(cx) {
             let query_editor_focus = self.query_editor.focus_handle(cx);
@@ -661,7 +538,9 @@ impl ToolbarItemView for BufferSearchBar {
             .and_then(|entity| entity.downcast::().ok())
         {
             self._splittable_editor_subscription =
-                Some(cx.observe(&splittable_editor, |_, _, cx| cx.notify()));
+                Some(cx.observe(&splittable_editor, |_, _, cx| {
+                    cx.notify();
+                }));
             self.splittable_editor = Some(splittable_editor.downgrade());
         }
 
diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs
index 707587658ce..86f4a79f759 100644
--- a/crates/search/src/text_finder.rs
+++ b/crates/search/src/text_finder.rs
@@ -29,11 +29,12 @@ use util::ResultExt as _;
 
 use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch};
 
-actions!(text_finder, [ToProjectSearch,]);
+actions!(text_finder, [ToProjectSearch, Fold, Unfold, ToggleFoldAll]);
 
 pub struct TextFinder {
     picker: Entity>,
     init_modifiers: Option,
+    workspace_id: Option,
     _subscription: Subscription,
 }
 
@@ -158,8 +159,9 @@ impl TextFinder {
             workspace
                 .update_in(cx, |workspace, window, cx| {
                     remove_project_search_tab(project_search_item_id, workspace, window, cx);
+                    let workspace_id = workspace.database_id();
                     workspace.toggle_modal(window, cx, |window, cx| {
-                        Self::new(delegate, None, window, cx)
+                        Self::new(delegate, None, workspace_id, window, cx)
                     });
                 })
                 .ok();
@@ -259,6 +261,24 @@ impl TextFinder {
         self.open_in_split(workspace::SplitDirection::Down, window, cx);
     }
 
+    fn fold(&mut self, _: &Fold, _window: &mut Window, cx: &mut Context) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.set_selected_group_collapsed(true, cx);
+        });
+    }
+
+    fn unfold(&mut self, _: &Unfold, _window: &mut Window, cx: &mut Context) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.set_selected_group_collapsed(false, cx);
+        });
+    }
+
+    fn toggle_fold_all(&mut self, _: &ToggleFoldAll, _window: &mut Window, cx: &mut Context) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.toggle_all_collapsed(cx);
+        });
+    }
+
     fn open_in_split(
         &mut self,
         direction: workspace::SplitDirection,
@@ -362,8 +382,9 @@ impl TextFinder {
             let delegate = delegate_task.await;
             workspace
                 .update_in(cx, |workspace, window, cx| {
+                    let workspace_id = workspace.database_id();
                     workspace.toggle_modal(window, cx, |window, cx| {
-                        Self::new(delegate, seed_query, window, cx)
+                        Self::new(delegate, seed_query, workspace_id, window, cx)
                     });
                 })
                 .ok();
@@ -373,6 +394,7 @@ impl TextFinder {
     fn new(
         delegate: Delegate,
         seed_query: Option,
+        workspace_id: Option,
         window: &mut Window,
         cx: &mut Context,
     ) -> Self {
@@ -400,6 +422,7 @@ impl TextFinder {
         Self {
             picker,
             init_modifiers: window.modifiers().modified().then_some(window.modifiers()),
+            workspace_id,
             _subscription: subscription,
         }
     }
@@ -432,11 +455,7 @@ impl ModalView for TextFinder {
         let query = picker.query(cx);
         if !query.is_empty() {
             let options = picker.delegate.search_options;
-            let workspace_id = self
-                .weak_workspace(cx)
-                .upgrade()
-                .and_then(|workspace| workspace.read(cx).database_id());
-            store_last_search(workspace_id, query, options, cx);
+            store_last_search(self.workspace_id, query, options, cx);
         }
         DismissDecision::Dismiss(true)
     }
@@ -456,7 +475,77 @@ pub struct SearchMatch {
     pub buffer: Entity,
     pub anchor_range: Range,
     pub range: Range,
-    pub relative_range: Range,
-    pub line_text: String,
+    pub match_start_byte_column: u32,
     pub line_number: u32,
 }
+
+#[cfg(test)]
+mod tests {
+    use gpui::{TestAppContext, VisualTestContext};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+    use workspace::MultiWorkspace;
+
+    use super::*;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings = SettingsStore::test(cx);
+            cx.set_global(settings);
+
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+
+            editor::init(cx);
+            crate::init(cx);
+        });
+    }
+
+    /// Dismissal can be initiated from inside a workspace update: workspace-level
+    /// action handlers (e.g. buffer search's `SearchActionsRegistrar`) call
+    /// `Workspace::hide_modal` while the workspace entity is leased, which runs
+    /// `on_before_dismiss` synchronously under that lease. Reading the workspace
+    /// entity there panics with "cannot read workspace::Workspace while it is
+    /// already being updated", so this test dismisses the finder the same way.
+    #[gpui::test]
+    async fn test_dismiss_from_within_workspace_update(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(path!("/dir"), json!({"one.rs": "const ONE: usize = 1;"}))
+            .await;
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let workspace = window
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+        let cx = &mut VisualTestContext::from_window(window.into(), cx);
+
+        // Seed a query: the last-search persistence in `on_before_dismiss` (the
+        // code path that read the workspace entity) only runs when the query is
+        // non-empty, which is the common case in practice since the finder seeds
+        // the previous query on open.
+        let seed_query = SearchSeed {
+            query: "ONE".to_string(),
+            options: None,
+        };
+        workspace
+            .update_in(cx, |_, window, cx| {
+                TextFinder::open(Some(seed_query), window, cx)
+            })
+            .await;
+
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.active_modal::(cx).is_some());
+        });
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            assert!(workspace.hide_modal(window, cx));
+        });
+
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.active_modal::(cx).is_none());
+        });
+    }
+}
diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs
index 0b69e49d044..63068b1a9ed 100644
--- a/crates/search/src/text_finder/delegate.rs
+++ b/crates/search/src/text_finder/delegate.rs
@@ -32,28 +32,28 @@ use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines};
 use file_icons::FileIcons;
 use futures::StreamExt;
 use gpui::{
-    AnyElement, AppContext, AsyncApp, DismissEvent, EntityId, HighlightStyle, StyledText, Task,
-    TextStyle, prelude::*,
+    AnyElement, AppContext, AsyncApp, ClickEvent, DismissEvent, EntityId, HighlightStyle,
+    Modifiers, StyledText, Task, TextStyle, prelude::*,
 };
 use gpui::{Entity, FocusHandle};
 use language::{Buffer, LanguageAwareStyling};
 use picker::{Picker, PickerDelegate};
-use project::{Project, ProjectPath};
+use project::{Project, ProjectPath, Search};
 use project::{SearchResults, search::SearchQuery, search::SearchResult};
 use settings::Settings;
 use smol::future::yield_now;
 use text::Anchor;
 use theme_settings::ThemeSettings;
 use ui::{
-    Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, Tooltip,
-    prelude::*,
+    Disclosure, Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable,
+    Tooltip, prelude::*, text_for_keystroke,
 };
 use util::ResultExt;
 use workspace::SplitDirection;
 use workspace::Workspace;
 use workspace::item::ItemSettings;
 
-use super::SearchMatch;
+use super::{Fold, SearchMatch, Unfold};
 use crate::project_search::{ActiveSettings, ProjectSearch};
 use crate::{ProjectSearchView, SearchOption, SearchOptions};
 
@@ -85,6 +85,7 @@ pub struct Delegate {
     /// column so every row's number right-aligns to the widest one. Recomputed in
     /// [`Delegate::rebuild_entries`].
     pub(crate) max_line_number: u32,
+    pub(crate) collapsed_paths: HashSet,
 }
 
 pub(crate) enum Entry {
@@ -129,27 +130,15 @@ fn multibuffer_ranges_to_search_matches<'a>(
 
         let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start);
         let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end);
-        let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1;
-
-        let text = buffer_snapshot.text();
-        let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
-        let line_end = text[start_offset..]
-            .find('\n')
-            .map(|i| start_offset + i)
-            .unwrap_or(text.len());
-        let line_text = text[line_start..line_end].to_string();
-
-        let relative_start = start_offset - line_start;
-        let relative_end = end_offset - line_start;
+        let point = buffer_snapshot.offset_to_point(start_offset);
 
         Some(SearchMatch {
             path,
             buffer,
             anchor_range: text_range,
             range: start_offset..end_offset,
-            relative_range: relative_start..relative_end,
-            line_text,
-            line_number,
+            match_start_byte_column: point.column,
+            line_number: point.row + 1,
         })
     })
 }
@@ -316,6 +305,7 @@ impl Delegate {
                 in_progress_search,
                 unique_files: HashSet::default(),
                 max_line_number: 0,
+                collapsed_paths: HashSet::default(),
             });
 
             this
@@ -368,7 +358,9 @@ impl Delegate {
                 entries.push(Entry::Header(search_match.path.clone()));
                 last_path = Some(&search_match.path);
             }
-            entries.push(Entry::Match(match_index));
+            if !self.collapsed_paths.contains(&search_match.path) {
+                entries.push(Entry::Match(match_index));
+            }
         }
         self.entries = entries;
         self.max_line_number = self
@@ -394,6 +386,65 @@ impl Delegate {
             .position(|entry| matches!(entry, Entry::Match(_)))
     }
 
+    pub(crate) fn toggle_group_collapsed(&mut self, path: &ProjectPath) {
+        if !self.collapsed_paths.remove(path) {
+            self.collapsed_paths.insert(path.clone());
+        }
+        self.rebuild_entries();
+    }
+
+    pub(crate) fn set_selected_group_collapsed(
+        &mut self,
+        collapsed: bool,
+        cx: &mut Context>,
+    ) {
+        let path = match self.entries.get(self.selected_index) {
+            Some(Entry::Match(match_index)) => self
+                .matches
+                .get(*match_index)
+                .map(|search_match| search_match.path.clone()),
+            Some(Entry::Header(path)) => Some(path.clone()),
+            Some(Entry::Separator) | None => None,
+        };
+        let Some(path) = path else {
+            return;
+        };
+        if collapsed == self.collapsed_paths.contains(&path) {
+            return;
+        }
+
+        self.toggle_group_collapsed(&path);
+
+        if let Some(index) = self.entries.iter().position(|entry| match entry {
+            Entry::Header(header_path) => collapsed && *header_path == path,
+            Entry::Match(match_index) => {
+                !collapsed
+                    && self
+                        .matches
+                        .get(*match_index)
+                        .is_some_and(|search_match| search_match.path == path)
+            }
+            Entry::Separator => false,
+        }) {
+            self.selected_index = index;
+        }
+        cx.notify();
+    }
+
+    pub(crate) fn toggle_all_collapsed(&mut self, cx: &mut Context>) {
+        if self.collapsed_paths.is_empty() {
+            self.collapsed_paths = self
+                .matches
+                .iter()
+                .map(|search_match| search_match.path.clone())
+                .collect();
+        } else {
+            self.collapsed_paths.clear();
+        }
+        self.rebuild_entries();
+        cx.notify();
+    }
+
     fn selected_search_match(&self) -> Option<&SearchMatch> {
         match self.entries.get(self.selected_index)? {
             Entry::Match(match_index) => self.matches.get(*match_index),
@@ -413,7 +464,7 @@ impl Delegate {
         };
         let path = selected_match.path.clone();
         let line_number = selected_match.line_number;
-        let column = selected_match.relative_range.start as u32;
+        let column = selected_match.match_start_byte_column;
         let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
             return;
         };
@@ -531,6 +582,7 @@ const SEARCH_DEBOUNCE_MS: u64 = 100;
 const CLICK_THRESHOLD_MS: u128 = 50;
 const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300;
 const SEARCH_RESULTS_BATCH_SIZE: usize = 256;
+const MAX_MATCH_CONTEXT_BYTES: usize = 512;
 
 impl PickerDelegate for Delegate {
     type ListItem = AnyElement;
@@ -626,7 +678,11 @@ impl PickerDelegate for Delegate {
     }
 
     fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool {
-        matches!(self.entries.get(ix), Some(Entry::Match(_)))
+        match self.entries.get(ix) {
+            Some(Entry::Match(_)) => true,
+            Some(Entry::Header(path)) => self.collapsed_paths.contains(path),
+            Some(Entry::Separator) | None => false,
+        }
     }
 
     fn selected_index(&self) -> usize {
@@ -674,6 +730,7 @@ impl PickerDelegate for Delegate {
             self.matches.clear();
             self.entries.clear();
             self.unique_files.clear();
+            self.collapsed_paths.clear();
             self.selected_index = 0;
             self.active_query = None;
             cx.notify();
@@ -758,7 +815,7 @@ impl PickerDelegate for Delegate {
 
         let path = selected_match.path.clone();
         let line_number = selected_match.line_number;
-        let column = selected_match.relative_range.start as u32;
+        let column = selected_match.match_start_byte_column;
 
         let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
             return;
@@ -841,27 +898,83 @@ impl PickerDelegate for Delegate {
                             .color(Color::Muted)
                             .size(IconSize::Small)
                     });
+                let is_collapsed = self.collapsed_paths.contains(path);
+                let toggle_path = path.clone();
+                let tooltip_focus_handle = self.focus_handle.clone();
 
                 Some(
-                    h_flex()
-                        .w_full()
-                        .min_w_0()
-                        .px(DynamicSpacing::Base06.rems(cx))
-                        .py_1()
-                        .gap_1p5()
-                        .children(file_icon)
+                    div()
+                        .px_1()
                         .child(
                             h_flex()
-                                .gap_1()
-                                .child(Label::new(file_name).size(LabelSize::Small))
-                                .when(!directory.is_empty(), |this| {
-                                    this.child(
-                                        Label::new(directory)
-                                            .size(LabelSize::Small)
-                                            .color(Color::Muted)
-                                            .truncate_start(),
-                                    )
-                                }),
+                                .w_full()
+                                .min_w_0()
+                                .p_1()
+                                .gap_1p5()
+                                .rounded_sm()
+                                .when(selected, |this| {
+                                    this.bg(cx.theme().colors().ghost_element_selected)
+                                })
+                                .child(
+                                    h_flex()
+                                        .gap_1()
+                                        .child(
+                                            Disclosure::new(
+                                                ("text-finder-fold", ix),
+                                                !is_collapsed,
+                                            )
+                                            .tooltip(move |_window, cx| {
+                                                let (label, action): (_, &dyn gpui::Action) =
+                                                    if is_collapsed {
+                                                        ("Unfold", &Unfold)
+                                                    } else {
+                                                        ("Fold", &Fold)
+                                                    };
+                                                Tooltip::with_meta_in(
+                                                    label,
+                                                    Some(action),
+                                                    format!(
+                                                        "{} to toggle all",
+                                                        text_for_keystroke(
+                                                            &Modifiers::alt(),
+                                                            "click",
+                                                            cx
+                                                        )
+                                                    ),
+                                                    &tooltip_focus_handle,
+                                                    cx,
+                                                )
+                                            })
+                                            .on_click(
+                                                cx.listener(
+                                                    move |this, event: &ClickEvent, _window, cx| {
+                                                        if event.modifiers().alt {
+                                                            this.delegate.toggle_all_collapsed(cx);
+                                                        } else {
+                                                            this.delegate.toggle_group_collapsed(
+                                                                &toggle_path,
+                                                            );
+                                                            cx.notify();
+                                                        }
+                                                    },
+                                                ),
+                                            ),
+                                        )
+                                        .children(file_icon),
+                                )
+                                .child(
+                                    h_flex()
+                                        .gap_1()
+                                        .child(Label::new(file_name).size(LabelSize::Small))
+                                        .when(!directory.is_empty(), |this| {
+                                            this.child(
+                                                Label::new(directory)
+                                                    .size(LabelSize::Small)
+                                                    .color(Color::Muted)
+                                                    .truncate_start(),
+                                            )
+                                        }),
+                                ),
                         )
                         .into_any_element(),
                 )
@@ -928,6 +1041,11 @@ async fn stream_results_to_picker(
             .ready_chunks(SEARCH_RESULTS_BATCH_SIZE)
     );
 
+    // Project search enforces its ranges cap per file,
+    // so one minified line slips through uncapped; cap it here.
+    let cap = Search::MAX_SEARCH_RESULT_RANGES;
+    let mut total_matches = 0;
+
     let mut clear_existing = matches!(imported_matches, ImportedMatches::No);
     while let Some(results) = results_stream.next().await {
         if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) {
@@ -940,8 +1058,14 @@ async fn stream_results_to_picker(
         for result in results {
             match result {
                 SearchResult::Buffer { buffer, ranges } => {
-                    let matches = Delegate::process_search_result(&buffer, &ranges, cx);
+                    let remaining = cap.saturating_sub(total_matches + batch_matches.len());
+                    let capped = ranges.len().min(remaining);
+                    let matches = Delegate::process_search_result(&buffer, &ranges[..capped], cx);
                     batch_matches.extend(matches);
+                    if capped < ranges.len() {
+                        limit_reached = true;
+                        break;
+                    }
                 }
                 SearchResult::LimitReached => {
                     limit_reached = true;
@@ -950,6 +1074,8 @@ async fn stream_results_to_picker(
             }
         }
 
+        total_matches += batch_matches.len();
+
         picker
             .update(cx, |picker, cx| {
                 let delegate = &mut picker.delegate;
@@ -958,6 +1084,7 @@ async fn stream_results_to_picker(
                     delegate.matches.clear();
                     delegate.entries.clear();
                     delegate.unique_files.clear();
+                    delegate.collapsed_paths.clear();
                     delegate.selected_index = 0;
                     clear_existing = false;
                 }
@@ -990,6 +1117,29 @@ async fn stream_results_to_picker(
     None
 }
 
+/// Byte range around the match to render: a bounded slice of the matched line so rendering never scales with line length.
+fn matched_line_window(
+    snapshot: &language::BufferSnapshot,
+    match_range: &Range,
+    column: u32,
+) -> Range {
+    let line_start = match_range.start.saturating_sub(column as usize);
+    let row = snapshot.offset_to_point(match_range.start).row;
+    let line_end = snapshot.point_to_offset(text::Point::new(row, snapshot.line_len(row)));
+    let start = snapshot.clip_offset(
+        match_range
+            .start
+            .saturating_sub(MAX_MATCH_CONTEXT_BYTES)
+            .max(line_start),
+        text::Bias::Left,
+    );
+    let end = snapshot.clip_offset(
+        (match_range.end + MAX_MATCH_CONTEXT_BYTES).min(line_end),
+        text::Bias::Right,
+    );
+    start..end
+}
+
 /// Renders the matched source line with syntax highlighting, overlaying the
 /// search match with a highlighted background and bold weight.
 fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText {
@@ -1004,23 +1154,40 @@ fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText {
         line_height: relative(1.),
         ..Default::default()
     };
-    let original_line = &search_match.line_text;
-    let line_text = original_line.trim_start();
-    let trim_offset = original_line.len() - line_text.len();
-
     let search_match_style = HighlightStyle {
         background_color: Some(cx.theme().colors().search_match_background),
         font_weight: Some(gpui::FontWeight::BOLD),
         ..Default::default()
     };
 
-    let line_start_abs = search_match.range.start - search_match.relative_range.start;
-    let visible_start_abs = line_start_abs + trim_offset;
-    let visible_end_abs = line_start_abs + original_line.len();
+    let snapshot = search_match.buffer.read(cx).snapshot();
+
+    // Render a bounded window around the match, not the whole line,
+    // so a minified single-line file stays cheap.
+    let line_start_abs = search_match
+        .range
+        .start
+        .saturating_sub(search_match.match_start_byte_column as usize);
+    let window = matched_line_window(
+        &snapshot,
+        &search_match.range,
+        search_match.match_start_byte_column,
+    );
+    let window_text: String = snapshot.text_for_range(window.clone()).collect();
+
+    // Trim leading indentation only when the window starts at the line start;
+    // a mid-line window already begins on content.
+    let trim_offset = if window.start == line_start_abs {
+        window_text.len() - window_text.trim_start().len()
+    } else {
+        0
+    };
+    let visible_start_abs = window.start + trim_offset;
+    let visible_end_abs = window.end;
+    let line_text = &window_text[trim_offset..];
 
     // Syntax highlights for the visible (trimmed) portion of the line, with
     // ranges relative to the start of the rendered text.
-    let snapshot = search_match.buffer.read(cx).snapshot();
     let syntax_theme = cx.theme().syntax();
     let mut syntax_highlights: Vec<(Range, HighlightStyle)> = Vec::new();
     let mut current_offset = 0;
@@ -1110,23 +1277,11 @@ impl Delegate {
                 worktree_id: f.worktree_id(cx),
                 path: f.path().clone(),
             });
-            let text = buf.text();
-
             let mut matches = Vec::new();
             for anchor_range in ranges {
                 let start_offset: usize = buf.summary_for_anchor(&anchor_range.start);
                 let end_offset: usize = buf.summary_for_anchor(&anchor_range.end);
-                let match_row = buf.offset_to_point(start_offset).row;
-                let line_number = match_row + 1;
-                let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
-                let line_end = text[start_offset..]
-                    .find('\n')
-                    .map(|i| start_offset + i)
-                    .unwrap_or(text.len());
-                let line_text = text[line_start..line_end].to_string();
-
-                let relative_start = start_offset - line_start;
-                let relative_end = end_offset - line_start;
+                let point = buf.offset_to_point(start_offset);
 
                 if let Some(path) = &path {
                     matches.push(SearchMatch {
@@ -1134,9 +1289,8 @@ impl Delegate {
                         buffer: buffer.clone(),
                         anchor_range: anchor_range.clone(),
                         range: start_offset..end_offset,
-                        relative_range: relative_start..relative_end,
-                        line_text,
-                        line_number,
+                        match_start_byte_column: point.column,
+                        line_number: point.row + 1,
                     });
                 }
             }
@@ -1144,3 +1298,137 @@ impl Delegate {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{AppContext, TestAppContext};
+    use project::search::{SearchQuery, SearchResult};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+    use util::paths::PathMatcher;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings = SettingsStore::test(cx);
+            cx.set_global(settings);
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+            editor::init(cx);
+            crate::init(cx);
+        });
+    }
+
+    async fn project_with_file(cx: &mut TestAppContext, contents: String) -> Entity {
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(path!("/dir"), json!({ "sample.js": contents }))
+            .await;
+        Project::test(fs, [path!("/dir").as_ref()], cx).await
+    }
+
+    #[gpui::test]
+    async fn test_finder_caps_matches_on_long_line(cx: &mut TestAppContext) {
+        use workspace::MultiWorkspace;
+
+        init_test(cx);
+
+        let line = "return ".repeat(Search::MAX_SEARCH_RESULT_RANGES * 2);
+        let project = project_with_file(cx, line).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 delegate = window
+            .update(cx, |_mw, window, cx| {
+                workspace.update(cx, |workspace, cx| Delegate::new(workspace, window, cx))
+            })
+            .unwrap()
+            .await;
+        let picker = window
+            .update(cx, |_mw, window, cx| {
+                cx.new(|cx| Picker::list(delegate, window, cx))
+            })
+            .unwrap();
+
+        window
+            .update(cx, |_mw, window, cx| {
+                picker.update(cx, |picker, cx| picker.set_query("return", window, cx))
+            })
+            .unwrap();
+
+        // Search is debounced; advance past the debounce and let results stream in.
+        cx.executor()
+            .advance_clock(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS + 50));
+        cx.run_until_parked();
+
+        picker.read_with(cx, |picker, _| {
+            assert_eq!(
+                picker.delegate.matches.len(),
+                Search::MAX_SEARCH_RESULT_RANGES
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_builds_one_match_per_occurrence(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let line = "return ".repeat(2_000);
+        let expected_matches = line.matches("return").count();
+        let project = project_with_file(cx, line).await;
+
+        let query = SearchQuery::text(
+            "return",
+            false,
+            false,
+            false,
+            PathMatcher::default(),
+            PathMatcher::default(),
+            false,
+            None,
+        )
+        .unwrap();
+
+        let search = project.update(cx, |project, cx| project.search(query, cx));
+        let async_cx = cx.to_async();
+        let mut matches = Vec::new();
+        while let Ok(SearchResult::Buffer { buffer, ranges }) = search.rx.recv().await {
+            matches.extend(Delegate::process_search_result(&buffer, &ranges, &async_cx));
+        }
+
+        assert_eq!(matches.len(), expected_matches);
+        assert!(matches.iter().all(|m| m.line_number == 1));
+        assert!(
+            matches
+                .windows(2)
+                .all(|pair| pair[0].match_start_byte_column < pair[1].match_start_byte_column)
+        );
+    }
+
+    #[gpui::test]
+    fn test_matched_line_window_is_bounded(cx: &mut gpui::TestAppContext) {
+        let long_line = "abcdefghij".repeat(1_000_000);
+        let buffer = cx.new(|cx| language::Buffer::local(long_line, cx));
+        buffer.read_with(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            let match_range = 5_000_000..5_000_003;
+            let column = match_range.start as u32; // single line, so column equals the offset
+            let window = matched_line_window(&snapshot, &match_range, column);
+
+            assert!(window.start <= match_range.start && window.end >= match_range.end);
+            assert!(window.len() <= 2 * MAX_MATCH_CONTEXT_BYTES + match_range.len());
+        });
+
+        let buffer = cx.new(|cx| language::Buffer::local("    let foo = bar;", cx));
+        buffer.read_with(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            let match_range = 8..11; // "foo"
+            let window = matched_line_window(&snapshot, &match_range, match_range.start as u32);
+            assert_eq!(window, 0..snapshot.len());
+        });
+    }
+}
diff --git a/crates/search/src/text_finder/render.rs b/crates/search/src/text_finder/render.rs
index d36587ce46d..1f120f0e32d 100644
--- a/crates/search/src/text_finder/render.rs
+++ b/crates/search/src/text_finder/render.rs
@@ -15,6 +15,9 @@ impl Render for TextFinder {
             .on_action(cx.listener(Self::split_right))
             .on_action(cx.listener(Self::split_up))
             .on_action(cx.listener(Self::split_down))
+            .on_action(cx.listener(Self::fold))
+            .on_action(cx.listener(Self::unfold))
+            .on_action(cx.listener(Self::toggle_fold_all))
             .child(self.picker.clone())
     }
 }
diff --git a/crates/session/src/session.rs b/crates/session/src/session.rs
index 76f2398b382..04109ce1b2f 100644
--- a/crates/session/src/session.rs
+++ b/crates/session/src/session.rs
@@ -70,8 +70,7 @@ impl AppSession {
     pub fn new(session: Session, cx: &Context) -> Self {
         let _subscriptions = vec![cx.on_app_quit(Self::app_will_quit)];
 
-        #[cfg(not(any(test, feature = "test-support")))]
-        let _serialization_task = {
+        let _serialization_task = if cfg!(not(any(test, feature = "test-support"))) {
             let db = KeyValueStore::global(cx);
             cx.spawn(async move |_, cx| {
                 // Disabled in tests: the infinite loop bypasses "parking forbidden" checks,
@@ -92,11 +91,10 @@ impl AppSession {
                     }
                 }
             })
+        } else {
+            Task::ready(())
         };
 
-        #[cfg(any(test, feature = "test-support"))]
-        let _serialization_task = Task::ready(());
-
         Self {
             session,
             _subscriptions,
diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs
index 9f734939d73..72d5606af43 100644
--- a/crates/settings/src/keymap_file.rs
+++ b/crates/settings/src/keymap_file.rs
@@ -893,6 +893,7 @@ impl KeymapFile {
         mut keymap_contents: String,
         tab_size: usize,
         keyboard_mapper: &dyn gpui::PlatformKeyboardMapper,
+        deprecated_aliases: &HashMap<&'static str, &'static str>,
     ) -> Result {
         // When replacing or removing a non-user binding, we may need to write an unbind entry
         // to suppress the original default binding.
@@ -937,9 +938,13 @@ impl KeymapFile {
                 let target_action_value = target
                     .action_value()
                     .context("Failed to generate target action JSON value")?;
-                let Some(binding_location) =
-                    find_binding(&keymap, target, &target_action_value, keyboard_mapper)
-                else {
+                let Some(binding_location) = find_binding(
+                    &keymap,
+                    target,
+                    &target_action_value,
+                    keyboard_mapper,
+                    deprecated_aliases,
+                ) else {
                     anyhow::bail!("Failed to find keybinding to remove");
                 };
                 let is_only_binding = binding_location.is_only_entry_in_section(&keymap);
@@ -973,9 +978,13 @@ impl KeymapFile {
                 .action_value()
                 .context("Failed to generate source action JSON value")?;
 
-            if let Some(binding_location) =
-                find_binding(&keymap, &target, &target_action_value, keyboard_mapper)
-            {
+            if let Some(binding_location) = find_binding(
+                &keymap,
+                &target,
+                &target_action_value,
+                keyboard_mapper,
+                deprecated_aliases,
+            ) {
                 if target.context == source.context {
                     // if we are only changing the keybinding (common case)
                     // not the context, etc. Then just update the binding in place
@@ -1072,7 +1081,7 @@ impl KeymapFile {
             let use_key_equivalents = from.and_then(|from| {
                 let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?;
                 let binding_location =
-                    find_binding(&keymap, &from, &action_value, keyboard_mapper)?;
+                    find_binding(&keymap, &from, &action_value, keyboard_mapper, deprecated_aliases)?;
                 Some(keymap.0[binding_location.index].use_key_equivalents)
             }).unwrap_or(false);
             if use_key_equivalents {
@@ -1122,6 +1131,7 @@ impl KeymapFile {
             target: &KeybindUpdateTarget<'a>,
             target_action_value: &Value,
             keyboard_mapper: &dyn gpui::PlatformKeyboardMapper,
+            deprecated_aliases: &HashMap<&'static str, &'static str>,
         ) -> Option> {
             let target_context_parsed =
                 KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok();
@@ -1139,6 +1149,7 @@ impl KeymapFile {
                     target,
                     target_action_value,
                     keyboard_mapper,
+                    deprecated_aliases,
                     |action| &action.0,
                 ) {
                     return Some(binding_location);
@@ -1151,6 +1162,7 @@ impl KeymapFile {
                     target,
                     target_action_value,
                     keyboard_mapper,
+                    deprecated_aliases,
                     |action| &action.0,
                 ) {
                     return Some(binding_location);
@@ -1166,6 +1178,7 @@ impl KeymapFile {
             target: &KeybindUpdateTarget<'a>,
             target_action_value: &Value,
             keyboard_mapper: &dyn gpui::PlatformKeyboardMapper,
+            deprecated_aliases: &HashMap<&'static str, &'static str>,
             action_value: impl Fn(&T) -> &Value,
         ) -> Option> {
             let entries = entries?;
@@ -1192,7 +1205,11 @@ impl KeymapFile {
                 {
                     continue;
                 }
-                if action_value(action) != target_action_value {
+                if !action_value_matches_target(
+                    action_value(action),
+                    target_action_value,
+                    deprecated_aliases,
+                ) {
                     continue;
                 }
                 return Some(BindingLocation {
@@ -1204,6 +1221,40 @@ impl KeymapFile {
             None
         }
 
+        /// Compares a keymap file entry's action value against the target action
+        /// value. The target is built from a loaded binding's canonical action
+        /// name, while the file entry may still use a deprecated alias.
+        fn action_value_matches_target(
+            action_value: &Value,
+            target_action_value: &Value,
+            deprecated_aliases: &HashMap<&'static str, &'static str>,
+        ) -> bool {
+            if action_value == target_action_value {
+                return true;
+            }
+            let (name, arguments) = match action_value {
+                Value::String(name) => (name.as_str(), None),
+                Value::Array(items) => match items.as_slice() {
+                    [Value::String(name), arguments] => (name.as_str(), Some(arguments)),
+                    _ => return false,
+                },
+                _ => return false,
+            };
+            let Some(&preferred_name) = deprecated_aliases.get(name) else {
+                return false;
+            };
+            match target_action_value {
+                Value::String(target_name) => target_name == preferred_name && arguments.is_none(),
+                Value::Array(items) => match items.as_slice() {
+                    [Value::String(target_name), target_arguments] => {
+                        target_name == preferred_name && arguments == Some(target_arguments)
+                    }
+                    _ => false,
+                },
+                _ => false,
+            }
+        }
+
         #[derive(Copy, Clone)]
         enum BindingKind {
             Binding,
@@ -1525,6 +1576,7 @@ impl Action for ActionSequence {
 
 #[cfg(test)]
 mod tests {
+    use collections::HashMap;
     use gpui::{Action, App, DummyKeyboardMapper, KeybindingKeystroke, Keystroke, Unbind};
     use serde_json::Value;
     use unindent::Unindent;
@@ -1742,11 +1794,23 @@ mod tests {
         operation: KeybindUpdateOperation,
         expected: impl ToString,
     ) {
+        check_keymap_update_with_deprecated_aliases(input, operation, expected, &[]);
+    }
+
+    #[track_caller]
+    fn check_keymap_update_with_deprecated_aliases(
+        input: impl ToString,
+        operation: KeybindUpdateOperation,
+        expected: impl ToString,
+        deprecated_aliases: &[(&'static str, &'static str)],
+    ) {
+        let deprecated_aliases = HashMap::from_iter(deprecated_aliases.iter().copied());
         let result = KeymapFile::update_keybinding(
             operation,
             input.to_string(),
             4,
             &gpui::DummyKeyboardMapper,
+            &deprecated_aliases,
         )
         .expect("Update succeeded");
         pretty_assertions::assert_eq!(expected.to_string(), result);
@@ -2567,6 +2631,135 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_keymap_remove_duplicate_binding() {
+        zlog::init_test();
+
+        // Repro: user created two identical bindings via the keymap editor UI,
+        // resulting in two sections with the same keystrokes and action.
+        // Deleting one of them should remove exactly one section.
+        check_keymap_update(
+            r#"
+            [
+              {
+                "bindings": {
+                  "alt-cmd-shift-c": "workspace::CopyRelativePath"
+                }
+              },
+              {
+                "bindings": {
+                  "alt-cmd-shift-c": "workspace::CopyRelativePath"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+            KeybindUpdateOperation::Remove {
+                target: KeybindUpdateTarget {
+                    context: None,
+                    keystrokes: &parse_keystrokes("alt-cmd-shift-c"),
+                    action_name: "workspace::CopyRelativePath",
+                    action_arguments: None,
+                },
+                target_keybind_source: KeybindSource::User,
+            },
+            r#"
+            [
+              {
+                "bindings": {
+                  "alt-cmd-shift-c": "workspace::CopyRelativePath"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_keymap_update_deprecated_alias_binding() {
+        zlog::init_test();
+
+        // The keymap file entry uses a deprecated alias of the action, while the
+        // update target uses the canonical name (as reported by the loaded binding).
+        let deprecated_aliases = &[("editor::CopyRelativePath", "workspace::CopyRelativePath")];
+
+        check_keymap_update_with_deprecated_aliases(
+            r#"
+            [
+              {
+                "bindings": {
+                  "alt-cmd-shift-c": "editor::CopyRelativePath",
+                  "a": "foo::Bar"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+            KeybindUpdateOperation::Remove {
+                target: KeybindUpdateTarget {
+                    context: None,
+                    keystrokes: &parse_keystrokes("alt-cmd-shift-c"),
+                    action_name: "workspace::CopyRelativePath",
+                    action_arguments: None,
+                },
+                target_keybind_source: KeybindSource::User,
+            },
+            r#"
+            [
+              {
+                "bindings": {
+                  "a": "foo::Bar"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+            deprecated_aliases,
+        );
+
+        // Editing an alias entry should update it in place (rewriting it with the
+        // canonical name) instead of falling back to appending a new binding.
+        check_keymap_update_with_deprecated_aliases(
+            r#"
+            [
+              {
+                "bindings": {
+                  "alt-cmd-shift-c": "editor::CopyRelativePath"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+            KeybindUpdateOperation::Replace {
+                target: KeybindUpdateTarget {
+                    context: None,
+                    keystrokes: &parse_keystrokes("alt-cmd-shift-c"),
+                    action_name: "workspace::CopyRelativePath",
+                    action_arguments: None,
+                },
+                source: KeybindUpdateTarget {
+                    context: None,
+                    keystrokes: &parse_keystrokes("ctrl-alt-c"),
+                    action_name: "workspace::CopyRelativePath",
+                    action_arguments: None,
+                },
+                target_keybind_source: KeybindSource::User,
+            },
+            r#"
+            [
+              {
+                "bindings": {
+                  "ctrl-alt-c": "workspace::CopyRelativePath"
+                }
+              }
+            ]
+            "#
+            .unindent(),
+            deprecated_aliases,
+        );
+    }
+
     #[test]
     fn test_keymap_remove() {
         zlog::init_test();
diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs
index 29d9f91dc6b..ae63dbe75fc 100644
--- a/crates/settings/src/settings_store.rs
+++ b/crates/settings/src/settings_store.rs
@@ -2461,6 +2461,148 @@ mod tests {
             .unindent(),
             cx,
         );
+
+        // formatOnSave: true with formatOnSaveMode: modificationsIfAvailable
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modificationsIfAvailable" }"#
+                .to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "modifications_if_available"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSave: true with formatOnSaveMode: modifications
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications" }"#
+                .to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "modifications"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSave: true with formatOnSaveMode: file
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "file" }"#.to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "on"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSaveMode is ignored when formatOnSave is disabled, as in VS Code
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications" }"#
+                .to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "off"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSaveMode alone does nothing, as formatOnSave defaults to false in VS Code
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSaveMode": "modifications" }"#.to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              }
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSaveMode not set, formatOnSave: true
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": true }"#.to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "on"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+
+        // formatOnSaveMode not set, formatOnSave: false
+        check_vscode_import(
+            &mut store,
+            r#"{
+            }
+            "#
+            .unindent(),
+            r#"{ "editor.formatOnSave": false }"#.to_owned(),
+            r#"{
+              "base_keymap": "VSCode",
+              "minimap": {
+                "show": "always"
+              },
+              "format_on_save": "off"
+            }
+            "#
+            .unindent(),
+            cx,
+        );
     }
 
     #[track_caller]
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
index 75b953e7cbc..0d2f5511430 100644
--- a/crates/settings/src/vscode_import.rs
+++ b/crates/settings/src/vscode_import.rs
@@ -199,7 +199,6 @@ impl VsCodeSettings {
             language_models: None,
             line_indicator_format: None,
             log: None,
-            message_editor: None,
             node: self.node_binary_settings(),
 
             outline_panel: self.outline_panel_settings_content(),
@@ -555,9 +554,15 @@ impl VsCodeSettings {
             extend_comment_on_newline: None,
             extend_list_on_newline: None,
             indent_list_on_tab: None,
-            format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| {
-                if b {
-                    FormatOnSave::On
+            // In VS Code, `editor.formatOnSaveMode` only applies when `editor.formatOnSave` is enabled.
+            format_on_save: self.read_bool("editor.formatOnSave").map(|enabled| {
+                if enabled {
+                    self.read_enum("editor.formatOnSaveMode", |s| match s {
+                        "modificationsIfAvailable" => Some(FormatOnSave::ModificationsIfAvailable),
+                        "modifications" => Some(FormatOnSave::Modifications),
+                        _ => None,
+                    })
+                    .unwrap_or(FormatOnSave::On)
                 } else {
                     FormatOnSave::Off
                 }
@@ -900,6 +905,7 @@ impl VsCodeSettings {
                 .map(FontSize::from),
             font_weight: None,
             keep_selection_on_copy: None,
+            open_links_in_mouse_mode: None,
             line_height: self
                 .read_f32("terminal.integrated.lineHeight")
                 .map(|lh| TerminalLineHeight::Custom(lh)),
diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs
index 0d27b3f9a84..460714c4622 100644
--- a/crates/settings_content/src/agent.rs
+++ b/crates/settings_content/src/agent.rs
@@ -812,6 +812,14 @@ pub struct SandboxPermissionsContent {
     /// to without prompting. Paths written by Zed are absolute.
     /// Default: []
     pub write_paths: Option>,
+
+    /// Whether to warn when a sandbox escalation prompt requests a domain or
+    /// write path that contains potentially confusable Unicode characters
+    /// (homoglyphs, invisible characters, or bidirectional overrides). When
+    /// enabled, such prompts show a warning that must be acknowledged before
+    /// the request can be allowed.
+    /// Default: true
+    pub warn_confusable_unicode: Option,
 }
 
 #[with_fallible_options]
diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs
index 2e5ef23875e..71beec78f7d 100644
--- a/crates/settings_content/src/language.rs
+++ b/crates/settings_content/src/language.rs
@@ -54,10 +54,32 @@ impl merge_from::MergeFrom for AllLanguageSettingsContent {
 
         // A user's global settings override the default global settings and
         // all default language-specific settings.
-        //
         self.defaults.merge_from(&other.defaults);
+        let globally_disabled_servers = other.defaults.language_servers.as_ref().map(|servers| {
+            servers
+                .iter()
+                .filter(|entry| entry.starts_with('!'))
+                .cloned()
+                .collect::>()
+        });
         for language_settings in self.languages.0.values_mut() {
+            let language_server_overrides = language_settings.language_servers.clone();
             language_settings.merge_from(&other.defaults);
+            if let Some(mut language_server_overrides) = language_server_overrides {
+                if let Some(disabled) = &globally_disabled_servers {
+                    let insert_before = language_server_overrides
+                        .iter()
+                        .position(|entry| entry == REST_OF_LANGUAGE_SERVERS)
+                        .unwrap_or(language_server_overrides.len());
+                    for disabled_server in disabled {
+                        if !language_server_overrides.contains(disabled_server) {
+                            language_server_overrides
+                                .insert(insert_before, disabled_server.clone());
+                        }
+                    }
+                }
+                language_settings.language_servers = Some(language_server_overrides);
+            }
         }
 
         // A user's language-specific settings override default language-specific settings.
@@ -390,6 +412,8 @@ pub enum SoftWrap {
     Bounded,
 }
 
+pub const REST_OF_LANGUAGE_SERVERS: &str = "...";
+
 /// The settings for a particular language.
 #[with_fallible_options]
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
@@ -899,12 +923,22 @@ pub struct PrettierSettingsContent {
     strum::VariantArray,
     strum::VariantNames,
 )]
-#[serde(rename_all = "lowercase")]
+#[serde(rename_all = "snake_case")]
 pub enum FormatOnSave {
     /// Files should be formatted on save.
     On,
     /// Files should not be formatted on save.
     Off,
+    /// Only lines with unstaged changes are formatted on save.
+    /// Requires source control and LSP range formatting support.
+    /// If no git diff is available or if the LSP doesn't support
+    /// range formatting, formatting is skipped.
+    Modifications,
+    /// Only lines with unstaged changes are formatted on save.
+    /// If no git diff is available (e.g., when source control is
+    /// unavailable) or if the LSP doesn't support range formatting,
+    /// falls back to formatting the whole file.
+    ModificationsIfAvailable,
 }
 
 /// Controls how line endings are normalized when a buffer is saved.
@@ -1157,7 +1191,7 @@ pub enum IndentGuideBackgroundColoring {
 #[cfg(test)]
 mod test {
 
-    use crate::{ParseStatus, fallible_options};
+    use crate::{ParseStatus, fallible_options, merge_from::MergeFrom};
 
     use super::*;
 
@@ -1228,6 +1262,231 @@ mod test {
         assert!(matches!(result, ParseStatus::Failed { .. }));
     }
 
+    #[test]
+    fn test_language_servers_merge_preserves_per_language_config() {
+        let mut base = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            languages: LanguageToSettingsMap(
+                [(
+                    "TypeScript".into(),
+                    LanguageSettingsContent {
+                        language_servers: Some(vec![
+                            "!typescript-language-server".into(),
+                            "vtsls".into(),
+                            REST_OF_LANGUAGE_SERVERS.into(),
+                        ]),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        let user = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec![
+                    "!tailwindcss-language-server".into(),
+                    "!eslint".into(),
+                    REST_OF_LANGUAGE_SERVERS.into(),
+                ]),
+                ..LanguageSettingsContent::default()
+            },
+            ..AllLanguageSettingsContent::default()
+        };
+
+        base.merge_from(&user);
+
+        let ts_servers = base.languages.0["TypeScript"]
+            .language_servers
+            .as_ref()
+            .unwrap();
+        assert_eq!(
+            ts_servers,
+            &vec![
+                "!typescript-language-server".to_string(),
+                "vtsls".to_string(),
+                "!eslint".to_string(),
+                "!tailwindcss-language-server".to_string(),
+                REST_OF_LANGUAGE_SERVERS.to_string(),
+            ]
+        );
+
+        let default_servers = base.defaults.language_servers.as_ref().unwrap();
+        assert_eq!(
+            default_servers,
+            &vec![
+                "!tailwindcss-language-server".to_string(),
+                "!eslint".to_string(),
+                REST_OF_LANGUAGE_SERVERS.to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn test_language_servers_merge_no_per_language_config_uses_global() {
+        let mut base = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            languages: LanguageToSettingsMap(
+                [(
+                    "Rust".into(),
+                    LanguageSettingsContent {
+                        tab_size: Some(std::num::NonZeroU32::new(4).unwrap()),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        let user = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec!["!eslint".into(), REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            ..AllLanguageSettingsContent::default()
+        };
+
+        base.merge_from(&user);
+
+        let rust_servers = base.languages.0["Rust"].language_servers.as_ref().unwrap();
+        assert_eq!(
+            rust_servers,
+            &vec!["!eslint".to_string(), REST_OF_LANGUAGE_SERVERS.to_string(),]
+        );
+    }
+
+    #[test]
+    fn test_language_servers_merge_user_per_language_overrides() {
+        let mut base = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            languages: LanguageToSettingsMap(
+                [(
+                    "TypeScript".into(),
+                    LanguageSettingsContent {
+                        language_servers: Some(vec![
+                            "!typescript-language-server".into(),
+                            "vtsls".into(),
+                            REST_OF_LANGUAGE_SERVERS.into(),
+                        ]),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        let user = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec!["!eslint".into(), REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            languages: LanguageToSettingsMap(
+                [(
+                    "TypeScript".into(),
+                    LanguageSettingsContent {
+                        language_servers: Some(vec![
+                            "deno".into(),
+                            "!vtsls".into(),
+                            REST_OF_LANGUAGE_SERVERS.into(),
+                        ]),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        base.merge_from(&user);
+
+        let ts_servers = base.languages.0["TypeScript"]
+            .language_servers
+            .as_ref()
+            .unwrap();
+        assert_eq!(
+            ts_servers,
+            &vec![
+                "deno".to_string(),
+                "!vtsls".to_string(),
+                REST_OF_LANGUAGE_SERVERS.to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn test_user_per_language_config_overrides_default_disables() {
+        let mut base = AllLanguageSettingsContent {
+            defaults: LanguageSettingsContent {
+                language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]),
+                ..LanguageSettingsContent::default()
+            },
+            languages: LanguageToSettingsMap(
+                [(
+                    "TypeScript".into(),
+                    LanguageSettingsContent {
+                        language_servers: Some(vec![
+                            "!typescript-language-server".into(),
+                            "vtsls".into(),
+                            REST_OF_LANGUAGE_SERVERS.into(),
+                        ]),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        let user = AllLanguageSettingsContent {
+            languages: LanguageToSettingsMap(
+                [(
+                    "TypeScript".into(),
+                    LanguageSettingsContent {
+                        language_servers: Some(vec![
+                            "typescript-language-server".into(),
+                            REST_OF_LANGUAGE_SERVERS.into(),
+                        ]),
+                        ..LanguageSettingsContent::default()
+                    },
+                )]
+                .into_iter()
+                .collect(),
+            ),
+            ..AllLanguageSettingsContent::default()
+        };
+
+        base.merge_from(&user);
+
+        let ts_servers = base.languages.0["TypeScript"]
+            .language_servers
+            .as_ref()
+            .unwrap();
+        assert_eq!(
+            ts_servers,
+            &vec![
+                "typescript-language-server".to_string(),
+                REST_OF_LANGUAGE_SERVERS.to_string(),
+            ]
+        );
+    }
+
     #[test]
     fn test_prettier_options() {
         let raw_prettier = r#"{"allowed": false, "tabWidth": 4, "semi": false}"#;
diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs
index e58f3dcc02b..4203bc232af 100644
--- a/crates/settings_content/src/language_model.rs
+++ b/crates/settings_content/src/language_model.rs
@@ -106,6 +106,8 @@ pub struct AnthropicAvailableModel {
     pub default_temperature: Option,
     #[serde(default)]
     pub extra_beta_headers: Vec,
+    /// Whether Anthropic's fast mode is available for this model.
+    pub supports_fast_mode: Option,
     /// The model's mode (e.g. thinking)
     pub mode: Option,
 }
@@ -114,6 +116,10 @@ pub struct AnthropicAvailableModel {
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
 pub struct AmazonBedrockSettingsContent {
     pub available_models: Option>,
+    /// Custom models served through the `bedrock-mantle` endpoint's
+    /// OpenAI-compatible APIs, in addition to the built-in Mantle models
+    /// (GPT-5.5, GPT-5.4, Grok 4.3).
+    pub mantle_available_models: Option>,
     pub custom_headers: Option>,
     pub endpoint_url: Option,
     pub region: Option,
@@ -139,6 +145,32 @@ pub struct BedrockAvailableModel {
     pub mode: Option,
 }
 
+#[with_fallible_options]
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct BedrockMantleAvailableModel {
+    /// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`.
+    pub name: String,
+    pub display_name: Option,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option,
+    /// The OpenAI-compatible API this model must be called through.
+    pub protocol: BedrockMantleProtocolContent,
+    pub supports_tools: Option,
+    pub supports_images: Option,
+    /// Whether this custom Mantle model supports OpenAI reasoning effort parameters.
+    pub supports_thinking: Option,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub enum BedrockMantleProtocolContent {
+    /// The OpenAI Chat Completions API (`/chat/completions`).
+    #[serde(rename = "chat_completions")]
+    ChatCompletions,
+    /// The OpenAI Responses API (`/responses`).
+    #[serde(rename = "responses")]
+    Responses,
+}
+
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
 pub enum BedrockAuthMethodContent {
     #[serde(rename = "named_profile")]
@@ -217,6 +249,18 @@ pub struct OpenCodeSettingsContent {
     pub show_free_models: Option,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub enum OpenCodeApiProtocol {
+    #[serde(rename = "anthropic")]
+    Anthropic,
+    #[serde(rename = "openai_responses", alias = "open_ai_responses")]
+    OpenAiResponses,
+    #[serde(rename = "openai_chat", alias = "open_ai_chat")]
+    OpenAiChat,
+    #[serde(rename = "google")]
+    Google,
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
 #[serde(rename_all = "snake_case")]
 pub enum OpenCodeModelSubscription {
@@ -232,8 +276,8 @@ pub struct OpenCodeAvailableModel {
     pub display_name: Option,
     pub max_tokens: u64,
     pub max_output_tokens: Option,
-    /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google".
-    pub protocol: String,
+    /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google". Defaults to "openai_chat".
+    pub protocol: Option,
     /// The subscription for this model: "zen", "go", or "free". Defaults to Zen.
     pub subscription: Option,
     /// Custom Model API URL to use for this model.
diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs
index a1d1a433815..f0f652d2baa 100644
--- a/crates/settings_content/src/settings_content.rs
+++ b/crates/settings_content/src/settings_content.rs
@@ -211,9 +211,6 @@ pub struct SettingsContent {
 
     pub project_panel: Option,
 
-    /// Configuration for the Message Editor
-    pub message_editor: Option,
-
     /// Configuration for Node-related features
     pub node: Option,
 
@@ -839,16 +836,6 @@ pub struct PanelSettingsContent {
     pub default_width: Option,
 }
 
-#[with_fallible_options]
-#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
-pub struct MessageEditorSettings {
-    /// Whether to automatically replace emoji shortcodes with emoji characters.
-    /// For example: typing `:wave:` gets replaced with `👋`.
-    ///
-    /// Default: false
-    pub auto_replace_emoji_shortcode: Option,
-}
-
 #[with_fallible_options]
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)]
 pub struct FileFinderSettingsContent {
diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs
index c7c1013278f..e66795a193f 100644
--- a/crates/settings_content/src/terminal.rs
+++ b/crates/settings_content/src/terminal.rs
@@ -124,6 +124,14 @@ pub struct TerminalSettingsContent {
     ///
     /// Default: true
     pub keep_selection_on_copy: Option,
+    /// 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).
+    ///
+    /// Default: true
+    pub open_links_in_mouse_mode: Option,
     /// Whether to show the terminal button in the status bar.
     ///
     /// Default: true
diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs
index 6c40f210c89..406c1ead91b 100644
--- a/crates/settings_content/src/theme.rs
+++ b/crates/settings_content/src/theme.rs
@@ -157,7 +157,7 @@ pub struct ThemeSettingsContent {
     /// markdown preview. Falls back to the buffer font if unset.
     pub markdown_preview_code_font_family: Option,
     /// The font size to use for rendering in the markdown preview.
-    /// Falls back to the buffer font size if unset.
+    /// Falls back to the UI font size if unset.
     pub markdown_preview_font_size: Option,
     /// The theme to use for the markdown preview.
     /// Falls back to the main editor theme if unset.
diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml
index b3a145c6598..ecc19ef8ac5 100644
--- a/crates/settings_ui/Cargo.toml
+++ b/crates/settings_ui/Cargo.toml
@@ -21,6 +21,7 @@ agent_settings.workspace = true
 agent_skills.workspace = true
 anyhow.workspace = true
 audio.workspace = true
+client.workspace = true
 cloud_api_types.workspace = true
 component.workspace = true
 codestral.workspace = true
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 34ee4440729..2f6ce0beec5 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -7115,7 +7115,7 @@ fn terminal_page() -> SettingsPage {
         ]
     }
 
-    fn behavior_settings_section() -> [SettingsPageItem; 5] {
+    fn behavior_settings_section() -> [SettingsPageItem; 6] {
         [
             SettingsPageItem::SectionHeader("Behavior Settings"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -7179,6 +7179,29 @@ fn terminal_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Open Links In Mouse Mode",
+                description: "Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting. When disabled, these clicks are forwarded to the application; links can still be opened with shift-cmd-click.",
+                field: Box::new(SettingField {
+                    organization_override: None,
+                    json_path: Some("terminal.open_links_in_mouse_mode"),
+                    pick: |settings_content| {
+                        settings_content
+                            .terminal
+                            .as_ref()?
+                            .open_links_in_mouse_mode
+                            .as_ref()
+                    },
+                    write: |settings_content, value, _| {
+                        settings_content
+                            .terminal
+                            .get_or_insert_default()
+                            .open_links_in_mouse_mode = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Audible Bell",
                 description: "Whether to play a sound when the BEL character (`\\a`, `0x07`) is printed",
@@ -8911,7 +8934,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> {
             SettingsPageItem::SectionHeader("Formatting"),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Format On Save",
-                description: "Whether or not to perform a buffer format before saving.",
+                description: "On: format the whole buffer.\nOff: do not format.\nModifications: format only lines with unstaged changes; skips formatting when a git diff or LSP range formatting is unavailable.\nModifications If Available: same, but falls back to formatting the whole buffer.",
                 field: Box::new(
                     // TODO(settings_ui): this setting should just be a bool
                     SettingField {
@@ -9791,7 +9814,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> {
         ]
     }
 
-    fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] {
+    fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 3] {
         [
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Image Viewer",
@@ -9871,30 +9894,6 @@ fn language_settings_data() -> Box<[SettingsPageItem]> {
                     }],
                 ],
             }),
-            SettingsPageItem::SettingItem(SettingItem {
-                title: "Auto Replace Emoji Shortcode",
-                description: "Whether to automatically replace emoji shortcodes with emoji characters.",
-                field: Box::new(SettingField {
-                    organization_override: None,
-                    json_path: Some("message_editor.auto_replace_emoji_shortcode"),
-                    pick: |settings_content| {
-                        settings_content
-                            .message_editor
-                            .as_ref()
-                            .and_then(|message_editor| {
-                                message_editor.auto_replace_emoji_shortcode.as_ref()
-                            })
-                    },
-                    write: |settings_content, value, _| {
-                        settings_content
-                            .message_editor
-                            .get_or_insert_default()
-                            .auto_replace_emoji_shortcode = value;
-                    },
-                }),
-                metadata: None,
-                files: USER,
-            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Drop Size Target",
                 description: "Relative size of the drop target in the editor that will open dropped file as a split pane.",
diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
index 3f756a876a8..01550cd16ea 100644
--- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
+++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
@@ -15,6 +15,9 @@ use workspace::AppState;
 const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
 const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
 
+const OPEN_AI_COMPATIBLE_API_URL_PLACEHOLDER: &str = "http://localhost:8080/v1/completions";
+const OPEN_AI_COMPATIBLE_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
+
 use crate::{
     SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
     components::{SettingsInputField, SettingsSectionHeader},
@@ -526,7 +529,7 @@ fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> {
                 json_path: Some("edit_predictions.open_ai_compatible_api.api_url"),
             }),
             metadata: Some(Box::new(SettingsFieldMetadata {
-                placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
+                placeholder: Some(OPEN_AI_COMPATIBLE_API_URL_PLACEHOLDER),
                 ..Default::default()
             })),
             files: USER,
@@ -560,7 +563,7 @@ fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> {
                 json_path: Some("edit_predictions.open_ai_compatible_api.model"),
             }),
             metadata: Some(Box::new(SettingsFieldMetadata {
-                placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
+                placeholder: Some(OPEN_AI_COMPATIBLE_MODEL_PLACEHOLDER),
                 ..Default::default()
             })),
             files: USER,
diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs
index f888b031eb8..bac9b7abb06 100644
--- a/crates/settings_ui/src/pages/llm_providers_page.rs
+++ b/crates/settings_ui/src/pages/llm_providers_page.rs
@@ -3,9 +3,8 @@ use std::{collections::HashSet, sync::Arc};
 use editor::Editor;
 use gpui::{AnyView, Entity, Focusable as _, ScrollHandle, prelude::*};
 use language_model::{
-    ApiKeyConfiguration, ConfigurationViewTargetAgent, IconOrSvg, InlineDescription,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
-    ProviderConfigurationView,
+    ApiKeyConfiguration, CreateProviderSettingsView, IconOrSvg, InlineDescription,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ProviderSettingsView,
 };
 
 use settings::{
@@ -149,14 +148,29 @@ fn render_provider_section(
     let provider_id = provider.id();
     let provider_name = provider.name().0;
 
-    let body = if let Some(config) = provider.api_key_configuration(cx) {
-        render_api_key_providers_item(settings_window, provider, provider_name.clone(), config, cx)
-    } else {
-        match get_or_create_configuration_view(settings_window, &provider_id, provider, window, cx)
-        {
-            ProviderConfigurationView::Inline { view } => render_inline_body(provider, view, cx),
-            ProviderConfigurationView::SubPage(_) => render_subpage_item(provider, cx),
+    let body = match provider.settings_view(cx) {
+        Some(ProviderSettingsView::ApiKey(config)) => {
+            render_api_key_providers_item(provider, provider_name.clone(), config, cx)
         }
+        Some(ProviderSettingsView::Inline(settings)) => {
+            let view = get_or_create_configuration_view(
+                settings_window,
+                &provider_id,
+                settings.create_view,
+                window,
+                cx,
+            );
+            render_inline_body(
+                provider_name.clone(),
+                settings.title,
+                settings.description,
+                view,
+            )
+        }
+        Some(ProviderSettingsView::SubPage(settings)) => {
+            render_subpage_item(provider, settings.description, cx)
+        }
+        None => div().into_any_element(),
     };
 
     v_flex()
@@ -196,18 +210,16 @@ fn render_provider_header(
 }
 
 fn render_api_key_providers_item(
-    _settings_window: &SettingsWindow,
     provider: &Arc,
     provider_name: SharedString,
     config: ApiKeyConfiguration,
     _cx: &mut Context,
 ) -> AnyElement {
-    let ApiKeyConfiguration {
-        has_key,
-        is_from_env_var,
-        env_var_name,
-        api_key_url,
-    } = config;
+    let provider_id = provider.id();
+    let has_key = config.has_key;
+    let is_from_env_var = config.is_from_env_var;
+    let env_var_name = config.env_var_name;
+    let api_key_url = config.api_key_url;
 
     if has_key {
         let configured_label = if is_from_env_var {
@@ -215,7 +227,7 @@ fn render_api_key_providers_item(
         } else {
             "API Key Configured"
         };
-        let button_id = format!("reset-api-key-{}", provider.id().0);
+        let button_id = format!("reset-api-key-{}", provider_id.0);
 
         let card = ConfiguredApiCard::new(button_id, configured_label)
             .button_label("Reset Key")
@@ -229,7 +241,7 @@ fn render_api_key_providers_item(
             .on_click({
                 let provider = provider.clone();
                 move |_, _, cx| {
-                    provider.reset_credentials(cx).detach_and_log_err(cx);
+                    provider.set_api_key(None, cx).detach_and_log_err(cx);
                 }
             })
             .into_any_element();
@@ -237,7 +249,7 @@ fn render_api_key_providers_item(
         return v_flex().gap_2().child(card).into_any_element();
     }
 
-    let input_id = format!("{}-api-key-input", provider.id().0);
+    let input_id = format!("{}-api-key-input", provider_id.0);
     let aria_label = format!("{provider_name} API Key");
 
     v_flex()
@@ -299,7 +311,7 @@ fn render_api_key_providers_item(
                             let provider = provider.clone();
                             move |api_key, _window, cx| {
                                 if let Some(key) = api_key.filter(|key| !key.is_empty()) {
-                                    provider.set_api_key(key, cx).detach_and_log_err(cx);
+                                    provider.set_api_key(Some(key), cx).detach_and_log_err(cx);
                                 }
                             }
                         }),
@@ -309,14 +321,11 @@ fn render_api_key_providers_item(
 }
 
 fn render_inline_body(
-    provider: &Arc,
+    provider_name: SharedString,
+    title: Option,
+    description: Option,
     view: AnyView,
-    cx: &mut Context,
 ) -> AnyElement {
-    let provider_name = provider.name().0;
-    let title = provider.inline_title(cx);
-    let description = provider.inline_description(cx);
-
     if title.is_none() && description.is_none() {
         return v_flex()
             .pt_1()
@@ -348,11 +357,11 @@ fn render_inline_body(
 
 fn render_subpage_item(
     provider: &Arc,
+    description: Option,
     cx: &mut Context,
 ) -> AnyElement {
     let provider_id = provider.id();
     let provider_name = provider.name().0;
-    let description = provider.inline_description(cx);
 
     h_flex()
         .pt_2p5()
@@ -449,18 +458,19 @@ fn render_provider_config_sub_page(
         return div().into_any_element();
     };
 
-    // A provider routed to a sub-page always provides a `SubPage` view; fall
-    // back to whatever view it returns otherwise.
-    let view = match get_or_create_configuration_view(
-        settings_window,
-        &provider_id,
-        &provider,
-        window,
-        cx,
-    ) {
-        ProviderConfigurationView::Inline { view, .. }
-        | ProviderConfigurationView::SubPage(view) => view,
+    let Some(create_view) =
+        provider
+            .settings_view(cx)
+            .and_then(|settings_view| match settings_view {
+                ProviderSettingsView::Inline(settings) => Some(settings.create_view),
+                ProviderSettingsView::SubPage(settings) => Some(settings.create_view),
+                ProviderSettingsView::ApiKey(_) => None,
+            })
+    else {
+        return div().into_any_element();
     };
+    let view =
+        get_or_create_configuration_view(settings_window, &provider_id, create_view, window, cx);
 
     v_flex()
         .id("provider-config-sub-page")
@@ -477,10 +487,10 @@ fn render_provider_config_sub_page(
 fn get_or_create_configuration_view(
     settings_window: &SettingsWindow,
     provider_id: &LanguageModelProviderId,
-    provider: &Arc,
+    create_view: CreateProviderSettingsView,
     window: &mut Window,
     cx: &mut Context,
-) -> ProviderConfigurationView {
+) -> AnyView {
     if let Some(view) = settings_window
         .provider_configuration_views
         .get(provider_id)
@@ -488,7 +498,7 @@ fn get_or_create_configuration_view(
         return view.clone();
     }
 
-    let view = provider.configuration_view_v2(ConfigurationViewTargetAgent::ZedAgent, window, cx);
+    let view = create_view(window, cx);
 
     // Store the view for future renders by deferring a mutation
     let provider_id = provider_id.clone();
@@ -1146,7 +1156,7 @@ fn save_llm_provider_form(
                 let provider = LanguageModelRegistry::read_global(cx)
                     .provider(&provider_id)
                     .ok_or_else(|| anyhow::anyhow!("Provider was not registered"))?;
-                anyhow::Ok(provider.set_api_key(api_key, cx))
+                anyhow::Ok(provider.set_api_key(Some(api_key), cx))
             })??;
             set_api_key.await?;
 
diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs
index da69fdd5761..533054f015f 100644
--- a/crates/settings_ui/src/pages/sandbox_settings.rs
+++ b/crates/settings_ui/src/pages/sandbox_settings.rs
@@ -73,6 +73,25 @@ pub(crate) fn render_sandbox_settings_page(
             )
             .tab_index(0),
         )
+        .child({
+            let docs_url =
+                client::zed_urls::sandboxing_docs(Some("persistent-sandbox-permissions"), cx);
+            let tooltip = format!("Opens {docs_url}");
+            // Wrap in a row so the button shrinks to its content width instead
+            // of stretching across the settings page.
+            h_flex().child(
+                Button::new("sandbox-docs-link", "Learn more about sandboxing")
+                    .label_size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .end_icon(
+                        Icon::new(IconName::ArrowUpRight)
+                            .color(Color::Muted)
+                            .size(IconSize::XSmall),
+                    )
+                    .tooltip(Tooltip::text(tooltip))
+                    .on_click(move |_, _, cx| cx.open_url(&docs_url)),
+            )
+        })
         .when(sandbox_enabled, |this| this
         .when_some(validation_error, |this, error| {
             this.child(
@@ -145,6 +164,27 @@ pub(crate) fn render_sandbox_settings_page(
                     empty_border,
                 )),
         )
+        .child(Divider::horizontal())
+        .child(
+            v_flex()
+                .gap_4()
+                .child(SettingsSectionHeader::new("Escalation Prompts").no_padding(true))
+                .child(
+                    SwitchField::new(
+                        "sandbox-warn-confusable-unicode",
+                        Some("Warn About Confusable Unicode"),
+                        Some(
+                            "Warn when an approval prompt requests a domain or write path that contains potentially confusable Unicode characters, such as homoglyphs (i.e. two symbols that look similar, such as a Cyrillic `а`)"
+                                .into(),
+                        ),
+                        permissions.warn_confusable_unicode,
+                        move |state, _window, cx| {
+                            set_warn_confusable_unicode(*state == ToggleState::Selected, cx);
+                        },
+                    )
+                    .tab_index(0),
+                ),
+        )
         )
         .into_any_element()
 }
@@ -418,6 +458,12 @@ fn set_allow_fs_write_all(value: bool, cx: &mut App) {
     });
 }
 
+fn set_warn_confusable_unicode(value: bool, cx: &mut App) {
+    update_sandbox_permissions(cx, move |permissions| {
+        permissions.warn_confusable_unicode = Some(value);
+    });
+}
+
 fn add_network_host(host: String, cx: &mut App) {
     update_sandbox_permissions(cx, move |permissions| {
         let hosts = &mut permissions.network_hosts.get_or_insert_default().0;
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index 34a2ac271d4..35f816d649b 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -948,10 +948,9 @@ pub struct SettingsWindow {
     pub(crate) regex_validation_error: Option,
     pub(crate) sandbox_host_validation_error: Option,
     last_copied_link_path: Option<&'static str>,
-    /// Cached configuration views per provider, created lazily. Holds the
-    /// provider's chosen presentation ([`Inline`] or [`SubPage`]).
+    /// Cached configuration views per provider, created lazily.
     pub(crate) provider_configuration_views:
-        HashMap,
+        HashMap,
     /// The provider whose configuration sub-page is currently open, if any.
     pub(crate) configuring_provider: Option,
     /// Directory path of the skill whose share link was most recently copied,
diff --git a/crates/terminal/src/alacritty.rs b/crates/terminal/src/alacritty.rs
index 99b6eadf2fd..5766d8b8fde 100644
--- a/crates/terminal/src/alacritty.rs
+++ b/crates/terminal/src/alacritty.rs
@@ -800,6 +800,10 @@ pub(super) fn clear_saved_screen(term: &mut Term) {
     }
 }
 
+pub(super) fn shrink_to_used(term: &mut Term) {
+    term.grid_mut().truncate();
+}
+
 pub(super) fn make_content(term: &Term, last_content: &Content) -> Content {
     let content = term.renderable_content();
 
diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs
index b3b6c7aff81..796593fe45d 100644
--- a/crates/terminal/src/terminal.rs
+++ b/crates/terminal/src/terminal.rs
@@ -67,9 +67,9 @@ use crate::alacritty::{
     display_only_term_config, find_from_terminal_point, full_content_range, last_non_empty_lines,
     make_content, new_term, open_pty, pty_options, pty_term_config, resize, screen_lines,
     scroll_display, scroll_to_point, search_matches, selection_text, set_default_cursor_style,
-    set_selection as set_term_selection, spawn_event_loop, toggle_vi_mode as toggle_term_vi_mode,
-    total_lines, update_selection as update_term_selection, update_selection_to_vi_cursor,
-    update_vi_cursor_for_scroll, vi_goto_point, vi_motion,
+    set_selection as set_term_selection, shrink_to_used, spawn_event_loop,
+    toggle_vi_mode as toggle_term_vi_mode, total_lines, update_selection as update_term_selection,
+    update_selection_to_vi_cursor, update_vi_cursor_for_scroll, vi_goto_point, vi_motion,
 };
 use crate::mappings::colors::to_vte_rgb;
 use crate::mappings::keys::to_esc_str;
@@ -1004,6 +1004,8 @@ impl TerminalBuilder {
             path_style,
             #[cfg(any(test, feature = "test-support"))]
             input_log: Vec::new(),
+            #[cfg(any(test, feature = "test-support"))]
+            pty_write_log: Default::default(),
         };
 
         TerminalBuilder {
@@ -1275,6 +1277,8 @@ impl TerminalBuilder {
                 path_style,
                 #[cfg(any(test, feature = "test-support"))]
                 input_log: Vec::new(),
+                #[cfg(any(test, feature = "test-support"))]
+                pty_write_log: Default::default(),
             };
 
             if !activation_script.is_empty() && no_task {
@@ -1442,6 +1446,8 @@ pub struct Terminal {
     path_style: PathStyle,
     #[cfg(any(test, feature = "test-support"))]
     input_log: Vec>,
+    #[cfg(any(test, feature = "test-support"))]
+    pty_write_log: std::cell::RefCell>>,
 }
 
 struct CopyTemplate {
@@ -1888,6 +1894,10 @@ impl Terminal {
         self.events.push_back(InternalEvent::Clear)
     }
 
+    pub fn shrink_to_used(&mut self) {
+        shrink_to_used(&mut self.term.lock());
+    }
+
     pub fn scroll_line_up(&mut self) {
         self.events
             .push_back(InternalEvent::Scroll(Scroll::Delta(1)));
@@ -1960,8 +1970,10 @@ impl Terminal {
     /// Write the Input payload to the PTY, if applicable.
     /// (This is a no-op for display-only terminals.)
     fn write_to_pty(&self, input: impl Into>) {
+        let input = input.into();
+        #[cfg(any(test, feature = "test-support"))]
+        self.pty_write_log.borrow_mut().push(input.to_vec());
         if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
-            let input = input.into();
             if log::log_enabled!(log::Level::Debug) {
                 if let Ok(str) = str::from_utf8(&input) {
                     log::debug!("Writing to PTY: {:?}", str);
@@ -2093,6 +2105,11 @@ impl Terminal {
         std::mem::take(&mut self.input_log)
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn take_pty_write_log(&mut self) -> Vec> {
+        std::mem::take(self.pty_write_log.get_mut())
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn keyboard_input_sent(&self) -> bool {
         self.keyboard_input_sent
@@ -2303,22 +2320,30 @@ impl Terminal {
     pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context) {
         let position = e.position - self.last_content.terminal_bounds.bounds.origin;
         if self.mouse_mode(e.modifiers.shift) {
-            let (point, side) = grid_point_and_side(
-                position,
-                self.last_content.terminal_bounds,
-                self.last_content.display_offset,
-            );
-
-            if self.mouse_changed(point, side) {
-                let bytes = mouse_moved_report(
-                    point,
-                    e.pressed_button,
-                    e.modifiers,
-                    self.last_content.mode,
+            // A ctrl/cmd press on a link suppressed its button-press report in
+            // `mouse_down`. Since the app never saw the press, we must swallow
+            // the whole gesture rather than forward later motion/release
+            // reports, which would be a press-less (malformed) sequence.
+            // `mouse_up` resolves it: release on the same link opens it,
+            // otherwise the gesture is dropped.
+            if self.mouse_down_hyperlink.is_none() {
+                let (point, side) = grid_point_and_side(
+                    position,
+                    self.last_content.terminal_bounds,
+                    self.last_content.display_offset,
                 );
 
-                if let Some(bytes) = bytes {
-                    self.write_to_pty(bytes);
+                if self.mouse_changed(point, side) {
+                    let bytes = mouse_moved_report(
+                        point,
+                        e.pressed_button,
+                        e.modifiers,
+                        self.last_content.mode,
+                    );
+
+                    if let Some(bytes) = bytes {
+                        self.write_to_pty(bytes);
+                    }
                 }
             }
         } else {
@@ -2440,7 +2465,7 @@ impl Terminal {
         Some(scroll_lines.clamp(-3, 3))
     }
 
-    pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context) {
+    pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context) {
         let position = e.position - self.last_content.terminal_bounds.bounds.origin;
         let point = grid_point(
             position,
@@ -2450,7 +2475,8 @@ impl Terminal {
 
         if e.button == MouseButton::Left
             && e.modifiers.secondary()
-            && !self.mouse_mode(e.modifiers.shift)
+            && (TerminalSettings::get_global(cx).open_links_in_mouse_mode
+                || !self.mouse_mode(e.modifiers.shift))
         {
             self.mouse_down_hyperlink = self.find_hyperlink_at_point(point);
 
@@ -2500,7 +2526,7 @@ impl Terminal {
                 }
                 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
                 MouseButton::Middle => {
-                    if let Some(item) = _cx.read_from_primary() {
+                    if let Some(item) = cx.read_from_primary() {
                         let text = item.text().unwrap_or_default();
                         self.paste(&text);
                     }
@@ -2514,6 +2540,33 @@ impl Terminal {
         let setting = TerminalSettings::get_global(cx);
 
         let position = e.position - self.last_content.terminal_bounds.bounds.origin;
+        if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
+            let point = grid_point(
+                position,
+                self.last_content.terminal_bounds,
+                self.last_content.display_offset,
+            );
+
+            if self
+                .find_hyperlink_at_point(point)
+                .is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink)
+            {
+                self.events
+                    .push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true));
+                self.selection_phase = SelectionPhase::Ended;
+                self.last_mouse = None;
+                self.mouse_down_position = None;
+                return;
+            }
+
+            if self.mouse_mode(e.modifiers.shift) {
+                self.selection_phase = SelectionPhase::Ended;
+                self.last_mouse = None;
+                self.mouse_down_position = None;
+                return;
+            }
+        }
+
         if self.mouse_mode(e.modifiers.shift) {
             let point = grid_point(
                 position,
@@ -2532,24 +2585,6 @@ impl Terminal {
                 self.copy(Some(true));
             }
 
-            if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
-                let point = grid_point(
-                    position,
-                    self.last_content.terminal_bounds,
-                    self.last_content.display_offset,
-                );
-
-                if let Some(mouse_up_hyperlink) = self.find_hyperlink_at_point(point) {
-                    if mouse_down_hyperlink == mouse_up_hyperlink {
-                        self.events
-                            .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true));
-                        self.selection_phase = SelectionPhase::Ended;
-                        self.last_mouse = None;
-                        return;
-                    }
-                }
-            }
-
             //Hyperlinks
             if self.selection_phase == SelectionPhase::Ended {
                 let mouse_cell_index =
@@ -3582,6 +3617,7 @@ mod tests {
             );
             terminal.last_content.terminal_bounds = terminal_bounds;
             terminal.events.clear();
+            terminal.take_pty_write_log();
         });
 
         terminal
@@ -3645,6 +3681,20 @@ mod tests {
         terminal.mouse_down(&mouse_down, cx);
     }
 
+    fn left_mouse_up_at(
+        terminal: &mut Terminal,
+        position: GpuiPoint,
+        cx: &mut Context,
+    ) {
+        let mouse_up = MouseUpEvent {
+            button: MouseButton::Left,
+            position,
+            modifiers: Modifiers::none(),
+            click_count: 1,
+        };
+        terminal.mouse_up(&mouse_up, cx);
+    }
+
     fn left_mouse_drag_to(
         terminal: &mut Terminal,
         position: GpuiPoint,
@@ -4404,6 +4454,166 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_hyperlink_ctrl_click_same_position_in_mouse_mode(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.last_content.mode = Modes::MOUSE_MODE;
+
+            let click_position = point(px(80.0), px(10.0));
+            ctrl_mouse_down_at(terminal, click_position, cx);
+            ctrl_mouse_up_at(terminal, click_position, cx);
+
+            assert!(
+                terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
+                "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode"
+            );
+            assert!(
+                terminal.take_pty_write_log().is_empty(),
+                "a consumed link click must not be reported to the PTY"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
+        cx: &mut TestAppContext,
+    ) {
+        let terminal = init_ctrl_click_hyperlink_test(
+            cx,
+            b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
+        );
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.last_content.mode = Modes::MOUSE_MODE;
+            terminal.take_pty_write_log();
+
+            let down_position = point(px(80.0), px(10.0));
+            let up_position = point(px(10.0), px(30.0));
+
+            ctrl_mouse_down_at(terminal, down_position, cx);
+            terminal.mouse_move(
+                &MouseMoveEvent {
+                    position: up_position,
+                    pressed_button: Some(MouseButton::Left),
+                    modifiers: Modifiers::secondary_key(),
+                },
+                cx,
+            );
+            ctrl_mouse_up_at(terminal, up_position, cx);
+
+            assert!(
+                !terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
+                "Should NOT open a link when press and release land on different hyperlinks"
+            );
+            let pty_writes = terminal.take_pty_write_log();
+            assert!(
+                pty_writes.is_empty(),
+                "a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.last_content.mode = Modes::MOUSE_MODE;
+            terminal.take_pty_write_log();
+
+            let click_position = point(px(80.0), px(10.0));
+            left_mouse_down_at(terminal, click_position, cx);
+            left_mouse_up_at(terminal, click_position, cx);
+
+            assert!(
+                !terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
+                "a plain click must not open a link"
+            );
+            let pty_writes = terminal.take_pty_write_log();
+            assert_eq!(
+                pty_writes.len(),
+                2,
+                "expected press and release reports, got {pty_writes:?}"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.last_content.mode = Modes::MOUSE_MODE;
+            terminal.take_pty_write_log();
+
+            // Past the end of the line: nothing link-like under the cursor.
+            let click_position = point(px(370.0), px(10.0));
+            ctrl_mouse_down_at(terminal, click_position, cx);
+            ctrl_mouse_up_at(terminal, click_position, cx);
+
+            assert!(
+                !terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
+                "a secondary click off a link must not open anything"
+            );
+            let pty_writes = terminal.take_pty_write_log();
+            assert_eq!(
+                pty_writes.len(),
+                2,
+                "expected press and release reports, got {pty_writes:?}"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        cx.update_global(|store: &mut settings::SettingsStore, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings
+                    .terminal
+                    .get_or_insert_default()
+                    .open_links_in_mouse_mode = Some(false);
+            });
+        });
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.last_content.mode = Modes::MOUSE_MODE;
+
+            let click_position = point(px(80.0), px(10.0));
+            ctrl_mouse_down_at(terminal, click_position, cx);
+            ctrl_mouse_up_at(terminal, click_position, cx);
+
+            assert!(
+                !terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
+                "with the setting disabled, ctrl+click must not open links in mouse mode"
+            );
+            let pty_writes = terminal.take_pty_write_log();
+            assert_eq!(
+                pty_writes.len(),
+                2,
+                "expected press and release reports, got {pty_writes:?}"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
         let terminal = init_ctrl_click_hyperlink_test(
diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs
index 8a31745d67d..64d76b7de3f 100644
--- a/crates/terminal/src/terminal_settings.rs
+++ b/crates/terminal/src/terminal_settings.rs
@@ -35,6 +35,7 @@ pub struct TerminalSettings {
     pub option_as_meta: bool,
     pub copy_on_select: bool,
     pub keep_selection_on_copy: bool,
+    pub open_links_in_mouse_mode: bool,
     pub button: bool,
     pub dock: TerminalDockPosition,
     pub flexible: bool,
@@ -105,6 +106,7 @@ impl settings::Settings for TerminalSettings {
             option_as_meta: user_content.option_as_meta.unwrap(),
             copy_on_select: user_content.copy_on_select.unwrap(),
             keep_selection_on_copy: user_content.keep_selection_on_copy.unwrap(),
+            open_links_in_mouse_mode: user_content.open_links_in_mouse_mode.unwrap(),
             button: user_content.button.unwrap(),
             dock: user_content.dock.unwrap(),
             default_width: px(user_content.default_width.unwrap()),
diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml
index 9da4c5ec6e1..5197dee517a 100644
--- a/crates/terminal_view/Cargo.toml
+++ b/crates/terminal_view/Cargo.toml
@@ -32,13 +32,14 @@ menu.workspace = true
 pretty_assertions.workspace = true
 project.workspace = true
 regex.workspace = true
-task.workspace = true
 schemars.workspace = true
+task.workspace = true
 
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 shellexpand.workspace = true
+shlex.workspace = true
 terminal.workspace = true
 theme.workspace = true
 theme_settings.workspace = true
diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs
index cd0ed9241fa..b74d28593d0 100644
--- a/crates/terminal_view/src/terminal_element.rs
+++ b/crates/terminal_view/src/terminal_element.rs
@@ -1349,7 +1349,6 @@ impl Element for TerminalElement {
             };
 
             let terminal_input_handler = TerminalInputHandler {
-                terminal: self.terminal.clone(),
                 terminal_view: self.terminal_view.clone(),
                 cursor_bounds: layout.ime_cursor_bounds.map(|bounds| bounds + origin),
                 workspace: self.workspace.clone(),
@@ -1510,7 +1509,6 @@ impl IntoElement for TerminalElement {
 }
 
 struct TerminalInputHandler {
-    terminal: Entity,
     terminal_view: Entity,
     workspace: WeakEntity,
     cursor_bounds: Option>,
@@ -1521,22 +1519,15 @@ impl InputHandler for TerminalInputHandler {
         &mut self,
         _ignore_disabled_input: bool,
         _: &mut Window,
-        cx: &mut App,
+        _cx: &mut App,
     ) -> Option {
-        if self
-            .terminal
-            .read(cx)
-            .last_content
-            .mode
-            .contains(Modes::ALT_SCREEN)
-        {
-            None
-        } else {
-            Some(UTF16Selection {
-                range: 0..0,
-                reversed: false,
-            })
-        }
+        // Always return a valid selection for IME positioning,
+        // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.)
+        // The terminal still has a cursor position that should be used for IME candidate window placement.
+        Some(UTF16Selection {
+            range: 0..0,
+            reversed: false,
+        })
     }
 
     fn marked_text_range(
diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs
index 25e4ad9d999..e1f31c3614c 100644
--- a/crates/terminal_view/src/terminal_panel.rs
+++ b/crates/terminal_view/src/terminal_panel.rs
@@ -11,9 +11,9 @@ use collections::HashMap;
 use db::kvp::KeyValueStore;
 use futures::{channel::oneshot, future::join_all};
 use gpui::{
-    Action, Anchor, AnyView, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter,
-    FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt,
-    WeakEntity, Window, actions,
+    Action, Anchor, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle,
+    Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt, WeakEntity,
+    Window, actions,
 };
 use itertools::Itertools;
 use project::{Fs, Project};
@@ -83,7 +83,6 @@ pub struct TerminalPanel {
     pending_terminals_to_add: usize,
     deferred_tasks: HashMap>,
     assistant_enabled: bool,
-    assistant_tab_bar_button: Option,
     active: bool,
 }
 
@@ -101,7 +100,6 @@ impl TerminalPanel {
             pending_terminals_to_add: 0,
             deferred_tasks: HashMap::default(),
             assistant_enabled: false,
-            assistant_tab_bar_button: None,
             active: false,
         };
         terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx);
@@ -110,20 +108,6 @@ impl TerminalPanel {
 
     pub fn set_assistant_enabled(&mut self, enabled: bool, cx: &mut Context) {
         self.assistant_enabled = enabled;
-        if enabled {
-            let focus_handle = self
-                .active_pane
-                .read(cx)
-                .active_item()
-                .map(|item| item.item_focus_handle(cx))
-                .unwrap_or(self.focus_handle(cx));
-            self.assistant_tab_bar_button = Some(
-                cx.new(move |_| InlineAssistTabBarButton { focus_handle })
-                    .into(),
-            );
-        } else {
-            self.assistant_tab_bar_button = None;
-        }
         for pane in self.center.panes() {
             self.apply_tab_bar_buttons(pane, cx);
         }
@@ -134,7 +118,7 @@ impl TerminalPanel {
         terminal_pane: &Entity,
         cx: &mut Context,
     ) {
-        let assistant_tab_bar_button = self.assistant_tab_bar_button.clone();
+        let assistant_enabled = self.assistant_enabled;
         terminal_pane.update(cx, |pane, cx| {
             pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| {
                 let split_context = pane
@@ -182,7 +166,11 @@ impl TerminalPanel {
                                 Some(menu)
                             }),
                     )
-                    .children(assistant_tab_bar_button.clone())
+                    .when(assistant_enabled, |this| {
+                        this.when_some(split_context.clone(), |this, focus_handle| {
+                            this.child(InlineAssistTabBarButton { focus_handle })
+                        })
+                    })
                     .child(
                         PopoverMenu::new("terminal-pane-tab-bar-split")
                             .trigger_with_tooltip(
@@ -1705,18 +1693,22 @@ impl workspace::TerminalProvider for TerminalProvider {
     }
 }
 
+#[derive(IntoElement)]
 struct InlineAssistTabBarButton {
     focus_handle: FocusHandle,
 }
 
-impl Render for InlineAssistTabBarButton {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        let focus_handle = self.focus_handle.clone();
+impl RenderOnce for InlineAssistTabBarButton {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let focus_handle = self.focus_handle;
         IconButton::new("terminal_inline_assistant", IconName::ZedAssistant)
             .icon_size(IconSize::Small)
-            .on_click(cx.listener(|_, _, window, cx| {
-                window.dispatch_action(InlineAssist::default().boxed_clone(), cx);
-            }))
+            .on_click({
+                let focus_handle = focus_handle.clone();
+                move |_, window, cx| {
+                    focus_handle.dispatch_action(&InlineAssist::default(), window, cx);
+                }
+            })
             .tooltip(move |_window, cx| {
                 Tooltip::for_action_in("Inline Assist", &InlineAssist::default(), &focus_handle, cx)
             })
@@ -1728,7 +1720,7 @@ mod tests {
     use std::num::NonZero;
 
     use super::*;
-    use gpui::{TestAppContext, UpdateGlobal as _};
+    use gpui::{Modifiers, TestAppContext, UpdateGlobal as _, VisualTestContext};
     use pretty_assertions::assert_eq;
     use project::FakeFs;
     use settings::SettingsStore;
@@ -1920,6 +1912,47 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_inline_assist_tooltip_shows_keybinding_of_active_terminal(
+        cx: &mut TestAppContext,
+    ) {
+        cx.executor().allow_parking();
+        init_test(cx);
+
+        cx.update(|cx| {
+            cx.bind_keys([gpui::KeyBinding::new(
+                "ctrl-enter",
+                InlineAssist::default(),
+                Some("Terminal"),
+            )])
+        });
+
+        let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await;
+        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
+
+        terminal_panel.update(cx, |panel, cx| panel.set_assistant_enabled(true, cx));
+        terminal_panel
+            .update_in(cx, |panel, window, cx| {
+                panel.add_terminal_shell(None, RevealStrategy::Always, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        let button_bounds = cx
+            .debug_bounds("ICON-ZedAssistant")
+            .expect("inline assist button should be rendered in the terminal tab bar");
+        cx.simulate_mouse_move(button_bounds.center(), None, Modifiers::default());
+
+        cx.executor().advance_clock(Duration::from_millis(600));
+        cx.run_until_parked();
+
+        assert!(
+            cx.debug_bounds("KEY_BINDING-enter").is_some(),
+            "tooltip should show the InlineAssist keybinding resolved in the terminal's context"
+        );
+    }
+
     async fn init_workspace_with_panel(
         cx: &mut TestAppContext,
     ) -> (gpui::WindowHandle, Entity) {
diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs
index 6511497623a..a323da81b50 100644
--- a/crates/terminal_view/src/terminal_view.rs
+++ b/crates/terminal_view/src/terminal_view.rs
@@ -959,7 +959,7 @@ impl TerminalView {
     pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
         let mut text = paths
             .iter()
-            .map(|path| format!(" {path:?}"))
+            .filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?)))
             .collect::();
         text.push(' ');
         window.focus(&self.focus_handle(cx), cx);
@@ -1129,6 +1129,7 @@ fn subscribe_for_terminal_events(
             match event {
                 Event::Wakeup => {
                     cx.notify();
+                    window.invalidate_character_coordinates();
                     cx.emit(Event::Wakeup);
                     cx.emit(ItemEvent::UpdateTab);
                     cx.emit(SearchEvent::MatchesInvalidated);
@@ -2176,7 +2177,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
@@ -2315,6 +2316,28 @@ mod tests {
         );
     }
 
+    #[cfg(target_os = "linux")]
+    #[gpui::test]
+    async fn ctrl_q_is_forwarded_to_terminal_not_quit(cx: &mut TestAppContext) {
+        let (project, _workspace, window_handle) = init_test_with_window(cx).await;
+        cx.update(load_default_keymap);
+        let (_pane, terminal, _terminal_view) =
+            add_display_only_terminal(&project, window_handle, true, cx);
+
+        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
+        cx.update(|window, cx| {
+            let _ = window.draw(cx);
+        });
+        cx.run_until_parked();
+
+        cx.simulate_keystrokes("ctrl-q");
+        assert_eq!(
+            terminal.update(&mut cx, |terminal, _| terminal.take_input_log()),
+            vec![vec![0x11]],
+            "ctrl-q in a focused terminal should send 0x11 to the PTY, not trigger zed::Quit",
+        );
+    }
+
     // Working directory calculation tests
 
     // No Worktrees in project -> home_dir()
diff --git a/crates/theme/src/color_space.rs b/crates/theme/src/color_space.rs
new file mode 100644
index 00000000000..125dfa6d9d6
--- /dev/null
+++ b/crates/theme/src/color_space.rs
@@ -0,0 +1,76 @@
+//! Conversions between gpui's [`Hsla`] and the OKLab / OKLCh perceptual color
+//! spaces, backed by the `palette` crate.
+//!
+//! These are exposed so consumers can reason about perceptual color distance
+//! (e.g. bracket colorization) without taking a direct dependency on `palette`.
+
+use gpui::{Hsla, Rgba};
+use palette::{
+    FromColor, OklabHue,
+    rgb::{LinSrgba, Srgba},
+};
+
+/// A color in the OKLab perceptual color space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Oklab {
+    /// Perceptual lightness, in `0.0..=1.0`.
+    pub l: f32,
+    /// Green/red opponent axis.
+    pub a: f32,
+    /// Blue/yellow opponent axis.
+    pub b: f32,
+}
+
+/// A color in the OKLCh perceptual color space (the cylindrical form of OKLab).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Oklch {
+    /// Perceptual lightness, in `0.0..=1.0`.
+    pub l: f32,
+    /// Chroma (colorfulness).
+    pub chroma: f32,
+    /// Hue, in degrees (`0.0..360.0`).
+    pub hue: f32,
+}
+
+/// Converts an [`Hsla`] color into the OKLab color space.
+pub fn hsla_to_oklab(color: Hsla) -> Oklab {
+    let oklab = palette::Oklab::from_color(hsla_to_linear(color));
+    Oklab {
+        l: oklab.l,
+        a: oklab.a,
+        b: oklab.b,
+    }
+}
+
+/// Converts an [`Hsla`] color into the OKLCh color space.
+pub fn hsla_to_oklch(color: Hsla) -> Oklch {
+    let oklch = palette::Oklch::from_color(hsla_to_linear(color));
+    Oklch {
+        l: oklch.l,
+        chroma: oklch.chroma,
+        hue: oklch.hue.into_positive_degrees(),
+    }
+}
+
+/// Converts an [`Oklch`] color back into [`Hsla`], using `alpha` for the
+/// resulting alpha channel. Channels outside the sRGB gamut are clamped.
+pub fn oklch_to_hsla(color: Oklch, alpha: f32) -> Hsla {
+    let oklch = palette::Oklch {
+        l: color.l,
+        chroma: color.chroma,
+        hue: OklabHue::from_degrees(color.hue),
+    };
+    let rgba: Srgba = Srgba::from_linear(LinSrgba::from_color(oklch));
+    let (red, green, blue, _) = rgba.into_components();
+    Hsla::from(Rgba {
+        r: red.clamp(0.0, 1.0),
+        g: green.clamp(0.0, 1.0),
+        b: blue.clamp(0.0, 1.0),
+        a: alpha,
+    })
+}
+
+fn hsla_to_linear(color: Hsla) -> LinSrgba {
+    let rgba = Rgba::from(color);
+    Srgba::new(rgba.r, rgba.g, rgba.b, rgba.a).into_linear()
+}
diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs
index ab49877c6bb..b973d8f25c1 100644
--- a/crates/theme/src/theme.rs
+++ b/crates/theme/src/theme.rs
@@ -8,6 +8,7 @@
 //!
 //! A theme is a collection of colors used to build a consistent appearance for UI components across the application.
 
+mod color_space;
 mod default_colors;
 mod fallback_themes;
 mod font_family_cache;
@@ -30,6 +31,7 @@ use gpui::{
 };
 use serde::Deserialize;
 
+pub use crate::color_space::*;
 pub use crate::default_colors::*;
 pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults};
 pub use crate::font_family_cache::*;
diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs
index 5deadcc108a..517367af2b8 100644
--- a/crates/theme_settings/src/settings.rs
+++ b/crates/theme_settings/src/settings.rs
@@ -64,7 +64,7 @@ pub struct ThemeSettings {
     /// Falls back to the buffer font family if unset.
     markdown_preview_code_font_family: Option,
     /// The font size to use for rendering in the markdown preview.
-    /// Falls back to the buffer font size if unset.
+    /// Falls back to the UI font size if unset.
     markdown_preview_font_size: Option,
     /// The theme to use for the markdown preview.
     /// Falls back to the main editor theme if unset.
@@ -451,14 +451,14 @@ impl ThemeSettings {
 
     /// Returns the markdown preview font size.
     ///
-    /// Note: the fallback deliberately uses `self.buffer_font_size` instead of `buffer_font_size(cx)`,
-    /// so that temporary editor zoom does not also resize the markdown preview.
+    /// Note: the fallback deliberately uses `self.ui_font_size` instead of `ui_font_size(cx)`,
+    /// so that temporary UI zoom does not also resize the markdown preview.
     pub fn markdown_preview_font_size(&self, cx: &App) -> Pixels {
         cx.try_global::()
             .map(|size| size.0)
             .or(self.markdown_preview_font_size)
             .map(clamp_font_size)
-            .unwrap_or_else(|| clamp_font_size(self.buffer_font_size))
+            .unwrap_or_else(|| clamp_font_size(self.ui_font_size))
     }
 
     /// Returns the buffer font size, read from the settings.
diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs
index 2ca96b5ac0e..876c00b9ba6 100644
--- a/crates/title_bar/src/update_version.rs
+++ b/crates/title_bar/src/update_version.rs
@@ -1,7 +1,7 @@
 use std::sync::Arc;
 
 use anyhow::anyhow;
-use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType, VersionCheckType};
+use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType};
 use gpui::{Empty, Render};
 use semver::Version;
 use ui::{UpdateButton, prelude::*};
@@ -9,31 +9,30 @@ use ui::{UpdateButton, prelude::*};
 pub struct UpdateVersion {
     status: AutoUpdateStatus,
     update_check_type: UpdateCheckType,
-    dismissed: bool,
+    dismissed_status: Option,
 }
 
 impl UpdateVersion {
     pub fn new(cx: &mut Context) -> Self {
         if let Some(auto_updater) = AutoUpdater::get(cx) {
             cx.observe(&auto_updater, |this, auto_update, cx| {
-                this.status = auto_update.read(cx).status();
-                this.update_check_type = auto_update.read(cx).update_check_type();
-                if this.status.is_updated() {
-                    this.dismissed = false;
-                }
+                let auto_update = auto_update.read(cx);
+                this.status = auto_update.status();
+                this.update_check_type = auto_update.update_check_type();
+                this.dismissed_status = auto_update.dismissed_status();
                 cx.notify();
             })
             .detach();
             Self {
                 status: auto_updater.read(cx).status(),
                 update_check_type: UpdateCheckType::Automatic,
-                dismissed: false,
+                dismissed_status: auto_updater.read(cx).dismissed_status(),
             }
         } else {
             Self {
                 status: AutoUpdateStatus::Idle,
                 update_check_type: UpdateCheckType::Automatic,
-                dismissed: false,
+                dismissed_status: None,
             }
         }
     }
@@ -42,13 +41,14 @@ impl UpdateVersion {
         let next_state = match self.status {
             AutoUpdateStatus::Idle => AutoUpdateStatus::Checking,
             AutoUpdateStatus::Checking => AutoUpdateStatus::Downloading {
-                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+                version: Version::new(1, 99, 0),
+                progress: Some(0.5),
             },
             AutoUpdateStatus::Downloading { .. } => AutoUpdateStatus::Installing {
-                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+                version: Version::new(1, 99, 0),
             },
             AutoUpdateStatus::Installing { .. } => AutoUpdateStatus::Updated {
-                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+                version: Version::new(1, 99, 0),
             },
             AutoUpdateStatus::Updated { .. } => AutoUpdateStatus::Errored {
                 error: Arc::new(anyhow!("Network timeout")),
@@ -58,51 +58,58 @@ impl UpdateVersion {
 
         self.status = next_state;
         self.update_check_type = UpdateCheckType::Manual;
-        self.dismissed = false;
+        self.dismissed_status = None;
         cx.notify()
     }
 
     pub fn show_update_in_menu_bar(&self) -> bool {
-        self.dismissed && self.status.is_updated()
+        self.is_dismissed() && self.status.is_updated()
     }
 
-    fn version_tooltip_message(version: &VersionCheckType) -> String {
-        format!("Update to Version: {}", {
-            match version {
-                VersionCheckType::Sha(sha) => sha.full(),
-                VersionCheckType::Semantic(semantic_version) => semantic_version.to_string(),
-            }
-        })
+    fn is_dismissed(&self) -> bool {
+        self.dismissed_status.as_ref() == Some(&self.status)
+    }
+
+    fn dismiss(&mut self, cx: &mut Context) {
+        self.dismissed_status = Some(self.status.clone());
+        if let Some(auto_updater) = AutoUpdater::get(cx) {
+            let status = self.status.clone();
+            auto_updater.update(cx, |auto_updater, cx| {
+                auto_updater.dismiss_status(status, cx)
+            });
+        }
+        cx.notify()
+    }
+
+    fn version_tooltip_message(version: &Version) -> String {
+        format!("Update to Version: {version}")
     }
 }
 
 impl Render for UpdateVersion {
     fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        if self.dismissed {
+        if self.is_dismissed() {
             return Empty.into_any_element();
         }
         match &self.status {
             AutoUpdateStatus::Checking if self.update_check_type.is_manual() => {
                 UpdateButton::checking().into_any_element()
             }
-            AutoUpdateStatus::Downloading { version } => {
-                let version = Self::version_tooltip_message(&version);
-                UpdateButton::downloading(version).into_any_element()
+            AutoUpdateStatus::Downloading { version, progress } => {
+                let version = Self::version_tooltip_message(version);
+                UpdateButton::downloading(version, *progress).into_any_element()
             }
             AutoUpdateStatus::Installing { version } => {
-                let version = Self::version_tooltip_message(&version);
+                let version = Self::version_tooltip_message(version);
                 UpdateButton::installing(version).into_any_element()
             }
             AutoUpdateStatus::Updated { version } => {
-                let version = Self::version_tooltip_message(&version);
+                let version = Self::version_tooltip_message(version);
                 UpdateButton::updated(version)
                     .on_click(|_, _, cx| {
                         workspace::reload(cx);
                     })
-                    .on_dismiss(cx.listener(|this, _, _window, cx| {
-                        this.dismissed = true;
-                        cx.notify()
-                    }))
+                    .on_dismiss(cx.listener(|this, _, _window, cx| this.dismiss(cx)))
                     .into_any_element()
             }
             AutoUpdateStatus::Errored { error } => {
@@ -111,10 +118,7 @@ impl Render for UpdateVersion {
                     .on_click(|_, window, cx| {
                         window.dispatch_action(Box::new(workspace::OpenLog), cx);
                     })
-                    .on_dismiss(cx.listener(|this, _, _window, cx| {
-                        this.dismissed = true;
-                        cx.notify()
-                    }))
+                    .on_dismiss(cx.listener(|this, _, _window, cx| this.dismiss(cx)))
                     .into_any_element()
             }
             AutoUpdateStatus::Idle | AutoUpdateStatus::Checking { .. } => Empty.into_any_element(),
@@ -123,27 +127,25 @@ impl Render for UpdateVersion {
 }
 #[cfg(test)]
 mod tests {
-    use auto_update::VersionCheckType;
-    use release_channel::AppCommitSha;
     use semver::Version;
 
     use super::*;
 
     #[test]
     fn test_version_tooltip_message() {
-        let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Semantic(
-            Version::new(1, 0, 0),
-        ));
+        let message = UpdateVersion::version_tooltip_message(&Version::new(1, 0, 0));
 
         assert_eq!(message, "Update to Version: 1.0.0");
 
-        let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Sha(
-            AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
-        ));
+        let message = UpdateVersion::version_tooltip_message(
+            &"1.0.0+nightly.14d9a4189f058d8736339b06ff2340101eaea5af"
+                .parse()
+                .unwrap(),
+        );
 
         assert_eq!(
             message,
-            "Update to Version: 14d9a4189f058d8736339b06ff2340101eaea5af"
+            "Update to Version: 1.0.0+nightly.14d9a4189f058d8736339b06ff2340101eaea5af"
         );
     }
 }
diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs
index a05a538b4a7..2da97d4bc56 100644
--- a/crates/ui/src/components/button/icon_button.rs
+++ b/crates/ui/src/components/button/icon_button.rs
@@ -124,6 +124,17 @@ impl IconButton {
 
         self
     }
+
+    /// Use the given callback to construct a new tooltip view when the mouse hovers over this
+    /// button. The tooltip itself is also hoverable and won't disappear when the user moves the
+    /// mouse into the tooltip, allowing it to contain interactive elements like links or buttons.
+    pub fn hoverable_tooltip(
+        mut self,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> Self {
+        self.base = self.base.hoverable_tooltip(tooltip);
+        self
+    }
 }
 
 impl Disableable for IconButton {
diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs
index 01cfc9a38b5..315bbc95f12 100644
--- a/crates/ui/src/components/button/split_button.rs
+++ b/crates/ui/src/components/button/split_button.rs
@@ -1,10 +1,11 @@
 use gpui::{
-    AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce, Styled, Window, div, hsla,
-    prelude::FluentBuilder, px, relative,
+    AnyElement, App, BoxShadow, ParentElement, RenderOnce, Styled, Window, div, hsla, prelude::*,
+    px,
 };
+
 use theme::ActiveTheme;
 
-use crate::{ElevationIndex, prelude::*};
+use crate::{Divider, ElevationIndex, prelude::*};
 
 use super::ButtonLike;
 
@@ -80,12 +81,13 @@ impl RenderOnce for SplitButton {
                 SplitButtonKind::ButtonLike(button) => button.into_any_element(),
                 SplitButtonKind::IconButton(icon) => icon.into_any_element(),
             }))
-            .child(
-                div()
-                    .h(relative(0.8))
-                    .w_px()
-                    .bg(cx.theme().colors().border.opacity(0.5)),
-            )
+            .child(Divider::vertical().map(|s| {
+                if self.style == SplitButtonStyle::Filled {
+                    s.h_full().color(crate::DividerColor::Border)
+                } else {
+                    s.h_4()
+                }
+            }))
             .child(self.right)
             .when(self.style == SplitButtonStyle::Filled, |this| {
                 this.bg(ElevationIndex::Surface.on_elevation_bg(cx))
diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs
index c0e74867cc6..38d6a169341 100644
--- a/crates/ui/src/components/collab/update_button.rs
+++ b/crates/ui/src/components/collab/update_button.rs
@@ -1,6 +1,10 @@
 use gpui::{AnyElement, ClickEvent, prelude::*};
 
-use crate::{ButtonLike, CommonAnimationExt, Tooltip, prelude::*};
+use crate::{ButtonLike, CircularProgress, CommonAnimationExt, Tooltip, prelude::*};
+
+const LOAD_CIRCLE_GLYPH_VIEWBOX: f32 = 16.0;
+const LOAD_CIRCLE_GLYPH_STROKE_WIDTH: f32 = 1.2;
+const LOAD_CIRCLE_GLYPH_RADIUS: f32 = 5.0;
 
 /// A button component displayed in the title bar to show auto-update status.
 #[derive(IntoElement, RegisterComponent)]
@@ -12,6 +16,7 @@ pub struct UpdateButton {
     tooltip: Option,
     disabled: bool,
     show_dismiss: bool,
+    progress: Option,
     on_click: Option>,
     on_dismiss: Option>,
 }
@@ -26,6 +31,7 @@ impl UpdateButton {
             tooltip: None,
             disabled: false,
             show_dismiss: false,
+            progress: None,
             on_click: None,
             on_dismiss: None,
         }
@@ -78,15 +84,21 @@ impl UpdateButton {
         self
     }
 
+    pub fn progress(mut self, progress: impl Into>) -> Self {
+        self.progress = progress.into();
+        self
+    }
+
     pub fn checking() -> Self {
         Self::new(IconName::LoadCircle, "Checking for Zed Updates…")
             .icon_animate(true)
             .disabled(true)
     }
 
-    pub fn downloading(version: impl Into) -> Self {
+    pub fn downloading(version: impl Into, progress: Option) -> Self {
         Self::new(IconName::Download, "Downloading Zed Update…")
             .tooltip(version)
+            .progress(progress)
             .disabled(true)
     }
 
@@ -112,24 +124,44 @@ impl UpdateButton {
 }
 
 impl RenderOnce for UpdateButton {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let border_color = if self.disabled {
             cx.theme().colors().border
         } else {
             cx.theme().colors().text.opacity(0.15)
         };
 
-        let icon = Icon::new(self.icon)
-            .size(IconSize::XSmall)
-            .when_some(self.icon_color, |this, color| this.color(color));
-        let icon_element = if self.icon_animate {
-            icon.with_rotate_animation(2).into_any_element()
+        let icon_element = if let Some(progress) = self.progress {
+            let progress = progress.clamp(0.0, 1.0);
+            let icon_box = IconSize::XSmall.rems().to_pixels(window.rem_size());
+            let progress_color = Color::Default.color(cx);
+            CircularProgress::new(progress, 1.0, icon_box, cx)
+                .stroke_width(
+                    icon_box * (LOAD_CIRCLE_GLYPH_STROKE_WIDTH / LOAD_CIRCLE_GLYPH_VIEWBOX),
+                )
+                .radius(icon_box * (LOAD_CIRCLE_GLYPH_RADIUS / LOAD_CIRCLE_GLYPH_VIEWBOX))
+                .bg_color(progress_color.opacity(0.2))
+                .progress_color(progress_color)
+                .into_any_element()
         } else {
-            icon.into_any_element()
+            let icon = Icon::new(self.icon)
+                .size(IconSize::XSmall)
+                .when_some(self.icon_color, |this, color| this.color(color));
+            if self.icon_animate {
+                icon.with_rotate_animation(2).into_any_element()
+            } else {
+                icon.into_any_element()
+            }
         };
 
         let tooltip = self.tooltip.clone();
 
+        let label_row = h_flex()
+            .h_full()
+            .gap_1()
+            .child(icon_element)
+            .child(Label::new(self.message).size(LabelSize::Small));
+
         h_flex()
             .mr_2()
             .rounded_sm()
@@ -137,13 +169,7 @@ impl RenderOnce for UpdateButton {
             .border_color(border_color)
             .child(
                 ButtonLike::new("update-button")
-                    .child(
-                        h_flex()
-                            .h_full()
-                            .gap_1()
-                            .child(icon_element)
-                            .child(Label::new(self.message).size(LabelSize::Small)),
-                    )
+                    .child(label_row)
                     .when_some(tooltip, |this, tooltip| {
                         this.tooltip(Tooltip::text(tooltip))
                     })
@@ -189,7 +215,7 @@ impl Component for UpdateButton {
                         single_example("Checking", UpdateButton::checking().into_any_element()),
                         single_example(
                             "Downloading",
-                            UpdateButton::downloading(version).into_any_element(),
+                            UpdateButton::downloading(version, Some(0.45)).into_any_element(),
                         ),
                         single_example(
                             "Installing",
diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs
index 4b98199239b..2a3388a406a 100644
--- a/crates/ui/src/components/context_menu.rs
+++ b/crates/ui/src/components/context_menu.rs
@@ -610,9 +610,23 @@ impl ContextMenu {
     }
 
     pub fn toggleable_entry(
+        self,
+        label: impl Into,
+        toggled: bool,
+        position: IconPosition,
+        action: Option>,
+        handler: impl Fn(&mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.toggleable_entry_disabled_when(label, toggled, false, position, action, handler)
+    }
+
+    /// Like [`Self::toggleable_entry`], but the entry is rendered disabled (and its handler is not
+    /// invoked) when `disabled` is `true`.
+    pub fn toggleable_entry_disabled_when(
         mut self,
         label: impl Into,
         toggled: bool,
+        disabled: bool,
         position: IconPosition,
         action: Option>,
         handler: impl Fn(&mut Window, &mut App) + 'static,
@@ -629,7 +643,7 @@ impl ContextMenu {
             icon_size: IconSize::Small,
             icon_color: None,
             action,
-            disabled: false,
+            disabled,
             documentation_aside: None,
             end_slot_icon: None,
             end_slot_title: None,
diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs
index 609ddef1d81..8d785fec8a0 100644
--- a/crates/ui/src/components/data_table.rs
+++ b/crates/ui/src/components/data_table.rs
@@ -7,8 +7,8 @@ use crate::{
     RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes,
     ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
     StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns,
-    div, example_group_with_title, h_flex, px, render_column_resize_divider,
-    render_redistributable_columns_resize_handles, single_example,
+    div, example_group_with_title, h_flex, px, redistribute_hidden_widths,
+    render_column_resize_divider, render_redistributable_columns_resize_handles, single_example,
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
@@ -368,6 +368,9 @@ pub struct Table {
     cols: usize,
     disable_base_cell_style: bool,
     pinned_cols: usize,
+    /// Optional per-column visibility mask. When set, it overrides any filter derived from the
+    /// column width config. Columns whose entry is `true` are hidden.
+    column_filter: Option>,
 }
 
 impl Table {
@@ -387,9 +390,20 @@ impl Table {
             disable_base_cell_style: false,
             column_width_config: ColumnWidthConfig::auto(),
             pinned_cols: 0,
+            column_filter: None,
         }
     }
 
+    /// Sets a per-column visibility mask. Columns whose entry is `true` are filtered out (hidden).
+    ///
+    /// This is useful when column visibility is driven by state that isn't part of the column
+    /// width config (for example a `Static`/`Explicit` width config). When set, this overrides
+    /// any filter derived from the column width config.
+    pub fn column_filter(mut self, filter: TableRow) -> Self {
+        self.column_filter = Some(filter);
+        self
+    }
+
     /// Disables based styling of row cell (paddings, text ellipsis, nowrap, etc), keeping width settings
     ///
     /// Doesn't affect base style of header cell.
@@ -646,18 +660,28 @@ pub fn render_table_row(
         });
 
     let pinned_cols = table_context.pinned_cols;
+    let column_filter = &table_context.column_filter;
 
     if is_pinned_layout(pinned_cols, cols) {
-        let mut items_vec: Vec = items.map(IntoElement::into_any_element).into_vec();
-        let mut widths_vec: Vec> = column_widths.into_vec();
+        let items_vec: Vec = items.map(IntoElement::into_any_element).into_vec();
+        let widths_vec: Vec> = column_widths.into_vec();
 
-        let scrollable_items: Vec = items_vec.drain(pinned_cols..).collect();
-        let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect();
+        // Drop filtered columns before splitting into pinned/scrollable sections. The number of
+        // pinned columns that survive filtering determines where the kept cells are split.
+        let pinned_visible = (0..pinned_cols)
+            .filter(|&idx| column_is_visible(column_filter, idx))
+            .count();
+        let mut kept: Vec<(AnyElement, Option)> = items_vec
+            .into_iter()
+            .zip(widths_vec)
+            .enumerate()
+            .filter(|(idx, _)| column_is_visible(column_filter, *idx))
+            .map(|(_, pair)| pair)
+            .collect();
+        let scrollable: Vec<(AnyElement, Option)> = kept.drain(pinned_visible..).collect();
 
         let pinned_section = div().flex().flex_row().flex_shrink_0().children(
-            items_vec
-                .into_iter()
-                .zip(widths_vec)
+            kept.into_iter()
                 .map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
         );
 
@@ -671,9 +695,8 @@ pub fn render_table_row(
             .flex()
             .child(
                 div().flex().flex_row().children(
-                    scrollable_items
+                    scrollable
                         .into_iter()
-                        .zip(scrollable_widths)
                         .map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
                 ),
             );
@@ -691,7 +714,9 @@ pub fn render_table_row(
                 .into_vec()
                 .into_iter()
                 .zip(column_widths.into_vec())
-                .map(|(cell, width)| render_cell(width, cell, &table_context, cx)),
+                .enumerate()
+                .filter(|(idx, _)| column_is_visible(column_filter, *idx))
+                .map(|(_, (cell, width))| render_cell(width, cell, &table_context, cx)),
         );
     }
 
@@ -725,62 +750,69 @@ pub fn render_table_header(
     let shared_element_id: SharedString = format!("table-{}", element_id).into();
     let pinned_cols = table_context.pinned_cols;
 
-    let outer = div()
-        .flex()
-        .flex_row()
-        .items_center()
+    let outer = h_flex()
+        .py_1()
         .w_full()
         .border_b_1()
-        .border_color(cx.theme().colors().border);
+        .border_color(cx.theme().colors().border_variant);
 
     let use_ui_font = table_context.use_ui_font;
     let resize_info_ref = resize_info.as_ref();
+    let column_filter = &table_context.column_filter;
 
     if is_pinned_layout(pinned_cols, cols) {
-        let mut headers_vec: Vec = headers
+        let headers_vec: Vec = headers
             .into_vec()
             .into_iter()
             .map(IntoElement::into_any_element)
             .collect();
-        let mut widths_vec: Vec> = column_widths.into_vec();
+        let widths_vec: Vec> = column_widths.into_vec();
 
-        let scrollable_headers: Vec = headers_vec.drain(pinned_cols..).collect();
-        let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect();
+        // Keep the original column index alongside each visible header so that resize info
+        // (which is indexed by original column position) stays correct after filtering.
+        let pinned_visible = (0..pinned_cols)
+            .filter(|&idx| column_is_visible(column_filter, idx))
+            .count();
+        let mut kept: Vec<(usize, AnyElement, Option)> = headers_vec
+            .into_iter()
+            .zip(widths_vec)
+            .enumerate()
+            .filter(|(idx, _)| column_is_visible(column_filter, *idx))
+            .map(|(idx, (h, width))| (idx, h, width))
+            .collect();
+        let scrollable: Vec<(usize, AnyElement, Option)> =
+            kept.drain(pinned_visible..).collect();
 
         let pinned_section =
-            div().flex().flex_row().flex_shrink_0().children(
-                headers_vec.into_iter().enumerate().zip(widths_vec).map(
-                    |((header_idx, h), width)| {
-                        render_header_cell(
-                            h,
-                            width,
-                            header_idx,
-                            &shared_element_id,
-                            resize_info_ref,
-                            use_ui_font,
-                            cx,
-                        )
-                    },
-                ),
-            );
-
-        let inner = div().flex().flex_row().children(
-            scrollable_headers
-                .into_iter()
-                .enumerate()
-                .zip(scrollable_widths)
-                .map(|((rel_idx, h), width)| {
+            div()
+                .flex()
+                .flex_row()
+                .flex_shrink_0()
+                .children(kept.into_iter().map(|(header_idx, h, width)| {
                     render_header_cell(
                         h,
                         width,
-                        pinned_cols + rel_idx,
+                        header_idx,
                         &shared_element_id,
                         resize_info_ref,
                         use_ui_font,
                         cx,
                     )
-                }),
-        );
+                }));
+
+        let inner = div().flex().flex_row().children(scrollable.into_iter().map(
+            |(header_idx, h, width)| {
+                render_header_cell(
+                    h,
+                    width,
+                    header_idx,
+                    &shared_element_id,
+                    resize_info_ref,
+                    use_ui_font,
+                    cx,
+                )
+            },
+        ));
         let mut scrollable_section = div()
             .id("table-header-scrollable")
             .flex_grow_1()
@@ -805,6 +837,7 @@ pub fn render_table_header(
                     .into_iter()
                     .enumerate()
                     .zip(column_widths.into_vec())
+                    .filter(|((idx, _), _)| column_is_visible(column_filter, *idx))
                     .map(|((header_idx, h), width)| {
                         render_header_cell(
                             h.into_any_element(),
@@ -832,6 +865,9 @@ pub struct TableRenderContext {
     pub use_ui_font: bool,
     pub disable_base_cell_style: bool,
     pub pinned_cols: usize,
+    /// Per-column visibility mask. When `Some`, columns whose entry is `true` are filtered
+    /// out (hidden) and not rendered. Indices map to the table's original column positions.
+    pub column_filter: Option>,
     /// Scroll handle shared by all scrollable sections in rows and headers.
     /// When `pinned_cols > 0`, each row's scrollable section tracks this handle so all rows
     /// scroll together without requiring per-scroll re-renders.
@@ -845,11 +881,15 @@ impl TableRenderContext {
             show_row_borders: table.show_row_borders,
             show_row_hover: table.show_row_hover,
             total_row_count: table.rows.len(),
-            column_widths: table.column_width_config.widths_to_render(cx),
+            column_widths: table
+                .column_width_config
+                .widths_to_render(cx)
+                .map(|widths| redistribute_hidden_widths(&widths, table.column_filter.as_ref())),
             map_row: table.map_row.clone(),
             use_ui_font: table.use_ui_font,
             disable_base_cell_style: table.disable_base_cell_style,
             pinned_cols: table.pinned_cols,
+            column_filter: table.column_filter.clone(),
             h_scroll_handle,
         }
     }
@@ -865,9 +905,23 @@ impl TableRenderContext {
             use_ui_font,
             disable_base_cell_style: false,
             pinned_cols: 0,
+            column_filter: None,
             h_scroll_handle: None,
         }
     }
+
+    /// Sets the per-column visibility mask. Columns whose entry is `true` are hidden.
+    pub fn with_column_filter(mut self, column_filter: Option>) -> Self {
+        self.column_filter = column_filter;
+        self
+    }
+}
+
+/// Returns `true` when the column at `col_idx` should be rendered (i.e. not filtered out).
+fn column_is_visible(filter: &Option>, col_idx: usize) -> bool {
+    filter
+        .as_ref()
+        .map_or(true, |mask| !mask.get(col_idx).copied().unwrap_or(false))
 }
 
 /// Builds resize dividers for the given column range, positioned absolutely from `left: 0`.
@@ -1090,6 +1144,7 @@ impl RenderOnce for Table {
                         None,
                         Some(render_redistributable_columns_resize_handles(
                             columns_state,
+                            self.column_filter.as_ref(),
                             window,
                             cx,
                         )),
@@ -1127,7 +1182,7 @@ impl RenderOnce for Table {
                 ))
             })
             .when_some(redistributable_entity, |this, widths| {
-                bind_redistributable_columns(this, widths)
+                bind_redistributable_columns(this, widths, self.column_filter.clone())
             })
             .when_some(resizable_entity, |this, entity| {
                 let scroll_handle_for_drag = h_scroll_handle.clone();
diff --git a/crates/ui/src/components/data_table/table_row.rs b/crates/ui/src/components/data_table/table_row.rs
index 9ef75e4cbbb..94d9910df33 100644
--- a/crates/ui/src/components/data_table/table_row.rs
+++ b/crates/ui/src/components/data_table/table_row.rs
@@ -74,6 +74,10 @@ impl TableRow {
         &self.0
     }
 
+    pub fn as_mut_slice(&mut self) -> &mut [T] {
+        &mut self.0
+    }
+
     pub fn into_vec(self) -> Vec {
         self.0
     }
diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs
index 509cceca712..652ef47467f 100644
--- a/crates/ui/src/components/data_table/tests.rs
+++ b/crates/ui/src/components/data_table/tests.rs
@@ -319,6 +319,130 @@ mod drag_handle {
     );
 }
 
+mod drag_with_hidden_columns {
+    use super::*;
+
+    // Dragging with hidden (filtered) columns: the resize dividers are laid out using the
+    // *redistributed* (visible-only) widths, so `compute_drag_preview` must do its geometry in
+    // that same space and skip hidden columns when propagating the resize to a neighbor.
+
+    /// Mirrors how the renderer turns raw widths into the on-screen layout: hidden columns
+    /// collapse to zero and their space is redistributed across the visible columns.
+    fn redistributed(widths: &[f32], hidden: &[bool]) -> Vec {
+        let visible_sum: f32 = widths
+            .iter()
+            .zip(hidden)
+            .filter(|(_, is_hidden)| !**is_hidden)
+            .map(|(width, _)| *width)
+            .sum();
+        widths
+            .iter()
+            .zip(hidden)
+            .map(|(width, is_hidden)| {
+                if *is_hidden {
+                    0.0
+                } else {
+                    *width / visible_sum
+                }
+            })
+            .collect()
+    }
+
+    #[test]
+    fn drag_without_hidden_columns_is_unchanged() {
+        // Guards the pre-existing behavior: with no hidden mask the drag operates on the raw
+        // widths directly (the visible space and the raw space are the same).
+        let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3);
+        let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3);
+
+        let result = RedistributableColumnsState::compute_drag_preview(
+            widths,
+            &resize_behavior,
+            None,
+            1,
+            0.8,
+            0.0,
+        );
+
+        let result = result.as_slice();
+        let boundary = result[0] + result[1];
+        assert!(
+            (boundary - 0.8).abs() < 1e-6,
+            "expected the boundary after column 1 to follow the cursor to 0.8: {result:?}",
+        );
+        assert!(
+            (result[0] - 1.0 / 3.0).abs() < 1e-6,
+            "column 0 must not be affected: {result:?}",
+        );
+    }
+
+    #[test]
+    fn drag_boundary_follows_cursor_with_hidden_column() {
+        // Three equal columns; column 0 is hidden. The two visible columns each render at 0.5
+        // of the container. The user grabs the divider between the visible columns (original
+        // index 1) and drags the cursor to 70% of the container. The boundary between the
+        // visible columns should follow the cursor to 0.7.
+        let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3);
+        let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3);
+        let hidden = [true, false, false];
+        let hidden_mask = TableRow::from_vec(hidden.to_vec(), 3);
+
+        let result = RedistributableColumnsState::compute_drag_preview(
+            widths,
+            &resize_behavior,
+            Some(&hidden_mask),
+            1,
+            0.7,
+            0.0,
+        );
+
+        let rendered = redistributed(result.as_slice(), &hidden);
+        assert!(
+            (rendered[1] - 0.7).abs() < 1e-3,
+            "expected the visible boundary to follow the cursor to 0.7, got {} (raw {:?})",
+            rendered[1],
+            result.as_slice(),
+        );
+    }
+
+    #[test]
+    fn drag_does_not_resize_hidden_neighbor() {
+        // Three equal columns; the middle column (1) is hidden. The only divider the user can
+        // grab sits between visible columns 0 and 2 (original index 0). Dragging it must resize
+        // the next *visible* column (2) and leave the hidden column's width untouched.
+        let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3);
+        let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3);
+        let hidden = [false, true, false];
+        let hidden_mask = TableRow::from_vec(hidden.to_vec(), 3);
+
+        let result = RedistributableColumnsState::compute_drag_preview(
+            widths,
+            &resize_behavior,
+            Some(&hidden_mask),
+            0,
+            0.7,
+            0.0,
+        );
+
+        let result = result.as_slice();
+        assert!(
+            (result[1] - 1.0 / 3.0).abs() < 1e-6,
+            "hidden column width must be preserved, but it changed: {result:?}",
+        );
+        // The drag moved width from visible column 2 to visible column 0, so the total is
+        // unchanged and the next *visible* column absorbed the resize.
+        let total: f32 = result.iter().sum();
+        assert!(
+            (total - 1.0).abs() < 1e-6,
+            "total must be preserved: {result:?}"
+        );
+        assert!(
+            result[0] > 1.0 / 3.0 && result[2] < 1.0 / 3.0,
+            "expected the resize to be absorbed by the next visible column: {result:?}",
+        );
+    }
+}
+
 mod resizable_drag {
     use super::*;
 
@@ -419,3 +543,115 @@ mod pin_layout {
         assert!(is_pinned_layout(4, 5));
     }
 }
+
+mod column_filter {
+    use super::super::column_is_visible;
+    use super::*;
+    use crate::{redistribute_hidden_fractions, redistribute_hidden_widths};
+    use gpui::{DefiniteLength, Length};
+
+    fn frac_row(values: &[f32]) -> TableRow {
+        TableRow::from_vec(values.to_vec(), values.len())
+    }
+
+    fn hidden_row(values: &[bool]) -> TableRow {
+        TableRow::from_vec(values.to_vec(), values.len())
+    }
+
+    fn width_row(values: &[f32]) -> TableRow {
+        TableRow::from_vec(
+            values
+                .iter()
+                .map(|fraction| Length::Definite(DefiniteLength::Fraction(*fraction)))
+                .collect(),
+            values.len(),
+        )
+    }
+
+    fn width_fractions(widths: &TableRow) -> Vec {
+        widths
+            .as_slice()
+            .iter()
+            .map(|length| match length {
+                Length::Definite(DefiniteLength::Fraction(fraction)) => *fraction,
+                other => panic!("expected fraction, got {other:?}"),
+            })
+            .collect()
+    }
+
+    #[test]
+    fn column_is_visible_respects_mask() {
+        let filter = Some(hidden_row(&[false, true, false]));
+        assert!(column_is_visible(&filter, 0));
+        assert!(!column_is_visible(&filter, 1));
+        assert!(column_is_visible(&filter, 2));
+        // Indices outside the mask default to visible.
+        assert!(column_is_visible(&filter, 5));
+    }
+
+    #[test]
+    fn column_is_visible_without_filter_is_always_visible() {
+        let filter: Option> = None;
+        assert!(column_is_visible(&filter, 0));
+        assert!(column_is_visible(&filter, 100));
+    }
+
+    #[test]
+    fn redistribute_widths_is_identity_without_hidden_columns() {
+        let widths = width_row(&[0.25, 0.25, 0.25, 0.25]);
+        assert_eq!(
+            width_fractions(&redistribute_hidden_widths(&widths, None)),
+            vec![0.25, 0.25, 0.25, 0.25]
+        );
+
+        let none_hidden = hidden_row(&[false, false, false, false]);
+        assert_eq!(
+            width_fractions(&redistribute_hidden_widths(&widths, Some(&none_hidden))),
+            vec![0.25, 0.25, 0.25, 0.25]
+        );
+    }
+
+    #[test]
+    fn redistribute_widths_scales_visible_columns_to_fill() {
+        let widths = width_row(&[0.25, 0.25, 0.25, 0.25]);
+        let hidden = hidden_row(&[false, true, false, false]);
+        let result = width_fractions(&redistribute_hidden_widths(&widths, Some(&hidden)));
+
+        // The hidden column keeps its stored width rather than being zeroed out (it is simply
+        // not rendered), so its width is restored intact when it is shown again.
+        assert_eq!(result[1], 0.25);
+        // The visible columns expand to fill the container.
+        let visible_sum: f32 = result[0] + result[2] + result[3];
+        assert!(
+            (visible_sum - 1.0).abs() < 1e-6,
+            "visible sum was {visible_sum}"
+        );
+        // Equal initial fractions stay equal after redistribution.
+        assert!((result[0] - result[2]).abs() < 1e-6);
+        assert!((result[0] - result[3]).abs() < 1e-6);
+    }
+
+    #[test]
+    fn redistribute_fractions_scales_visible_columns_to_fill() {
+        let fractions = frac_row(&[0.25, 0.25, 0.25, 0.25]);
+        let hidden = hidden_row(&[false, true, false, false]);
+        let result = redistribute_hidden_fractions(&fractions, Some(&hidden));
+        let result = result.as_slice();
+
+        assert_eq!(result[1], 0.25);
+        let visible_sum: f32 = result[0] + result[2] + result[3];
+        assert!(
+            (visible_sum - 1.0).abs() < 1e-6,
+            "visible sum was {visible_sum}"
+        );
+    }
+
+    #[test]
+    fn redistribute_fractions_is_identity_without_hidden_columns() {
+        let fractions = frac_row(&[0.2, 0.3, 0.5]);
+        assert_eq!(
+            redistribute_hidden_fractions(&fractions, None).as_slice(),
+            &[0.2, 0.3, 0.5]
+        );
+    }
+}
diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs
index 74c697a76ac..7e343867f14 100644
--- a/crates/ui/src/components/divider.rs
+++ b/crates/ui/src/components/divider.rs
@@ -2,26 +2,6 @@ use gpui::{Hsla, IntoElement, PathBuilder, Refineable as _, StyleRefinement, can
 
 use crate::prelude::*;
 
-pub fn divider() -> Divider {
-    Divider {
-        line_style: DividerStyle::Solid,
-        direction: DividerDirection::Horizontal,
-        color: DividerColor::default(),
-        style: StyleRefinement::default(),
-        inset: false,
-    }
-}
-
-pub fn vertical_divider() -> Divider {
-    Divider {
-        line_style: DividerStyle::Solid,
-        direction: DividerDirection::Vertical,
-        color: DividerColor::default(),
-        style: StyleRefinement::default(),
-        inset: false,
-    }
-}
-
 #[derive(Clone, Copy, PartialEq)]
 enum DividerStyle {
     Solid,
@@ -166,7 +146,7 @@ impl RenderOnce for Divider {
             DividerDirection::Vertical => div()
                 .min_w_0()
                 .w_px()
-                .h_full()
+                .h_4()
                 .when(self.inset, |this| this.my_1p5()),
         };
 
diff --git a/crates/ui/src/components/indent_guides.rs b/crates/ui/src/components/indent_guides.rs
index 9e78b0d4bf6..f83dda592f6 100644
--- a/crates/ui/src/components/indent_guides.rs
+++ b/crates/ui/src/components/indent_guides.rs
@@ -28,9 +28,14 @@ impl IndentGuideColors {
     }
 }
 
+/// Horizontal offset that lines an indent guide up with the icon column of a
+/// standard [`ListItem`](crate::ListItem)-based row.
+pub const LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET: Pixels = px(15.);
+
 pub struct IndentGuides {
     colors: IndentGuideColors,
     indent_size: Pixels,
+    left_offset: Pixels,
     compute_indents_fn:
         Option, &mut Window, &mut App) -> SmallVec<[usize; 64]>>>,
     render_fn: Option<
@@ -49,6 +54,7 @@ pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGu
     IndentGuides {
         colors,
         indent_size,
+        left_offset: px(0.),
         compute_indents_fn: None,
         render_fn: None,
         on_click: None,
@@ -56,6 +62,15 @@ pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGu
 }
 
 impl IndentGuides {
+    /// Sets a horizontal offset applied to every guide, used to line the guides
+    /// up with the icon column of the list's rows. Ignored when a custom render
+    /// function is set via [`Self::with_render_fn`], which is responsible for its
+    /// own positioning.
+    pub fn with_left_offset(mut self, left_offset: Pixels) -> Self {
+        self.left_offset = left_offset;
+        self
+    }
+
     /// Sets the callback that will be called when the user clicks on an indent guide.
     pub fn on_click(
         mut self,
@@ -124,7 +139,7 @@ impl IndentGuides {
                 .map(|layout| RenderedIndentGuide {
                     bounds: Bounds::new(
                         point(
-                            layout.offset.x * self.indent_size,
+                            layout.offset.x * self.indent_size + self.left_offset,
                             layout.offset.y * item_height,
                         ),
                         size(px(1.), layout.length * item_height),
diff --git a/crates/ui/src/components/progress/circular_progress.rs b/crates/ui/src/components/progress/circular_progress.rs
index 69f93b740cd..26e38258f94 100644
--- a/crates/ui/src/components/progress/circular_progress.rs
+++ b/crates/ui/src/components/progress/circular_progress.rs
@@ -11,6 +11,7 @@ pub struct CircularProgress {
     max_value: f32,
     size: Pixels,
     stroke_width: Pixels,
+    radius: Option,
     bg_color: Hsla,
     progress_color: Hsla,
 }
@@ -22,6 +23,7 @@ impl CircularProgress {
             max_value,
             size,
             stroke_width: px(4.0),
+            radius: None,
             bg_color: cx.theme().colors().border_variant,
             progress_color: cx.theme().status().info,
         }
@@ -51,6 +53,16 @@ impl CircularProgress {
         self
     }
 
+    /// Sets the ring radius explicitly, decoupling it from the layout box.
+    ///
+    /// By default the radius is derived from the size and stroke width so
+    /// the ring fills the box. Set it explicitly to draw a smaller ring
+    /// inside the box, e.g. to match the padding of an icon glyph.
+    pub fn radius(mut self, radius: Pixels) -> Self {
+        self.radius = Some(radius);
+        self
+    }
+
     /// Sets the background circle color.
     pub fn bg_color(mut self, color: Hsla) -> Self {
         self.bg_color = color;
@@ -69,6 +81,8 @@ impl RenderOnce for CircularProgress {
         let value = self.value;
         let max_value = self.max_value;
         let size = self.size;
+        let stroke_width = self.stroke_width;
+        let radius = self.radius.unwrap_or_else(|| (size / 2.0) - stroke_width);
         let bg_color = self.bg_color;
         let progress_color = self.progress_color;
 
@@ -80,9 +94,6 @@ impl RenderOnce for CircularProgress {
                 let center_x = bounds.origin.x + bounds.size.width / 2.0;
                 let center_y = bounds.origin.y + bounds.size.height / 2.0;
 
-                let stroke_width = self.stroke_width;
-                let radius = (size / 2.0) - stroke_width;
-
                 // Draw background circle (full 360 degrees)
                 let mut bg_builder = PathBuilder::stroke(stroke_width);
 
diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs
index cb5da35d565..0fa93c8ba35 100644
--- a/crates/ui/src/components/redistributable_columns.rs
+++ b/crates/ui/src/components/redistributable_columns.rs
@@ -1,11 +1,3 @@
-use std::rc::Rc;
-
-use gpui::{
-    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity,
-    EntityId, Length, Stateful, WeakEntity,
-};
-use itertools::intersperse_with;
-
 use super::data_table::{
     ResizableColumnsState,
     table_row::{IntoTableRow as _, TableRow},
@@ -15,6 +7,11 @@ use crate::{
     IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
     px,
 };
+use gpui::{
+    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity,
+    EntityId, Length, Stateful, WeakEntity,
+};
+use std::rc::Rc;
 
 pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0;
 pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
@@ -270,6 +267,7 @@ impl RedistributableColumnsState {
     fn on_drag_move(
         &mut self,
         drag_event: &DragMoveEvent,
+        hidden: Option<&TableRow>,
         window: &mut Window,
         cx: &mut Context,
     ) {
@@ -280,7 +278,6 @@ impl RedistributableColumnsState {
             return;
         }
 
-        let mut col_position = 0.0;
         let rem_size = window.rem_size();
         let col_idx = drag_event.drag(cx).col_idx;
 
@@ -290,28 +287,107 @@ impl RedistributableColumnsState {
             rem_size,
         );
 
-        let mut widths = self
+        let widths = self
             .committed_widths
             .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
 
-        for length in widths[0..=col_idx].iter() {
+        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
+
+        let widths = Self::compute_drag_preview(
+            widths,
+            &self.resize_behavior,
+            hidden,
+            col_idx,
+            drag_fraction,
+            divider_width,
+        );
+
+        self.preview_widths = widths.map(DefiniteLength::Fraction);
+    }
+
+    /// Computes the preview column fractions produced by dragging the divider after `col_idx`
+    /// to `drag_fraction` (the cursor's x position expressed as a fraction of the container
+    /// width). `divider_width` is the resize-divider width as a fraction of the container.
+    ///
+    /// The on-screen layout only contains the visible columns, with the hidden columns' width
+    /// budget redistributed across them (see [`redistribute_hidden_widths`]), so the geometry
+    /// here is done in that visible/redistributed space: the raw `widths` are compacted to the
+    /// visible columns and scaled to match the rendered layout, the drag is applied there (which
+    /// also makes neighbor propagation skip hidden columns), and the result is mapped back to
+    /// the raw widths, leaving hidden columns untouched.
+    ///
+    /// Extracted as a pure function so the drag math can be unit tested, mirroring the
+    /// `drag_column_handle` / `propagate_resize_diff` helpers.
+    pub(crate) fn compute_drag_preview(
+        mut widths: TableRow,
+        resize_behavior: &TableRow,
+        hidden: Option<&TableRow>,
+        col_idx: usize,
+        drag_fraction: f32,
+        divider_width: f32,
+    ) -> TableRow {
+        let visible_cols: Vec = (0..widths.cols())
+            .filter(|idx| !is_column_hidden(hidden, *idx))
+            .collect();
+
+        // Dividers are only rendered after visible columns, so a hidden `col_idx` should be
+        // impossible; bail out rather than resizing the wrong column.
+        let Some(divider_position) = visible_cols.iter().position(|&idx| idx == col_idx) else {
+            return widths;
+        };
+
+        let total_sum: f32 = widths.as_slice().iter().sum();
+        let visible_sum: f32 = visible_cols.iter().map(|&idx| widths[idx]).sum();
+        // The drag only moves width between visible columns, so `visible_sum` (and therefore
+        // this scale) is the same before and after the drag, making the mapping back exact.
+        let scale = if visible_sum > 0.0 {
+            total_sum / visible_sum
+        } else {
+            1.0
+        };
+
+        let mut rendered_widths = TableRow::from_vec(
+            visible_cols
+                .iter()
+                .map(|&idx| widths[idx] * scale)
+                .collect(),
+            visible_cols.len(),
+        );
+        let rendered_behavior = TableRow::from_vec(
+            visible_cols
+                .iter()
+                .map(|&idx| resize_behavior[idx])
+                .collect(),
+            visible_cols.len(),
+        );
+
+        let mut col_position = 0.0;
+        for length in rendered_widths[0..=divider_position].iter() {
             col_position += length + divider_width;
         }
 
         let mut total_length_ratio = col_position;
-        for length in widths[col_idx + 1..].iter() {
+        for length in rendered_widths[divider_position + 1..].iter() {
             total_length_ratio += length;
         }
-        let cols = self.resize_behavior.cols();
-        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
+        let cols = rendered_behavior.cols();
+        total_length_ratio += (cols - 1 - divider_position) as f32 * divider_width;
 
-        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
         let drag_fraction = drag_fraction * total_length_ratio;
         let diff = drag_fraction - col_position - divider_width / 2.0;
 
-        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
+        Self::drag_column_handle(
+            diff,
+            divider_position,
+            &mut rendered_widths,
+            &rendered_behavior,
+        );
 
-        self.preview_widths = widths.map(DefiniteLength::Fraction);
+        for (visible_position, &idx) in visible_cols.iter().enumerate() {
+            widths[idx] = rendered_widths[visible_position] / scale;
+        }
+
+        widths
     }
 
     pub(crate) fn drag_column_handle(
@@ -385,9 +461,99 @@ impl RedistributableColumnsState {
     }
 }
 
+/// Returns `true` when the column at `idx` is hidden by `hidden`.
+pub fn is_column_hidden(hidden: Option<&TableRow>, idx: usize) -> bool {
+    hidden
+        .and_then(|mask| mask.get(idx).copied())
+        .unwrap_or(false)
+}
+
+/// Redistributes the fractional width budget of hidden columns across the visible columns so the
+/// visible columns fill the container instead of leaving a gap. Hidden columns keep their stored
+/// width (they are never rendered, so the value is not shown) and `Absolute` widths are left
+/// untouched. Returns the widths unchanged when no column is hidden.
+pub fn redistribute_hidden_widths(
+    widths: &TableRow,
+    hidden: Option<&TableRow>,
+) -> TableRow {
+    if !(0..widths.cols()).any(|idx| is_column_hidden(hidden, idx)) {
+        return widths.clone();
+    }
+
+    let mut total_fraction_sum = 0.0;
+    let mut visible_fraction_sum = 0.0;
+    for (idx, width) in widths.as_slice().iter().enumerate() {
+        if let Length::Definite(DefiniteLength::Fraction(fraction)) = width {
+            total_fraction_sum += *fraction;
+            if !is_column_hidden(hidden, idx) {
+                visible_fraction_sum += *fraction;
+            }
+        }
+    }
+    let scale = if visible_fraction_sum > 0.0 {
+        total_fraction_sum / visible_fraction_sum
+    } else {
+        1.0
+    };
+
+    let scaled: Vec = widths
+        .as_slice()
+        .iter()
+        .enumerate()
+        .map(|(idx, width)| match width {
+            Length::Definite(DefiniteLength::Fraction(fraction))
+                if !is_column_hidden(hidden, idx) =>
+            {
+                Length::Definite(DefiniteLength::Fraction(fraction * scale))
+            }
+            other => *other,
+        })
+        .collect();
+    TableRow::from_vec(scaled, widths.cols())
+}
+
+/// Fraction-valued counterpart of [`redistribute_hidden_widths`].
+pub fn redistribute_hidden_fractions(
+    fractions: &TableRow,
+    hidden: Option<&TableRow>,
+) -> TableRow {
+    if !(0..fractions.cols()).any(|idx| is_column_hidden(hidden, idx)) {
+        return fractions.clone();
+    }
+
+    let total_sum: f32 = fractions.as_slice().iter().sum();
+    let visible_sum: f32 = fractions
+        .as_slice()
+        .iter()
+        .enumerate()
+        .filter(|(idx, _)| !is_column_hidden(hidden, *idx))
+        .map(|(_, fraction)| *fraction)
+        .sum();
+    let scale = if visible_sum > 0.0 {
+        total_sum / visible_sum
+    } else {
+        1.0
+    };
+
+    let scaled: Vec = fractions
+        .as_slice()
+        .iter()
+        .enumerate()
+        .map(|(idx, fraction)| {
+            if is_column_hidden(hidden, idx) {
+                *fraction
+            } else {
+                fraction * scale
+            }
+        })
+        .collect();
+    TableRow::from_vec(scaled, fractions.cols())
+}
+
 pub fn bind_redistributable_columns(
     container: Div,
     columns_state: Entity,
+    hidden: Option>,
 ) -> Div {
     container
         .on_drag_move::({
@@ -397,7 +563,7 @@ pub fn bind_redistributable_columns(
                     return;
                 }
                 columns_state.update(cx, |columns, cx| {
-                    columns.on_drag_move(event, window, cx);
+                    columns.on_drag_move(event, hidden.as_ref(), window, cx);
                 });
             }
         })
@@ -420,65 +586,70 @@ pub fn bind_redistributable_columns(
 
 pub fn render_redistributable_columns_resize_handles(
     columns_state: &Entity,
+    hidden: Option<&TableRow>,
     window: &mut Window,
     cx: &mut App,
 ) -> AnyElement {
     let (column_widths, resize_behavior) = {
         let state = columns_state.read(cx);
-        (state.widths_to_render(), state.resize_behavior().clone())
+        (
+            redistribute_hidden_widths(&state.widths_to_render(), hidden),
+            state.resize_behavior().clone(),
+        )
     };
 
-    let mut column_ix = 0;
-    let resize_behavior = Rc::new(resize_behavior);
-    let dividers = intersperse_with(
-        column_widths
-            .as_slice()
-            .iter()
-            .copied()
-            .map(|width| resize_spacer(width).into_any_element()),
-        || {
-            let current_column_ix = column_ix;
-            let resize_behavior = Rc::clone(&resize_behavior);
-            let columns_state = columns_state.clone();
-            column_ix += 1;
+    // Only the visible columns participate in the layout; filtered columns are skipped entirely
+    // (no spacer, no divider) so we don't draw a stray resize line where a hidden column was.
+    let visible_cols: Vec = (0..column_widths.cols())
+        .filter(|idx| !is_column_hidden(hidden, *idx))
+        .collect();
 
-            {
-                let divider = div().id(current_column_ix).relative().top_0();
-                let entity_id = columns_state.entity_id();
-                let on_reset: Rc = {
-                    let columns_state = columns_state.clone();
-                    Rc::new(move |window, cx| {
-                        columns_state.update(cx, |columns, cx| {
-                            columns.reset_column_to_initial_width(current_column_ix, window);
-                            cx.notify();
-                        });
-                    })
-                };
-                let on_drag_end: Option> = {
-                    Some(Rc::new(move |cx| {
-                        columns_state.update(cx, |state, _| state.commit_preview());
-                    }))
-                };
-                render_column_resize_divider(
-                    divider,
-                    current_column_ix,
-                    resize_behavior[current_column_ix].is_resizable(),
-                    entity_id,
-                    on_reset,
-                    on_drag_end,
-                    window,
-                    cx,
-                )
-            }
-        },
-    );
+    let mut children: Vec = Vec::with_capacity(visible_cols.len() * 2);
+    for (position, &col_idx) in visible_cols.iter().enumerate() {
+        children.push(resize_spacer(column_widths[col_idx]).into_any_element());
+
+        // A divider is rendered after every visible column except the last, mirroring the
+        // original `intersperse` behavior but in terms of visible columns.
+        let is_last_visible = position + 1 == visible_cols.len();
+        if is_last_visible {
+            continue;
+        }
+
+        let columns_state = columns_state.clone();
+        let divider = div().id(col_idx).relative().top_0();
+        let entity_id = columns_state.entity_id();
+        let on_reset: Rc = {
+            let columns_state = columns_state.clone();
+            Rc::new(move |window, cx| {
+                columns_state.update(cx, |columns, cx| {
+                    columns.reset_column_to_initial_width(col_idx, window);
+                    cx.notify();
+                });
+            })
+        };
+        let on_drag_end: Option> = {
+            Some(Rc::new(move |cx| {
+                columns_state.update(cx, |state, _| state.commit_preview());
+            }))
+        };
+        children.push(render_column_resize_divider(
+            divider,
+            col_idx,
+            resize_behavior[col_idx].is_resizable(),
+            entity_id,
+            on_reset,
+            on_drag_end,
+            window,
+            cx,
+        ));
+    }
 
     h_flex()
         .id("resize-handles")
         .absolute()
         .inset_0()
         .w_full()
-        .children(dividers)
+        .children(children)
         .into_any_element()
 }
 
diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs
index 274fabac9a7..df6a911329b 100644
--- a/crates/vim/src/command.rs
+++ b/crates/vim/src/command.rs
@@ -835,8 +835,8 @@ pub fn register(editor: &mut Editor, cx: &mut Context) {
                         {
                             let last_sel = editor.selections.disjoint_anchors_arc();
                             editor.modify_transaction_selection_history(tx_id, |old| {
-                                old.0 = old.0.get(..1).unwrap_or(&[]).into();
-                                old.1 = Some(last_sel);
+                                old.undo = old.undo.get(..1).unwrap_or(&[]).into();
+                                old.redo = Some(last_sel);
                             });
                         }
                     });
diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs
index 2642221b31c..2ab20aef36e 100644
--- a/crates/vim/src/helix.rs
+++ b/crates/vim/src/helix.rs
@@ -2341,6 +2341,31 @@ mod test {
         );
     }
 
+    // Deleting a selection that ends at the last non-newline character should
+    // leave the cursor on the newline (matching Helix), not clamp it onto the
+    // character to the left of the selection.
+    #[gpui::test]
+    async fn test_delete_to_end_of_line_keeps_cursor_on_newline(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state(
+            indoc! {"
+            ab«cdefgˇ»
+            hij"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("d");
+
+        cx.assert_state(
+            indoc! {"
+            abˇ
+            hij"},
+            Mode::HelixNormal,
+        );
+    }
+
     // #[gpui::test]
     // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) {
     //     let mut cx = VimTestContext::new(cx, true).await;
@@ -2985,11 +3010,11 @@ mod test {
         cx.simulate_keystrokes("v g l d");
         cx.assert_state("ˇ\nfox jumps over", Mode::HelixNormal);
 
-        // same from the middle of a line — cursor lands on the last
-        // remaining character (the space) after delete
+        // same from the middle of a line — the cursor rests on the trailing
+        // newline, matching Helix and the whole-line case above.
         cx.set_state("The ˇquick brown\nfox jumps over", Mode::HelixNormal);
         cx.simulate_keystrokes("v g l d");
-        cx.assert_state("Theˇ \nfox jumps over", Mode::HelixNormal);
+        cx.assert_state("The ˇ\nfox jumps over", Mode::HelixNormal);
     }
 
     #[gpui::test]
@@ -4325,7 +4350,7 @@ mod test {
             .set_request_handler::(
                 move |_, params, _| async move {
                     assert_eq!(params.position, expected_position);
-                    Ok(Some(lsp::PrepareRenameResponse::Range(def_range)))
+                    Ok(Some(lsp::PrepareRenameResponse::Range(tgt_range)))
                 },
             );
         let mut rename_request = cx.set_request_handler::(
diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs
index d91af01a542..389eeb88e61 100644
--- a/crates/vim/src/normal.rs
+++ b/crates/vim/src/normal.rs
@@ -160,10 +160,11 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) {
         let transaction_id = vim.visual_delete(false, window, cx);
         if let (Some(original_selections), Some(transaction_id)) =
             (original_selections, transaction_id)
+            && !original_selections.is_empty()
         {
             let updated = vim.update_editor(cx, |_, editor, _| {
                 editor.modify_transaction_selection_history(transaction_id, |selections| {
-                    selections.0 = original_selections;
+                    selections.undo = original_selections;
                 })
             });
             debug_assert_ne!(updated, Some(false));
diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs
index c426127dc07..4a874591f72 100644
--- a/crates/vim/src/test.rs
+++ b/crates/vim/src/test.rs
@@ -1272,7 +1272,7 @@ async fn test_visual_rename_uses_visible_cursor_position(cx: &mut gpui::TestAppC
     let mut prepare_request = cx.set_request_handler::(
         move |_, params, _| async move {
             assert_eq!(params.position, expected_position);
-            Ok(Some(lsp::PrepareRenameResponse::Range(def_range)))
+            Ok(Some(lsp::PrepareRenameResponse::Range(tgt_range)))
         },
     );
     let mut rename_request =
diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs
index e715e98a655..e9c3f22c23c 100644
--- a/crates/vim/src/visual.rs
+++ b/crates/vim/src/visual.rs
@@ -695,8 +695,9 @@ impl Vim {
                 }
                 editor.delete_selections_with_linked_edits(window, cx);
 
-                // Fixup cursor position after the deletion
-                editor.set_clip_at_line_ends(true, cx);
+                // Fixup cursor position after the deletion. Helix keeps the
+                // cursor on the trailing newline, so only clip in Vim modes.
+                editor.set_clip_at_line_ends(!vim.mode.is_helix(), cx);
                 editor.change_selections(Default::default(), window, cx, |s| {
                     s.move_with(&mut |map, selection| {
                         let mut cursor = selection.head().to_point(map);
diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs
index 5fd304b7338..11461d38ee3 100644
--- a/crates/workspace/src/multi_workspace.rs
+++ b/crates/workspace/src/multi_workspace.rs
@@ -1228,7 +1228,9 @@ impl MultiWorkspace {
         window: &mut Window,
         cx: &mut Context,
     ) -> Task>> {
-        if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) {
+        if let Some(workspace) =
+            self.workspace_for_paths_excluding(&paths, host.as_ref(), excluding, cx)
+        {
             self.activate(workspace.clone(), source_workspace, window, cx);
             return Task::ready(Ok(workspace));
         }
diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs
index 72c81503173..e0a6bb1e0eb 100644
--- a/crates/workspace/src/multi_workspace_tests.rs
+++ b/crates/workspace/src/multi_workspace_tests.rs
@@ -506,6 +506,67 @@ async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_mis
     });
 }
 
+#[gpui::test]
+async fn test_remove_fallback_via_find_or_create_skips_removed_workspaces(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+    let project_b = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+    let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b, window, cx)
+    });
+    cx.run_until_parked();
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.activate(workspace_a.clone(), None, window, cx);
+    });
+
+    let removed = multi_workspace
+        .update_in(cx, |mw, window, cx| {
+            let excluded = vec![workspace_a.clone()];
+            mw.remove(
+                excluded.clone(),
+                move |this, window, cx| {
+                    this.find_or_create_workspace(
+                        PathList::new(&[PathBuf::from("/root_a")]),
+                        None,
+                        None,
+                        |_options, _window, _cx| Task::ready(Ok(None)),
+                        &excluded,
+                        None,
+                        OpenMode::Activate,
+                        window,
+                        cx,
+                    )
+                },
+                window,
+                cx,
+            )
+        })
+        .await
+        .expect("removing the active workspace should succeed");
+    assert!(removed, "the workspace should have been removed");
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(
+            mw.workspace().entity_id(),
+            workspace_b.entity_id(),
+            "the non-excluded workspace should become active"
+        );
+        assert!(
+            mw.workspaces()
+                .all(|workspace| workspace.entity_id() != workspace_a.entity_id()),
+            "the removed workspace should be gone"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
     cx: &mut TestAppContext,
@@ -785,6 +846,7 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont
                 updated_repositories: vec![],
                 removed_repositories: vec![],
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             });
     });
     cx.run_until_parked();
diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs
index ea3091a6fff..47ae6942010 100644
--- a/crates/workspace/src/pane.rs
+++ b/crates/workspace/src/pane.rs
@@ -50,7 +50,8 @@ use ui::{
     Tooltip, prelude::*, right_click_menu,
 };
 use util::{
-    ResultExt, debug_panic, maybe, paths::PathStyle, serde::default_true, truncate_and_remove_front,
+    ResultExt, debug_panic, markdown::MarkdownInlineCode, maybe, paths::PathStyle,
+    serde::default_true, truncate_and_remove_front,
 };
 
 /// A selected entry in e.g. project panel.
@@ -2773,6 +2774,54 @@ impl Pane {
         self.activate_item(index, true, true, window, cx);
     }
 
+    fn tab_icon_element(
+        &self,
+        item: &dyn ItemHandle,
+        is_active: bool,
+        window: &Window,
+        cx: &App,
+    ) -> Option {
+        let icon = item
+            .tab_icon(window, cx)?
+            .size(IconSize::Small)
+            .color(Color::Muted);
+
+        let item_diagnostic = item
+            .project_path(cx)
+            .and_then(|project_path| self.diagnostics.get(&project_path));
+
+        let Some(diagnostic) = item_diagnostic else {
+            return Some(icon.into_any_element());
+        };
+
+        let knockout_item_color = if is_active {
+            cx.theme().colors().tab_active_background
+        } else {
+            cx.theme().colors().tab_bar_background
+        };
+
+        let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR) {
+            (IconDecorationKind::X, Color::Error)
+        } else {
+            (IconDecorationKind::Triangle, Color::Warning)
+        };
+
+        Some(
+            DecoratedIcon::new(
+                icon,
+                Some(
+                    IconDecoration::new(icon_decoration, knockout_item_color, cx)
+                        .color(icon_color.color(cx))
+                        .position(Point {
+                            x: px(-2.),
+                            y: px(-2.),
+                        }),
+                ),
+            )
+            .into_any_element(),
+        )
+    }
+
     fn render_tab(
         &self,
         ix: usize,
@@ -2801,54 +2850,7 @@ impl Pane {
             cx,
         );
 
-        let item_diagnostic = item
-            .project_path(cx)
-            .map_or(None, |project_path| self.diagnostics.get(&project_path));
-
-        let decorated_icon = item_diagnostic.map_or(None, |diagnostic| {
-            let icon = match item.tab_icon(window, cx) {
-                Some(icon) => icon,
-                None => return None,
-            };
-
-            let knockout_item_color = if is_active {
-                cx.theme().colors().tab_active_background
-            } else {
-                cx.theme().colors().tab_bar_background
-            };
-
-            let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR)
-            {
-                (IconDecorationKind::X, Color::Error)
-            } else {
-                (IconDecorationKind::Triangle, Color::Warning)
-            };
-
-            Some(DecoratedIcon::new(
-                icon.size(IconSize::Small).color(Color::Muted),
-                Some(
-                    IconDecoration::new(icon_decoration, knockout_item_color, cx)
-                        .color(icon_color.color(cx))
-                        .position(Point {
-                            x: px(-2.),
-                            y: px(-2.),
-                        }),
-                ),
-            ))
-        });
-
-        let icon = if decorated_icon.is_none() {
-            match item_diagnostic {
-                Some(&DiagnosticSeverity::ERROR) => None,
-                Some(&DiagnosticSeverity::WARNING) => None,
-                _ => item
-                    .tab_icon(window, cx)
-                    .map(|icon| icon.color(Color::Muted)),
-            }
-            .map(|icon| icon.size(IconSize::Small))
-        } else {
-            None
-        };
+        let icon = self.tab_icon_element(item, is_active, window, cx);
 
         let settings = ItemSettings::get_global(cx);
         let close_side = &settings.close_position;
@@ -2887,7 +2889,7 @@ impl Pane {
                 }))
         };
 
-        let has_file_icon = icon.is_some() | decorated_icon.is_some();
+        let has_file_icon = icon.is_some();
 
         let capability = item.capability(cx);
         let tab = Tab::new(ix)
@@ -3039,10 +3041,8 @@ impl Pane {
                 h_flex()
                     .id(("pane-tab-content", ix))
                     .gap_1()
-                    .children(if let Some(decorated_icon) = decorated_icon {
-                        Some(decorated_icon.into_any_element())
-                    } else if let Some(icon) = icon {
-                        Some(icon.into_any_element())
+                    .children(if let Some(icon) = icon {
+                        Some(icon)
                     } else if !capability.editable() {
                         Some(read_only_toggle(capability == Capability::Read).into_any_element())
                     } else {
@@ -4212,7 +4212,7 @@ fn default_render_tab_bar_buttons(
             PopoverMenu::new("pane-tab-bar-popover-menu")
                 .trigger_with_tooltip(
                     IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small),
-                    Tooltip::text("New..."),
+                    Tooltip::text("New…"),
                 )
                 .anchor(Anchor::TopRight)
                 .with_handle(pane.new_item_context_menu_handle.clone())
@@ -4891,15 +4891,20 @@ impl NavHistoryState {
 }
 
 fn dirty_message_for(buffer_path: Option, path_style: PathStyle) -> String {
-    let path = buffer_path
-        .as_ref()
-        .and_then(|p| {
-            let path = p.path.display(path_style);
-            if path.is_empty() { None } else { Some(path) }
-        })
-        .unwrap_or("This buffer".into());
-    let path = truncate_and_remove_front(&path, 80);
-    format!("{path} contains unsaved edits. Do you want to save it?")
+    let path = buffer_path.as_ref().and_then(|p| {
+        let path = p.path.display(path_style);
+        if path.is_empty() { None } else { Some(path) }
+    });
+    match path {
+        Some(path) => {
+            let path = truncate_and_remove_front(&path, 80);
+            format!(
+                "{} contains unsaved edits. Do you want to save it?",
+                MarkdownInlineCode(path.as_str())
+            )
+        }
+        None => "This buffer contains unsaved edits. Do you want to save it?".to_string(),
+    }
 }
 
 pub fn tab_details(items: &[Box], _window: &Window, cx: &App) -> Vec {
@@ -4935,8 +4940,13 @@ impl Render for DraggedTab {
             window,
             cx,
         );
+        let icon =
+            self.pane
+                .read(cx)
+                .tab_icon_element(self.item.as_ref(), self.is_active, window, cx);
         Tab::new("")
             .toggle_state(self.is_active)
+            .children(icon)
             .child(label)
             .render(window, cx)
             .font(ui_font)
@@ -9093,6 +9103,22 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_dirty_message_for_escapes_markdown_in_path() {
+        let project_path = ProjectPath {
+            worktree_id: WorktreeId::from_usize(0),
+            path: util::rel_path::rel_path("dir/__init__.py").into(),
+        };
+        assert_eq!(
+            dirty_message_for(Some(project_path), PathStyle::Posix),
+            "`dir/__init__.py` contains unsaved edits. Do you want to save it?"
+        );
+        assert_eq!(
+            dirty_message_for(None, PathStyle::Posix),
+            "This buffer contains unsaved edits. Do you want to save it?"
+        );
+    }
+
     mod property_test {
         use super::*;
         use proptest::prelude::*;
diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs
index 161f65974c4..0fc345b1b7c 100644
--- a/crates/workspace/src/workspace.rs
+++ b/crates/workspace/src/workspace.rs
@@ -2823,10 +2823,11 @@ impl Workspace {
                     } else {
                         // If the item is no longer present in this pane, then retrieve its
                         // path info in order to reopen it.
-                        break pane
-                            .nav_history()
-                            .path_for_item(entry.item.id())
-                            .map(|(project_path, abs_path)| (project_path, abs_path, entry));
+                        if let Some((project_path, abs_path)) =
+                            pane.nav_history().path_for_item(entry.item.id())
+                        {
+                            break Some((project_path, abs_path, entry));
+                        }
                     }
                 }
             })
@@ -6293,7 +6294,7 @@ impl Workspace {
                     .children(
                         self.notifications
                             .iter()
-                            .map(|(_, notification)| notification.clone().into_any()),
+                            .map(|(_, notification)| notification.clone().into_any_element()),
                     ),
             )
         }
@@ -15068,6 +15069,84 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_reopen_closed_item_skips_items_without_paths(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+
+        let project = Project::test(fs, [], cx).await;
+
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+        let reopenable_item = cx.new(TestItem::new);
+
+        let active_item = cx.new(TestItem::new);
+        let unreopenable_item = cx.new(TestItem::new);
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(reopenable_item.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+            workspace.add_item_to_active_pane(
+                Box::new(active_item.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+        });
+
+        pane.update(cx, |pane, _| {
+            pane.nav_history_mut().set_mode(NavigationMode::ClosingItem);
+        });
+
+        reopenable_item.update_in(cx, |item, window, cx| {
+            item.deactivated(window, cx);
+        });
+
+        pane.update(cx, |pane, _| {
+            pane.nav_history_mut().set_mode(NavigationMode::Normal);
+        });
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(unreopenable_item.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+        });
+
+        pane.update_in(cx, |pane, window, cx| {
+            pane.close_item_by_id(unreopenable_item.item_id(), SaveIntent::Skip, window, cx)
+                .detach_and_log_err(cx);
+        });
+
+        cx.run_until_parked();
+
+        workspace
+            .update_in(cx, |workspace, window, cx| {
+                workspace.reopen_closed_item(window, cx)
+            })
+            .await
+            .unwrap();
+
+        pane.read_with(cx, |pane, _| {
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                reopenable_item.item_id()
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane(
         cx: &mut TestAppContext,
diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs
index 2711362dd18..b83532b1636 100644
--- a/crates/worktree/src/worktree.rs
+++ b/crates/worktree/src/worktree.rs
@@ -24,7 +24,8 @@ use fuzzy::CharBag;
 use git::{
     BISECT_LOG, COMMIT_MESSAGE, DOT_GIT, FETCH_HEAD, FSMONITOR_DAEMON, GC_PID, GITIGNORE,
     HOOKS_DIR, INFO_DIR, LFS_DIR, LOGS_DIR, LOGS_REF_STASH, OBJECTS_DIR, ORIG_HEAD,
-    REBASE_APPLY_DIR, REBASE_MERGE_DIR, REPO_EXCLUDE, SEQUENCER_DIR, status::GitSummary,
+    REBASE_APPLY_DIR, REBASE_MERGE_DIR, REFS_DIR, REFTABLE_DIR, REPO_EXCLUDE, SEQUENCER_DIR,
+    status::GitSummary,
 };
 use gpui::{
     App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority,
@@ -111,6 +112,7 @@ pub struct LoadedFile {
     pub text: String,
     pub encoding: &'static Encoding,
     pub has_bom: bool,
+    pub is_writable: bool,
 }
 
 pub struct LoadedBinaryFile {
@@ -181,6 +183,7 @@ pub struct Snapshot {
     entries_by_path: SumTree,
     entries_by_id: SumTree,
     root_repo_common_dir: Option>,
+    root_repo_is_linked_worktree: bool,
     always_included_entries: Vec>,
 
     /// A number that increases every time the worktree begins scanning
@@ -271,16 +274,65 @@ struct BackgroundScannerState {
     watched_dir_abs_paths_by_entry_id: HashMap>,
     path_prefixes_to_scan: HashSet>,
     paths_to_scan: HashSet>,
-    /// The ids of all of the entries that were removed from the snapshot
-    /// as part of the current update. These entry ids may be re-used
-    /// if the same inode is discovered at a new path, or if the given
-    /// path is re-created after being deleted.
-    removed_entries: HashMap,
+    removed_entries: RemovedEntries,
     changed_paths: Vec>,
     prev_snapshot: Snapshot,
     scanning_enabled: bool,
 }
 
+/// The entries that were removed from the snapshot as part of the current
+/// update. Their entry ids may be re-used if the same inode is discovered
+/// at a new path, or if the given path is re-created after being deleted.
+///
+/// Symlink aliases inside the worktree share their inode (and usually mtime)
+/// with the symlink target, so an inode may correspond to several entries.
+/// The path index allows an exact match to take precedence over the
+/// inode-based rename heuristics in that case.
+#[derive(Default)]
+struct RemovedEntries {
+    by_inode: HashMap,
+    by_path: HashMap, Entry>,
+}
+
+impl RemovedEntries {
+    fn insert(&mut self, entry: &Entry) {
+        self.by_path.insert(entry.path.clone(), entry.clone());
+        match self.by_inode.entry(entry.inode) {
+            hash_map::Entry::Occupied(mut o) => {
+                if entry.id > o.get().id {
+                    o.insert(entry.clone());
+                }
+            }
+            hash_map::Entry::Vacant(v) => {
+                v.insert(entry.clone());
+            }
+        }
+    }
+
+    fn take_by_path(&mut self, path: &RelPath, inode: u64) -> Option {
+        if self.by_path.get(path)?.inode != inode {
+            return None;
+        }
+        let removed = self.by_path.remove(path)?;
+        if let hash_map::Entry::Occupied(o) = self.by_inode.entry(removed.inode)
+            && o.get().id == removed.id
+        {
+            o.remove();
+        }
+        Some(removed)
+    }
+
+    fn take_by_inode(&mut self, inode: u64) -> Option {
+        let removed = self.by_inode.remove(&inode)?;
+        if let hash_map::Entry::Occupied(o) = self.by_path.entry(removed.path.clone())
+            && o.get().id == removed.id
+        {
+            o.remove();
+        }
+        Some(removed)
+    }
+}
+
 #[derive(Clone, Debug, Eq, PartialEq)]
 struct EventRoot {
     path: Arc,
@@ -421,12 +473,18 @@ impl Worktree {
             None
         };
 
-        let root_repo_common_dir = if visible {
-            discover_root_repo_common_dir(&abs_path, fs.as_ref())
+        let (root_repo_common_dir, root_repo_is_linked_worktree) = if visible {
+            discover_root_repo_metadata(&abs_path, fs.as_ref())
                 .await
-                .map(SanitizedPath::from_arc)
+                .map(|(common_dir, is_linked_worktree)| {
+                    (
+                        Some(SanitizedPath::from_arc(common_dir)),
+                        is_linked_worktree,
+                    )
+                })
+                .unwrap_or((None, false))
         } else {
-            None
+            (None, false)
         };
         Ok(cx.new(move |cx: &mut Context| {
             let mut snapshot = LocalSnapshot {
@@ -447,6 +505,7 @@ impl Worktree {
                 root_file_handle,
             };
             snapshot.root_repo_common_dir = root_repo_common_dir;
+            snapshot.root_repo_is_linked_worktree = root_repo_is_linked_worktree;
 
             let worktree_id = snapshot.id();
             let settings_location = Some(SettingsLocation {
@@ -535,6 +594,7 @@ impl Worktree {
             snapshot.root_repo_common_dir = worktree
                 .root_repo_common_dir
                 .map(|p| SanitizedPath::new_arc(Path::new(&p)));
+            snapshot.root_repo_is_linked_worktree = worktree.root_repo_is_linked_worktree;
 
             let background_snapshot = Arc::new(Mutex::new((
                 snapshot.clone(),
@@ -597,6 +657,8 @@ impl Worktree {
                         }
 
                         let old_root_repo_common_dir = this.snapshot.root_repo_common_dir.clone();
+                        let old_root_repo_is_linked_worktree =
+                            this.snapshot.root_repo_is_linked_worktree;
                         let mut changed_entries: Vec<(Arc, ProjectEntryId, PathChange)> =
                             Vec::new();
                         {
@@ -639,6 +701,8 @@ impl Worktree {
                         let is_first_update = !this.received_initial_update;
                         this.received_initial_update = true;
                         if this.snapshot.root_repo_common_dir != old_root_repo_common_dir
+                            || this.snapshot.root_repo_is_linked_worktree
+                                != old_root_repo_is_linked_worktree
                             || (is_first_update && this.snapshot.root_repo_common_dir.is_none())
                         {
                             cx.emit(Event::UpdatedRootRepoCommonDir {
@@ -734,6 +798,7 @@ impl Worktree {
             root_repo_common_dir: self
                 .root_repo_common_dir()
                 .map(|p| p.to_string_lossy().into_owned()),
+            root_repo_is_linked_worktree: self.root_repo_is_linked_worktree(),
         }
     }
 
@@ -1215,7 +1280,7 @@ impl LocalWorktree {
                         scanning_enabled,
                         path_prefixes_to_scan: Default::default(),
                         paths_to_scan: Default::default(),
-                        removed_entries: Default::default(),
+                        removed_entries: RemovedEntries::default(),
                         changed_paths: Default::default(),
                     }),
                     phase: BackgroundScannerPhase::InitialScan,
@@ -1274,13 +1339,28 @@ impl LocalWorktree {
     ) {
         let repo_changes = self.changed_repos(&self.snapshot, &mut new_snapshot);
 
-        new_snapshot.root_repo_common_dir = new_snapshot
+        if let Some((common_dir, is_linked_worktree)) = new_snapshot
             .local_repo_for_work_directory_path(RelPath::empty())
-            .map(|repo| SanitizedPath::from_arc(repo.common_dir_abs_path.clone()));
+            .map(|repo| {
+                (
+                    SanitizedPath::from_arc(repo.common_dir_abs_path.clone()),
+                    repo.repository_dir_abs_path != repo.common_dir_abs_path,
+                )
+            })
+        {
+            new_snapshot.root_repo_common_dir = Some(common_dir);
+            new_snapshot.root_repo_is_linked_worktree = is_linked_worktree;
+        } else {
+            new_snapshot.root_repo_common_dir = None;
+            new_snapshot.root_repo_is_linked_worktree = false;
+        }
 
-        let old_root_repo_common_dir = (self.snapshot.root_repo_common_dir
-            != new_snapshot.root_repo_common_dir)
-            .then(|| self.snapshot.root_repo_common_dir.clone());
+        let root_repo_metadata_changed = self.snapshot.root_repo_common_dir
+            != new_snapshot.root_repo_common_dir
+            || self.snapshot.root_repo_is_linked_worktree
+                != new_snapshot.root_repo_is_linked_worktree;
+        let old_root_repo_common_dir =
+            root_repo_metadata_changed.then(|| self.snapshot.root_repo_common_dir.clone());
         self.snapshot = new_snapshot;
 
         if let Some(share) = self.update_observer.as_mut() {
@@ -1516,15 +1596,15 @@ impl LocalWorktree {
             //       if it is too large
             //       5GB seems to be more reasonable, peaking at ~16GB, while 6GB jumps up to >24GB which seems like a
             //       reasonable limit
+            const FILE_SIZE_MAX: u64 = 6 * 1024 * 1024 * 1024; // 6GB
+            let metadata = fs.metadata(&abs_path).await?;
+            if let Some(metadata) = metadata.as_ref()
+                && metadata.len >= FILE_SIZE_MAX
             {
-                const FILE_SIZE_MAX: u64 = 6 * 1024 * 1024 * 1024; // 6GB
-                if let Ok(Some(metadata)) = fs.metadata(&abs_path).await
-                    && metadata.len >= FILE_SIZE_MAX
-                {
-                    anyhow::bail!("File is too large to load");
-                }
+                anyhow::bail!("File is too large to load");
             }
             let (text, encoding, has_bom) = decode_file_text(fs.as_ref(), &abs_path).await?;
+            let is_writable = metadata.is_some_and(|metadata| metadata.is_writable);
 
             let worktree = this.upgrade().context("worktree was dropped")?;
             let file = match entry.await? {
@@ -1558,6 +1638,7 @@ impl LocalWorktree {
                 text,
                 encoding,
                 has_bom,
+                is_writable,
             })
         })
     }
@@ -2361,6 +2442,7 @@ impl Snapshot {
             entries_by_path: Default::default(),
             entries_by_id: Default::default(),
             root_repo_common_dir: None,
+            root_repo_is_linked_worktree: false,
             scan_id: 1,
             completed_scan_id: 0,
         }
@@ -2392,6 +2474,10 @@ impl Snapshot {
             .map(SanitizedPath::cast_arc_ref)
     }
 
+    pub fn root_repo_is_linked_worktree(&self) -> bool {
+        self.root_repo_is_linked_worktree
+    }
+
     fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree {
         let mut updated_entries = self
             .entries_by_path
@@ -2408,6 +2494,7 @@ impl Snapshot {
             root_repo_common_dir: self
                 .root_repo_common_dir()
                 .map(|p| p.to_string_lossy().into_owned()),
+            root_repo_is_linked_worktree: self.root_repo_is_linked_worktree,
             updated_entries,
             removed_entries: Vec::new(),
             scan_id: self.scan_id as u64,
@@ -2553,11 +2640,21 @@ impl Snapshot {
         self.entries_by_path.edit(entries_by_path_edits, ());
         self.entries_by_id.edit(entries_by_id_edits, ());
 
-        if let Some(dir) = update
+        // A `None` from a completed scan is a real repo removal, whereas a `None`
+        // mid-scan may just mean the sender hasn't registered the root repo yet.
+        match update
             .root_repo_common_dir
             .map(|p| SanitizedPath::new_arc(Path::new(&p)))
         {
-            self.root_repo_common_dir = Some(dir);
+            Some(dir) => {
+                self.root_repo_common_dir = Some(dir);
+                self.root_repo_is_linked_worktree = update.root_repo_is_linked_worktree;
+            }
+            None if update.is_last_update => {
+                self.root_repo_common_dir = None;
+                self.root_repo_is_linked_worktree = false;
+            }
+            None => {}
         }
 
         self.scan_id = update.scan_id as usize;
@@ -2790,6 +2887,7 @@ impl LocalSnapshot {
             root_repo_common_dir: self
                 .root_repo_common_dir()
                 .map(|p| p.to_string_lossy().into_owned()),
+            root_repo_is_linked_worktree: self.root_repo_is_linked_worktree,
             updated_entries,
             removed_entries,
             scan_id: self.scan_id as u64,
@@ -3040,21 +3138,11 @@ impl BackgroundScannerState {
     }
 
     fn reuse_entry_id(&mut self, entry: &mut Entry) {
-        if let Some(mtime) = entry.mtime {
-            // If an entry with the same inode was removed from the worktree during this scan,
-            // then it *might* represent the same file or directory. But the OS might also have
-            // re-used the inode for a completely different file or directory.
-            //
-            // Conditionally reuse the old entry's id:
-            // * if the mtime is the same, the file was probably been renamed.
-            // * if the path is the same, the file may just have been updated
-            if let Some(removed_entry) = self.removed_entries.remove(&entry.inode) {
-                if removed_entry.mtime == Some(mtime) || removed_entry.path == entry.path {
-                    entry.id = removed_entry.id;
-                }
-            } else if let Some(existing_entry) = self.snapshot.entry_for_path(&entry.path) {
-                entry.id = existing_entry.id;
-            }
+        let Some(mtime) = entry.mtime else {
+            return;
+        };
+        if let Some(entry_id) = self.reused_entry_id(&entry.path, entry.inode, mtime) {
+            entry.id = entry_id;
         }
     }
 
@@ -3064,6 +3152,20 @@ impl BackgroundScannerState {
         path: &RelPath,
         metadata: &fs::Metadata,
     ) -> ProjectEntryId {
+        self.reused_entry_id(path, metadata.inode, metadata.mtime)
+            .unwrap_or_else(|| ProjectEntryId::new(next_entry_id))
+    }
+
+    fn reused_entry_id(
+        &mut self,
+        path: &RelPath,
+        inode: u64,
+        mtime: MTime,
+    ) -> Option {
+        if let Some(removed_entry) = self.removed_entries.take_by_path(path, inode) {
+            return Some(removed_entry.id);
+        }
+
         // If an entry with the same inode was removed from the worktree during this scan,
         // then it *might* represent the same file or directory. But the OS might also have
         // re-used the inode for a completely different file or directory.
@@ -3071,14 +3173,12 @@ impl BackgroundScannerState {
         // Conditionally reuse the old entry's id:
         // * if the mtime is the same, the file was probably been renamed.
         // * if the path is the same, the file may just have been updated
-        if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) {
-            if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path {
-                return removed_entry.id;
-            }
-        } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) {
-            return existing_entry.id;
+        if let Some(removed_entry) = self.removed_entries.take_by_inode(inode) {
+            (removed_entry.mtime == Some(mtime) || *removed_entry.path == *path)
+                .then_some(removed_entry.id)
+        } else {
+            Some(self.snapshot.entry_for_path(path)?.id)
         }
-        ProjectEntryId::new(next_entry_id)
     }
 
     async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry {
@@ -3246,17 +3346,7 @@ impl BackgroundScannerState {
                 removed_dir_abs_paths.push(watch_path);
             }
 
-            match self.removed_entries.entry(entry.inode) {
-                hash_map::Entry::Occupied(mut e) => {
-                    let prev_removed_entry = e.get_mut();
-                    if entry.id > prev_removed_entry.id {
-                        *prev_removed_entry = entry.clone();
-                    }
-                }
-                hash_map::Entry::Vacant(e) => {
-                    e.insert(entry.clone());
-                }
-            }
+            self.removed_entries.insert(entry);
 
             if entry.path.file_name() == Some(GITIGNORE) {
                 let abs_parent_path = self.snapshot.absolutize(&entry.path.parent().unwrap());
@@ -3374,14 +3464,9 @@ impl BackgroundScannerState {
             .context("failed to add repository directory to watcher")
             .log_err();
 
-        // On Linux and FreeBSD, the native watcher is non-recursive, so subdirectories inside `.git` need explicit watching.
-        // For repos using the reftable backend, watch the `.git/reftable` directory so that ref changes are detected.
-        let reftable_path = common_dir_abs_path.join("reftable");
-        if fs.is_dir(&reftable_path).await {
-            watcher
-                .add(&reftable_path)
-                .context("failed to add reftable directory to watcher")
-                .log_err();
+        watch_git_dir_subdirectories(&common_dir_abs_path, fs, watcher).await;
+        if repository_dir_abs_path != common_dir_abs_path {
+            watch_git_dir_subdirectories(&repository_dir_abs_path, fs, watcher).await;
         }
 
         let work_directory_id = work_dir_entry.id;
@@ -3405,6 +3490,55 @@ impl BackgroundScannerState {
     }
 }
 
+/// Watches the directories inside a git directory that git writes ref updates to.
+///
+/// On Linux and FreeBSD the native file watcher is non-recursive, so a watch on the git
+/// directory itself does not report changes to files nested below it, such as the loose
+/// refs that git updates on commit, fetch, and branch operations. Watch the `refs` tree
+/// (its directories are watched individually because branch names may contain slashes)
+/// and, for repositories using the reftable backend, the `reftable` directory. On
+/// platforms with recursive watchers these calls are deduplicated against the existing
+/// recursive registration, making them effectively free.
+async fn watch_git_dir_subdirectories(git_dir_abs_path: &Path, fs: &dyn Fs, watcher: &dyn Watcher) {
+    let reftable_dir_abs_path = git_dir_abs_path.join(REFTABLE_DIR);
+    if fs.is_dir(&reftable_dir_abs_path).await {
+        watcher
+            .add(&reftable_dir_abs_path)
+            .context("failed to add reftable directory to watcher")
+            .log_err();
+    }
+
+    watch_dir_tree(git_dir_abs_path.join(REFS_DIR), fs, watcher).await;
+}
+
+/// Watches a directory and all of its descendant directories.
+///
+/// Each directory is watched before its children are enumerated, so that a child
+/// created concurrently is either seen by the enumeration or reported by the watch.
+async fn watch_dir_tree(root_abs_path: PathBuf, fs: &dyn Fs, watcher: &dyn Watcher) {
+    let mut dirs_to_watch = vec![root_abs_path];
+    while let Some(dir_abs_path) = dirs_to_watch.pop() {
+        if !fs.is_dir(&dir_abs_path).await {
+            continue;
+        }
+        watcher
+            .add(&dir_abs_path)
+            .with_context(|| format!("failed to watch directory {dir_abs_path:?}"))
+            .log_err();
+        let Some(mut children) = fs.read_dir(&dir_abs_path).await.log_err() else {
+            continue;
+        };
+        while let Some(child_abs_path) = children.next().await {
+            let Some(child_abs_path) = child_abs_path.log_err() else {
+                continue;
+            };
+            if fs.is_dir(&child_abs_path).await {
+                dirs_to_watch.push(child_abs_path);
+            }
+        }
+    }
+}
+
 async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool {
     if let Some(file_name) = path.file_name()
         && file_name == DOT_GIT
@@ -4559,6 +4693,24 @@ impl BackgroundScanner {
                         continue;
                     }
 
+                    // New directories can appear under the `refs` tree at any time, e.g. when a
+                    // remote is added or a branch name contains slashes. On platforms where the
+                    // native watcher is non-recursive they need their own watches, or subsequent
+                    // ref updates inside them would go unnoticed. The subtree is walked because
+                    // nested directories may have been created before this watch took effect.
+                    if matches!(event.kind, Some(PathEventKind::Created))
+                        && path_in_git_dir
+                            .components()
+                            .any(|component| component.as_os_str() == OsStr::new(REFS_DIR))
+                    {
+                        watch_dir_tree(
+                            abs_path.as_path().to_path_buf(),
+                            self.fs.as_ref(),
+                            self.watcher.as_ref(),
+                        )
+                        .await;
+                    }
+
                     if !dot_git_abs_paths.contains(&dot_git_abs_path) {
                         log::debug!(
                             "detected update within git repo at {dot_git_abs_path:?}: {abs_path:?}"
@@ -4754,7 +4906,8 @@ impl BackgroundScanner {
         {
             let mut state = self.state.lock().await;
             state.snapshot.completed_scan_id = state.snapshot.scan_id;
-            for (_, entry) in mem::take(&mut state.removed_entries) {
+            let RemovedEntries { by_inode, by_path } = mem::take(&mut state.removed_entries);
+            for entry in by_inode.into_values().chain(by_path.into_values()) {
                 state.scanned_dirs.remove(&entry.id);
             }
         }
@@ -5674,56 +5827,57 @@ impl BackgroundScanner {
         let scan_id = state.snapshot.scan_id;
         let mut affected_repo_roots = Vec::new();
         for dot_git_dir in dot_git_paths {
-            let existing_repository_entry =
-                state
-                    .snapshot
-                    .git_repositories
-                    .iter()
-                    .find_map(|(_, repo)| {
-                        let dot_git_dir = SanitizedPath::new(&dot_git_dir);
-                        if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir
-                            || SanitizedPath::new(repo.repository_dir_abs_path.as_ref())
-                                == dot_git_dir
-                            || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir
-                        {
-                            Some(repo.clone())
-                        } else {
-                            None
-                        }
-                    });
+            // Several repositories can share a git directory: a linked worktree's
+            // commondir is the main checkout's `.git`, so a ref update there must
+            // refresh every repository that reads from it.
+            let existing_work_directory_ids = state
+                .snapshot
+                .git_repositories
+                .iter()
+                .filter_map(|(&work_directory_id, repo)| {
+                    let dot_git_dir = SanitizedPath::new(&dot_git_dir);
+                    if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir
+                        || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) == dot_git_dir
+                        || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir
+                    {
+                        Some(work_directory_id)
+                    } else {
+                        None
+                    }
+                })
+                .collect::>();
 
-            match existing_repository_entry {
-                None => {
-                    let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else {
-                        // A `.git` path outside the worktree root is not
-                        // ours to register. This happens legitimately when
-                        // `.git` is a gitfile pointing outside the worktree
-                        // (linked worktrees and submodules), and also when
-                        // a rescan of a linked worktree's commondir arrives
-                        // after the worktree's repository has already been
-                        // unregistered.
-                        continue;
-                    };
-                    affected_repo_roots.push(dot_git_dir.parent().unwrap().into());
+            if existing_work_directory_ids.is_empty() {
+                let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else {
+                    // A `.git` path outside the worktree root is not
+                    // ours to register. This happens legitimately when
+                    // `.git` is a gitfile pointing outside the worktree
+                    // (linked worktrees and submodules), and also when
+                    // a rescan of a linked worktree's commondir arrives
+                    // after the worktree's repository has already been
+                    // unregistered.
+                    continue;
+                };
+                affected_repo_roots.push(dot_git_dir.parent().unwrap().into());
+                state
+                    .insert_git_repository(
+                        RelPath::new(relative, PathStyle::local())
+                            .unwrap()
+                            .into_arc(),
+                        self.fs.as_ref(),
+                        self.watcher.as_ref(),
+                    )
+                    .await;
+            } else {
+                for work_directory_id in existing_work_directory_ids {
                     state
-                        .insert_git_repository(
-                            RelPath::new(relative, PathStyle::local())
-                                .unwrap()
-                                .into_arc(),
-                            self.fs.as_ref(),
-                            self.watcher.as_ref(),
-                        )
-                        .await;
-                }
-                Some(local_repository) => {
-                    state.snapshot.git_repositories.update(
-                        &local_repository.work_directory_id,
-                        |entry| {
+                        .snapshot
+                        .git_repositories
+                        .update(&work_directory_id, |entry| {
                             entry.git_dir_scan_id = scan_id;
-                        },
-                    );
+                        });
                 }
-            };
+            }
         }
 
         // Remove any git repositories whose .git entry no longer exists.
@@ -6621,13 +6775,23 @@ fn resolve_commondir_path(repository_dir_abs_path: &Path, commondir_path: &str)
 }
 
 pub async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option> {
+    discover_root_repo_metadata(root_abs_path, fs)
+        .await
+        .map(|(common_dir, _)| common_dir)
+}
+
+async fn discover_root_repo_metadata(
+    root_abs_path: &Path,
+    fs: &dyn Fs,
+) -> Option<(Arc, bool)> {
     let root_dot_git = root_abs_path.join(DOT_GIT);
     if !fs.metadata(&root_dot_git).await.is_ok_and(|m| m.is_some()) {
         return None;
     }
     let dot_git_path: Arc = root_dot_git.into();
-    let (_, common_dir) = discover_git_paths(&dot_git_path, fs).await;
-    Some(common_dir)
+    let (repository_dir, common_dir) = discover_git_paths(&dot_git_path, fs).await;
+    let is_linked_worktree = repository_dir != common_dir;
+    Some((common_dir, is_linked_worktree))
 }
 
 async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, Arc) {
diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs
index 6a4370539af..b4b6c2d1aee 100644
--- a/crates/worktree/tests/integration/worktree_tests.rs
+++ b/crates/worktree/tests/integration/worktree_tests.rs
@@ -1198,6 +1198,98 @@ async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_internal_symlink_updates_preserve_entry_ids(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.background_executor.clone());
+
+    fs.insert_tree(
+        "/root",
+        json!({
+            "project": {
+                "real-dir": {
+                    "existing.rs": "old",
+                },
+                "links": {}
+            }
+        }),
+    )
+    .await;
+
+    fs.create_symlink(
+        "/root/project/links/internal".as_ref(),
+        "../real-dir".into(),
+    )
+    .await
+    .unwrap();
+
+    let tree = Worktree::local(
+        Path::new("/root/project"),
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+
+    let (real_entry_id, symlink_entry_id, old_mtime) = tree.read_with(cx, |tree, _| {
+        let real_entry = tree
+            .entry_for_path(rel_path("real-dir/existing.rs"))
+            .unwrap();
+        let symlink_entry = tree
+            .entry_for_path(rel_path("links/internal/existing.rs"))
+            .unwrap();
+        assert_eq!(real_entry.inode, symlink_entry.inode);
+        assert_eq!(real_entry.mtime, symlink_entry.mtime);
+        assert_ne!(real_entry.id, symlink_entry.id);
+        (real_entry.id, symlink_entry.id, real_entry.mtime)
+    });
+
+    fs.write(Path::new("/root/project/real-dir/existing.rs"), b"new")
+        .await
+        .unwrap();
+
+    wait_for_condition(cx, |cx| {
+        tree.read_with(cx, |tree, _| {
+            let real_entry = tree
+                .entry_for_path(rel_path("real-dir/existing.rs"))
+                .unwrap();
+            let symlink_entry = tree
+                .entry_for_path(rel_path("links/internal/existing.rs"))
+                .unwrap();
+            real_entry.mtime != old_mtime && symlink_entry.mtime != old_mtime
+        })
+    })
+    .await;
+
+    tree.read_with(cx, |tree, _| {
+        let real_entry = tree
+            .entry_for_path(rel_path("real-dir/existing.rs"))
+            .unwrap();
+        let symlink_entry = tree
+            .entry_for_path(rel_path("links/internal/existing.rs"))
+            .unwrap();
+
+        assert_eq!(real_entry.inode, symlink_entry.inode);
+        assert_eq!(real_entry.id, real_entry_id);
+        assert_eq!(
+            tree.entry_for_id(real_entry_id).unwrap().path.as_ref(),
+            rel_path("real-dir/existing.rs")
+        );
+        assert_eq!(symlink_entry.id, symlink_entry_id);
+        assert_eq!(
+            tree.entry_for_id(symlink_entry_id).unwrap().path.as_ref(),
+            rel_path("links/internal/existing.rs")
+        );
+    });
+}
+
 #[cfg(target_os = "macos")]
 #[gpui::test]
 async fn test_renaming_case_only(cx: &mut TestAppContext) {
@@ -4107,6 +4199,86 @@ async fn test_linked_worktree_gitfile_event_preserves_repo(
     });
 }
 
+#[gpui::test]
+async fn test_shared_common_dir_event_updates_all_repositories(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    // A main checkout and one of its linked worktrees can both live inside the
+    // same project worktree, sharing a common git directory. An event in that
+    // common directory (e.g. a ref update) must refresh every repository that
+    // reads from it, not just the first match.
+    init_test(cx);
+
+    use git::repository::Worktree as GitWorktree;
+
+    let fs = FakeFs::new(executor.clone());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main_repo": {
+                ".git": {},
+                "file.txt": "content",
+            },
+        }),
+    )
+    .await;
+    fs.add_linked_worktree_for_repo(
+        Path::new(path!("/project/main_repo/.git")),
+        false,
+        GitWorktree {
+            path: PathBuf::from(path!("/project/linked")),
+            ref_name: Some("refs/heads/feature".into()),
+            sha: "abc123".into(),
+            is_main: false,
+            is_bare: false,
+        },
+    )
+    .await;
+
+    let tree = Worktree::local(
+        path!("/project").as_ref(),
+        true,
+        fs.clone(),
+        Arc::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    let mut events = cx.events(&tree);
+    fs.emit_fs_event(
+        path!("/project/main_repo/.git/refs/heads/main"),
+        Some(PathEventKind::Changed),
+    );
+    executor.run_until_parked();
+
+    let mut updated_work_dirs = Vec::new();
+    while let Ok(event) = events.try_recv() {
+        if let Event::UpdatedGitRepositories(updates) = event {
+            updated_work_dirs.extend(
+                updates
+                    .iter()
+                    .filter_map(|update| update.new_work_directory_abs_path.clone()),
+            );
+        }
+    }
+    updated_work_dirs.sort();
+    assert_eq!(
+        updated_work_dirs,
+        [
+            Arc::from(Path::new(path!("/project/linked"))),
+            Arc::from(Path::new(path!("/project/main_repo"))),
+        ],
+        "a ref update in the shared common dir should refresh both repositories"
+    );
+}
+
 #[gpui::test]
 async fn test_noisy_dot_git_events_do_not_emit_git_repo_update(
     executor: BackgroundExecutor,
@@ -4449,6 +4621,89 @@ async fn test_dot_git_dir_event_does_not_suppress_children(
     }
 }
 
+#[gpui::test]
+async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAppContext) {
+    // On Linux and FreeBSD the native file watcher is non-recursive: watching `.git`
+    // does not deliver events for files nested below it, like the loose refs that git
+    // updates on commit, fetch, and branch operations. The worktree must watch the
+    // `refs` tree explicitly, including directories created after the initial scan.
+    init_test(cx);
+    cx.executor().allow_parking();
+
+    let dir = TempTree::new(json!({
+        ".git": {},
+        "a.txt": "a-contents",
+    }));
+    std::fs::write(
+        dir.path().join(".git/refs/heads/main"),
+        "0000000000000000000000000000000000000000\n",
+    )
+    .unwrap();
+
+    let tree = Worktree::local(
+        dir.path(),
+        true,
+        Arc::new(RealFs::new(None, cx.executor())),
+        Default::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    tree.flush_fs_events(cx).await;
+
+    let mut events = cx.events(&tree);
+    std::fs::write(
+        dir.path().join(".git/refs/heads/main"),
+        "1111111111111111111111111111111111111111\n",
+    )
+    .unwrap();
+    expect_git_repo_update(&mut events, cx, "updating a loose ref").await;
+
+    std::fs::create_dir_all(dir.path().join(".git/refs/remotes/origin")).unwrap();
+    expect_git_repo_update(&mut events, cx, "creating a directory under refs").await;
+    tree.flush_fs_events(cx).await;
+    drain_git_repo_updates(&mut events);
+
+    std::fs::write(
+        dir.path().join(".git/refs/remotes/origin/main"),
+        "2222222222222222222222222222222222222222\n",
+    )
+    .unwrap();
+    expect_git_repo_update(
+        &mut events,
+        cx,
+        "updating a ref in a directory created after the initial scan",
+    )
+    .await;
+}
+
+async fn expect_git_repo_update(
+    events: &mut futures::channel::mpsc::UnboundedReceiver,
+    cx: &mut TestAppContext,
+    description: &str,
+) {
+    let mut elapsed = std::time::Duration::ZERO;
+    let timeout = std::time::Duration::from_secs(10);
+    let poll_interval = std::time::Duration::from_millis(50);
+    loop {
+        match events.try_recv() {
+            Ok(Event::UpdatedGitRepositories(_)) => return,
+            Ok(_) => continue,
+            Err(_) => {}
+        }
+        assert!(
+            elapsed < timeout,
+            "timed out waiting for UpdatedGitRepositories after {description}"
+        );
+        cx.background_executor.timer(poll_interval).await;
+        elapsed += poll_interval;
+    }
+}
+
 fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver) -> bool {
     let mut found = false;
     while let Ok(event) = events.try_recv() {
@@ -4932,6 +5187,7 @@ async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_upda
                 visible: true,
                 abs_path: "/home/user/project".to_string(),
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             },
             client,
             PathStyle::Posix,
@@ -4989,6 +5245,7 @@ async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_upda
                 updated_repositories: vec![],
                 removed_repositories: vec![],
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             });
     });
 
@@ -5027,6 +5284,7 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri
                 visible: true,
                 abs_path: "/home/user/project".to_string(),
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             },
             client,
             PathStyle::Posix,
@@ -5081,6 +5339,7 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri
                 updated_repositories: vec![],
                 removed_repositories: vec![],
                 root_repo_common_dir: Some("/home/user/project/.git".to_string()),
+                root_repo_is_linked_worktree: false,
             });
     });
 
@@ -5101,6 +5360,94 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri
     );
 }
 
+#[gpui::test]
+async fn test_remote_worktree_root_repo_metadata_cleared_only_by_completed_scan(
+    cx: &mut TestAppContext,
+) {
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+    });
+
+    let client = AnyProtoClient::new(NoopProtoClient::new());
+
+    // Metadata eagerly seeds the root repo info, as `AddWorktreeResponse` /
+    // `WorktreeMetadata` do before the host's scan completes.
+    let worktree = cx.update(|cx| {
+        Worktree::remote(
+            1,
+            clock::ReplicaId::new(1),
+            proto::WorktreeMetadata {
+                id: 1,
+                root_name: "feature-a".to_string(),
+                visible: true,
+                abs_path: "/home/user/monty/feature-a".to_string(),
+                root_repo_common_dir: Some("/home/user/monty/.bare".to_string()),
+                root_repo_is_linked_worktree: true,
+            },
+            client,
+            PathStyle::Posix,
+            cx,
+        )
+    });
+
+    let root_repo_metadata = |cx: &mut TestAppContext| {
+        worktree.read_with(cx, |worktree, _| {
+            let snapshot = worktree.snapshot();
+            (
+                snapshot.root_repo_common_dir().cloned(),
+                snapshot.root_repo_is_linked_worktree(),
+            )
+        })
+    };
+
+    let update = |scan_id: u64, is_last_update: bool| proto::UpdateWorktree {
+        project_id: 1,
+        worktree_id: 1,
+        abs_path: "/home/user/monty/feature-a".to_string(),
+        root_name: "feature-a".to_string(),
+        updated_entries: vec![],
+        removed_entries: vec![],
+        scan_id,
+        is_last_update,
+        updated_repositories: vec![],
+        removed_repositories: vec![],
+        root_repo_common_dir: None,
+        root_repo_is_linked_worktree: false,
+    };
+
+    // A mid-scan update without repo info must not clobber the seeded
+    // metadata: the sender's scanner may not have registered the repo yet.
+    worktree.update(cx, |worktree, _cx| {
+        worktree
+            .as_remote()
+            .unwrap()
+            .update_from_remote(update(2, false));
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        root_repo_metadata(cx),
+        (Some(Arc::from(Path::new("/home/user/monty/.bare"))), true,),
+        "mid-scan update without repo info should not clear seeded metadata"
+    );
+
+    // A completed scan without repo info is authoritative: the repo is gone.
+    worktree.update(cx, |worktree, _cx| {
+        worktree
+            .as_remote()
+            .unwrap()
+            .update_from_remote(update(3, true));
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        root_repo_metadata(cx),
+        (None, false),
+        "completed scan without repo info should clear root repo metadata"
+    );
+}
+
 // Regression test: a remote worktree used to emit `UpdatedEntries` with an
 // empty changeset (`Arc::default()`), discarding the changed paths. Consumers
 // that key off those paths - notably the agent's `.agents/skills` refresh -
@@ -5152,6 +5499,7 @@ async fn test_remote_worktree_update_entries_carry_changed_paths(cx: &mut TestAp
                 visible: true,
                 abs_path: path!("/root").to_string(),
                 root_repo_common_dir: None,
+                root_repo_is_linked_worktree: false,
             },
             AnyProtoClient::new(NoopProtoClient::new()),
             PathStyle::local(),
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index 50099f28a4a..213147207c3 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "1.10.0"
+version = "1.12.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team "]
diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL
index 4de2f126df5..38f8e886e1a 100644
--- a/crates/zed/RELEASE_CHANNEL
+++ b/crates/zed/RELEASE_CHANNEL
@@ -1 +1 @@
-preview
\ No newline at end of file
+dev
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index 725db34126c..1dcac5480f5 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -1886,7 +1886,13 @@ fn watch_themes(fs: Arc, cx: &mut App) {
 
         while let Some(paths) = events.next().await {
             for event in paths {
-                if fs.metadata(&event.path).await.ok().flatten().is_some() {
+                if fs
+                    .metadata(&event.path)
+                    .await
+                    .ok()
+                    .flatten()
+                    .is_some_and(|m| !m.is_dir)
+                {
                     let theme_registry = cx.update(|cx| ThemeRegistry::global(cx));
                     if let Some(bytes) = fs.load_bytes(&event.path).await.log_err()
                         && load_user_theme(&theme_registry, &bytes).log_err().is_some()
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 7e07e7f0f3f..2ecd422be6e 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -31,10 +31,13 @@ use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag};
 use fs::Fs;
 use futures::FutureExt as _;
 use futures::{StreamExt, channel::mpsc, select_biased};
+use git_ui::branch_diff::BranchDiffToolbar;
 use git_ui::commit_view::CommitViewToolbar;
 use git_ui::git_panel::GitPanel;
-use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
+use git_ui::project_diff::ProjectDiffToolbar;
 use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
+use git_ui::staged_diff::StagedDiffToolbar;
+use git_ui::unstaged_diff::UnstagedDiffToolbar;
 use gpui::{
     Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
     Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
@@ -101,12 +104,13 @@ use workspace::{
 };
 use workspace::{Pane, notifications::DetachAndPromptErr};
 use zed_actions::{
-    About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile,
-    OpenStatusPage, OpenZedUrl, Quit,
+    About, GetMerch, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings,
+    OpenSettingsFile, OpenStatusPage, OpenZedUrl, Quit,
 };
 
 const DOCS_URL: &str = "https://zed.dev/docs/";
 const STATUS_URL: &str = "https://status.zed.dev";
+const MERCH_URL: &str = "https://merch.zed.dev/";
 
 pub struct CrashHandler(pub Arc);
 
@@ -887,6 +891,7 @@ fn register_actions(
     workspace
         .register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL))
         .register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL))
+        .register_action(|_, _: &GetMerch, _, cx| cx.open_url(MERCH_URL))
         .register_action(
             |workspace: &mut Workspace,
              _: &input_latency_ui::DumpInputLatencyHistogram,
@@ -1411,6 +1416,10 @@ fn initialize_pane(
             toolbar.add_item(highlights_tree_item, window, cx);
             let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
             toolbar.add_item(project_diff_toolbar, window, cx);
+            let staged_diff_toolbar = cx.new(|cx| StagedDiffToolbar::new(workspace, cx));
+            toolbar.add_item(staged_diff_toolbar, window, cx);
+            let unstaged_diff_toolbar = cx.new(|cx| UnstagedDiffToolbar::new(workspace, cx));
+            toolbar.add_item(unstaged_diff_toolbar, window, cx);
             let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
             toolbar.add_item(branch_diff_toolbar, window, cx);
             let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
@@ -2035,19 +2044,23 @@ pub fn handle_keymap_file_changes(
     let mut old_base_keymap = *BaseKeymap::get_global(cx);
     let mut old_vim_enabled = VimModeSetting::get_global(cx).0;
     let mut old_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
+    let mut old_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
 
     cx.observe_global::(move |cx| {
         let new_base_keymap = *BaseKeymap::get_global(cx);
         let new_vim_enabled = VimModeSetting::get_global(cx).0;
         let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
+        let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
 
         if new_base_keymap != old_base_keymap
             || new_vim_enabled != old_vim_enabled
             || new_helix_enabled != old_helix_enabled
+            || new_disable_ai != old_disable_ai
         {
             old_base_keymap = new_base_keymap;
             old_vim_enabled = new_vim_enabled;
             old_helix_enabled = new_helix_enabled;
+            old_disable_ai = new_disable_ai;
 
             base_keymap_tx.unbounded_send(()).unwrap();
         }
@@ -2216,7 +2229,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) {
     for key_binding in &mut user_key_bindings {
         key_binding.set_meta(KeybindSource::User.meta());
     }
-    cx.bind_keys(user_key_bindings);
+    cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx));
 
     let menus = app_menus(cx);
     cx.set_menus(menus);
@@ -2236,18 +2249,23 @@ pub fn load_default_keymap(cx: &mut App) {
         return;
     }
 
-    cx.bind_keys(
+    cx.bind_keys(filter_disabled_ai_bindings(
         KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, Some(KeybindSource::Default), cx).unwrap(),
-    );
+        cx,
+    ));
 
     if let Some(asset_path) = base_keymap.asset_path() {
-        cx.bind_keys(KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap());
+        cx.bind_keys(filter_disabled_ai_bindings(
+            KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap(),
+            cx,
+        ));
     }
 
     if VimModeSetting::get_global(cx).0 || vim_mode_setting::HelixModeSetting::get_global(cx).0 {
-        cx.bind_keys(
+        cx.bind_keys(filter_disabled_ai_bindings(
             KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(),
-        );
+            cx,
+        ));
     }
 
     cx.bind_keys(
@@ -2260,6 +2278,37 @@ pub fn load_default_keymap(cx: &mut App) {
     );
 }
 
+/// Namespaces of actions that are part of an AI feature. When the user opts out
+/// of AI via the `disable_ai` setting, bindings to these actions are dropped so
+/// that lower-precedence editor defaults (e.g. `editor::NewlineBelow` for
+/// `ctrl-enter`) can fire instead of being shadowed by an action whose handler
+/// silently no-ops.
+const AI_ACTION_NAMESPACES: &[&str] = &[
+    "acp::",
+    "agent::",
+    "assistant::",
+    "edit_prediction::",
+    "inline_assistant::",
+    "zeta::",
+];
+
+fn is_ai_keybinding(binding: &KeyBinding) -> bool {
+    let name = binding.action().name();
+    AI_ACTION_NAMESPACES
+        .iter()
+        .any(|namespace| name.starts_with(namespace))
+}
+
+fn filter_disabled_ai_bindings(bindings: Vec, cx: &App) -> Vec {
+    if !DisableAiSettings::get_global(cx).disable_ai {
+        return bindings;
+    }
+    bindings
+        .into_iter()
+        .filter(|binding| !is_ai_keybinding(binding))
+        .collect()
+}
+
 pub fn open_new_ssh_project_from_project(
     workspace: &mut Workspace,
     paths: Vec,
@@ -5797,6 +5846,67 @@ mod tests {
         // If this panics, the test has failed
     }
 
+    #[gpui::test]
+    async fn test_disable_ai_filters_keybindings(cx: &mut gpui::TestAppContext) {
+        let _app_state = init_keymap_test(cx);
+
+        // With AI enabled, the default keymap should include the assistant
+        // bindings that intercept e.g. ctrl-enter in the editor.
+        cx.update(load_default_keymap);
+        cx.update(|cx| {
+            let keymap = cx.key_bindings();
+            let keymap = keymap.borrow();
+            let has_ai_binding = keymap.bindings().any(|binding| is_ai_keybinding(binding));
+            assert!(
+                has_ai_binding,
+                "expected AI-namespaced bindings in the default keymap before disabling AI"
+            );
+        });
+
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |settings_store, cx| {
+                settings_store.update_user_settings(cx, |settings| {
+                    settings.project.disable_ai = Some(SaturatingBool(true));
+                });
+            });
+        });
+
+        // The default keymap should drop every AI-namespaced binding so that
+        // lower-precedence editor defaults can run instead.
+        cx.update(|cx| {
+            cx.clear_key_bindings();
+            load_default_keymap(cx);
+        });
+        cx.update(|cx| {
+            let keymap = cx.key_bindings();
+            let keymap = keymap.borrow();
+            if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) {
+                panic!(
+                    "expected no AI-namespaced bindings after disabling AI, but found `{}`",
+                    binding.action().name()
+                );
+            }
+        });
+
+        // User-defined bindings to AI actions should also be filtered.
+        let user_binding = KeyBinding::new(
+            "ctrl-enter",
+            zed_actions::assistant::InlineAssist { prompt: None },
+            None,
+        );
+        cx.update(|cx| reload_keymaps(cx, vec![user_binding]));
+        cx.update(|cx| {
+            let keymap = cx.key_bindings();
+            let keymap = keymap.borrow();
+            if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) {
+                panic!(
+                    "expected user binding `{}` to be filtered when AI is disabled",
+                    binding.action().name()
+                );
+            }
+        });
+    }
+
     #[gpui::test]
     async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
         let app_state = init_test(cx);
diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs
index 29a06747f9e..bb1f3ee7aa1 100644
--- a/crates/zed/src/zed/app_menus.rs
+++ b/crates/zed/src/zed/app_menus.rs
@@ -2,11 +2,9 @@ use collab_ui::collab_panel;
 use gpui::{App, Menu, MenuItem, OsAction};
 use release_channel::ReleaseChannel;
 use terminal_view::terminal_panel;
-use zed_actions::{debug_panel, dev};
+use zed_actions::{Quit, assistant, debug_panel, dev, git_panel, project_panel};
 
 pub fn app_menus(cx: &mut App) -> Vec {
-    use zed_actions::Quit;
-
     let mut view_items = vec![
         MenuItem::action(
             "Zoom In",
@@ -40,11 +38,13 @@ pub fn app_menus(cx: &mut App) -> Vec {
             ],
         }),
         MenuItem::separator(),
-        MenuItem::action("Project Panel", zed_actions::project_panel::ToggleFocus),
+        MenuItem::action("Project Panel", project_panel::ToggleFocus),
         MenuItem::action("Outline Panel", outline_panel::ToggleFocus),
         MenuItem::action("Collab Panel", collab_panel::ToggleFocus),
-        MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus),
+        MenuItem::action("Terminal Panel", terminal_panel::Toggle),
         MenuItem::action("Debugger Panel", debug_panel::ToggleFocus),
+        MenuItem::action("Agent Panel", assistant::ToggleFocus),
+        MenuItem::action("Git Panel", git_panel::ToggleFocus),
         MenuItem::separator(),
         MenuItem::action("Diagnostics", diagnostics::Deploy),
         MenuItem::separator(),
diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs
index a992dfe4fd9..d59e80a35d2 100644
--- a/crates/zed/src/zed/open_listener.rs
+++ b/crates/zed/src/zed/open_listener.rs
@@ -750,9 +750,13 @@ pub(crate) fn open_options_for_request(
     location: &SerializedWorkspaceLocation,
     cx: &App,
 ) -> workspace::OpenOptions {
-    open_behavior.map_or_else(workspace::OpenOptions::default, |open_behavior| {
-        open_options_for_behavior(open_behavior, location, cx)
-    })
+    let open_behavior = open_behavior.unwrap_or_else(|| {
+        match workspace::WorkspaceSettings::get_global(cx).default_open_behavior {
+            settings::DefaultOpenBehavior::ExistingWindow => cli::OpenBehavior::ExistingWindow,
+            settings::DefaultOpenBehavior::NewWindow => cli::OpenBehavior::PreferNewWindow,
+        }
+    });
+    open_options_for_behavior(open_behavior, location, cx)
 }
 
 pub(crate) fn open_options_for_behavior(
@@ -814,10 +818,7 @@ async fn open_workspaces(
 ) -> Result<()> {
     if paths.is_empty()
         && diff_paths.is_empty()
-        && !matches!(
-            open_behavior,
-            cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::PreferNewWindow
-        )
+        && !matches!(open_behavior, cli::OpenBehavior::AlwaysNew)
     {
         return restore_or_create_workspace(app_state, cx).await;
     }
@@ -1105,7 +1106,8 @@ mod tests {
     use remote::SshConnectionOptions;
     use rope::Rope;
     use serde_json::json;
-    use std::{sync::Arc, task::Poll};
+    use session::Session;
+    use std::{path::Path, sync::Arc, task::Poll};
     use util::path;
     use workspace::{AppState, MultiWorkspace};
 
@@ -1364,6 +1366,48 @@ mod tests {
         assert!(options.requesting_window.is_none());
     }
 
+    #[gpui::test]
+    fn test_open_options_for_request_respects_default_open_behavior(cx: &mut TestAppContext) {
+        use gpui::UpdateGlobal as _;
+
+        let _app_state = init_test(cx);
+
+        // A `None` behavior (e.g. a Finder or URL open) consults the UI-level
+        // `default_open_behavior` setting rather than falling back to fixed
+        // defaults.
+        cx.update(|cx| {
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.workspace.default_open_behavior =
+                        Some(settings::DefaultOpenBehavior::NewWindow);
+                });
+            });
+        });
+        let options =
+            cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx));
+        assert_eq!(
+            options.workspace_matching,
+            workspace::WorkspaceMatching::MatchSubpaths
+        );
+        assert!(!options.add_dirs_to_sidebar);
+
+        cx.update(|cx| {
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.workspace.default_open_behavior =
+                        Some(settings::DefaultOpenBehavior::ExistingWindow);
+                });
+            });
+        });
+        let options =
+            cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx));
+        assert_eq!(
+            options.workspace_matching,
+            workspace::WorkspaceMatching::MatchExact
+        );
+        assert!(options.add_dirs_to_sidebar);
+    }
+
     #[gpui::test]
     fn test_parse_agent_url(cx: &mut TestAppContext) {
         let _app_state = init_test(cx);
@@ -2607,6 +2651,78 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_e2e_new_window_setting_restores_workspace_when_no_paths(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), json!({ "file.txt": "content" }))
+            .await;
+
+        cx.update(|cx| {
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.workspace.cli_default_open_behavior =
+                        Some(settings::CliDefaultOpenBehavior::NewWindow);
+                });
+            });
+        });
+
+        let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
+
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let multi_workspace = cx.windows()[0].downcast::().unwrap();
+        let serialization_tasks = multi_workspace
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.flush_all_serialization(window, cx)
+            })
+            .unwrap();
+        futures::future::join_all(serialization_tasks).await;
+
+        multi_workspace
+            .update(cx, |_, window, _| window.remove_window())
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(cx.windows().len(), 0);
+
+        cx.update(|cx| {
+            app_state.session.update(cx, |app_session, _cx| {
+                app_session.replace_session_for_test(Session::test_with_old_session(session_id));
+            });
+        });
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(Vec::new(), cli::OpenBehavior::Default),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when no windows exist"
+        );
+        assert_eq!(cx.windows().len(), 1);
+
+        let restored_window = cx.windows()[0].downcast::().unwrap();
+        restored_window
+            .read_with(cx, |multi_workspace, cx| {
+                let root_paths = multi_workspace.workspace().read(cx).root_paths(cx);
+                assert!(
+                    root_paths
+                        .iter()
+                        .any(|path| path.as_ref() == Path::new(path!("/project"))),
+                    "expected CLI launch with no paths to restore /project, got {root_paths:?}"
+                );
+            })
+            .unwrap();
+    }
+
     #[gpui::test]
     async fn test_e2e_new_window_setting_opens_project_root_in_new_window(cx: &mut TestAppContext) {
         let app_state = init_test(cx);
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
index 9d47f272406..12b34def15c 100644
--- a/crates/zed_actions/src/lib.rs
+++ b/crates/zed_actions/src/lib.rs
@@ -69,6 +69,8 @@ actions!(
         OpenLicenses,
         /// Opens the Zed status page.
         OpenStatusPage,
+        /// Opens the Zed merch store.
+        GetMerch,
         /// Opens the telemetry log.
         OpenTelemetryLog,
         /// Opens the performance profiler.
@@ -350,6 +352,12 @@ pub mod git {
             /// Opens the git branch selector.
             #[action(deprecated_aliases = ["branches::OpenRecent"])]
             Branch,
+            /// Shows uncommitted changes across the project.
+            ViewUncommittedChanges,
+            /// Shows unstaged changes across the project.
+            ViewUnstagedChanges,
+            /// Shows staged changes across the project.
+            ViewStagedChanges,
             /// Opens the git stash selector.
             ViewStash,
             /// Opens the git worktree selector.
@@ -539,7 +547,7 @@ pub mod agent {
     actions!(
         agent,
         [
-            /// Opens the agent settings panel.
+            /// Opens the agent settings UI.
             #[action(deprecated_aliases = ["agent::OpenConfiguration"])]
             OpenSettings,
             /// Opens the agent onboarding modal.
@@ -565,6 +573,17 @@ pub mod agent {
         ]
     );
 
+    /// Selects the agent used for new threads in the agent panel, without
+    /// opening the panel. The selected agent is launched the next time the
+    /// panel is opened.
+    #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+    #[action(namespace = agent)]
+    #[serde(deny_unknown_fields)]
+    pub struct SelectAgent {
+        /// The id of the agent to select.
+        pub agent: String,
+    }
+
     /// Opens a new agent thread with the provided branch diff for review.
     #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
     #[action(namespace = agent)]
@@ -928,3 +947,15 @@ pub mod notebook {
         ]
     );
 }
+
+pub mod git_panel {
+    use gpui::actions;
+
+    actions!(
+        git_panel,
+        [
+            /// Toggles focus on the git panel.
+            ToggleFocus,
+        ]
+    );
+}
diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs
index 540bc2db11a..0cecb38decf 100644
--- a/crates/zeta_prompt/src/multi_region.rs
+++ b/crates/zeta_prompt/src/multi_region.rs
@@ -582,8 +582,8 @@ pub fn nearest_marker_number(cursor_offset: Option, marker_offsets: &[usi
 fn cursor_block_index(cursor_offset: Option, marker_offsets: &[usize]) -> usize {
     let cursor = cursor_offset.unwrap_or(0);
     marker_offsets
-        .windows(2)
-        .position(|window| cursor >= window[0] && cursor < window[1])
+        .array_windows::<2>()
+        .position(|&[a, b]| cursor >= a && cursor < b)
         .unwrap_or_else(|| marker_offsets.len().saturating_sub(2))
 }
 
@@ -1220,10 +1220,7 @@ hhhhhhhhhh = 8;
             Some(text.len()),
             "offsets: {offsets:?}"
         );
-        assert!(
-            offsets.windows(2).all(|window| window[0] <= window[1]),
-            "offsets must be sorted: {offsets:?}"
-        );
+        assert!(offsets.is_sorted(), "offsets must be sorted: {offsets:?}");
     }
 
     #[test]
diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs
index 710ddf761eb..4f03a47f609 100644
--- a/crates/zlog/src/filter.rs
+++ b/crates/zlog/src/filter.rs
@@ -42,6 +42,7 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[
     // usvg prints a lot of warnings on rendering an SVG with partial errors, which
     // can happen a lot with the SVG preview
     ("usvg::parser", log::LevelFilter::Error),
+    ("pet", log::LevelFilter::Warn),
 ];
 
 pub fn init_env_filter(filter: env_config::EnvFilter) {
diff --git a/docs/book.toml b/docs/book.toml
index 2eed5f7907a..e1a48d1f7f0 100644
--- a/docs/book.toml
+++ b/docs/book.toml
@@ -45,10 +45,10 @@ enable = false
 "/assistant.html" = "/docs/assistant/assistant.html"
 "/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html"
 "/assistant/assistant.html" = "/docs/ai/overview.html"
-"/assistant/commands.html" = "/docs/ai/text-threads.html"
+"/assistant/commands.html" = "/docs/ai/agent-panel.html"
 "/assistant/configuration.html" = "/docs/ai/quick-start.html"
 "/assistant/context-servers.html" = "/docs/ai/mcp.html"
-"/assistant/contexts.html" = "/docs/ai/text-threads.html"
+"/assistant/contexts.html" = "/docs/ai/agent-panel.html"
 "/assistant/inline-assistant.html" = "/docs/ai/inline-assistant.html"
 "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html"
 "/assistant/prompting.html" = "/docs/ai/skills.html"
diff --git a/docs/src/account/zed-hosted-models.md b/docs/src/account/zed-hosted-models.md
index a74fd81b572..cc79a54ddf7 100644
--- a/docs/src/account/zed-hosted-models.md
+++ b/docs/src/account/zed-hosted-models.md
@@ -1,16 +1,20 @@
 ---
 title: Zed-Hosted Models - Zed
-description: AI models available via Zed Pro including Claude, GPT-5.5, Gemini 3.1 Pro, and Gemini 3.5 Flash. Pricing, context windows, and tool call support.
+description: AI models available via Zed Pro including Claude Fable 5, Claude Sonnet 5, GPT-5.6, and Gemini 3. Pricing, context windows, and tool call support.
 ---
 
 # Zed-Hosted Models
 
 Zed's plans offer hosted versions of major LLMs with higher rate limits than direct API access. Model availability is updated regularly. To use your own API keys instead, see [LLM Providers](../ai/llm-providers.md). For general setup, see [AI Quick Start](../ai/quick-start.md).
 
-> **Note:** Claude Opus models, GPT-5.5 pro, and GPT-5.4 pro are only available on Zed Pro and Zed Business.
+> **Note:** Claude Fable 5, Claude Opus models, GPT-5.5 pro, and GPT-5.4 pro are only available on Zed Pro and Zed Business.
 
 | Model             | Provider  | Token Type          | Provider Price per 1M tokens | Zed Price per 1M tokens |
 | ----------------- | --------- | ------------------- | ---------------------------- | ----------------------- |
+| Claude Fable 5    | Anthropic | Input               | $10.00                       | $11.00                  |
+|                   | Anthropic | Output              | $50.00                       | $55.00                  |
+|                   | Anthropic | Input - Cache Write | $12.50                       | $13.75                  |
+|                   | Anthropic | Input - Cache Read  | $1.00                        | $1.10                   |
 | Claude Opus 4.5   | Anthropic | Input               | $5.00                        | $5.50                   |
 |                   | Anthropic | Output              | $25.00                       | $27.50                  |
 |                   | Anthropic | Input - Cache Write | $6.25                        | $6.875                  |
@@ -27,6 +31,10 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir
 |                   | Anthropic | Output              | $25.00                       | $27.50                  |
 |                   | Anthropic | Input - Cache Write | $6.25                        | $6.875                  |
 |                   | Anthropic | Input - Cache Read  | $0.50                        | $0.55                   |
+| Claude Sonnet 5   | Anthropic | Input               | $2.00                        | $2.20                   |
+|                   | Anthropic | Output              | $10.00                       | $11.00                  |
+|                   | Anthropic | Input - Cache Write | $2.50                        | $2.75                   |
+|                   | Anthropic | Input - Cache Read  | $0.20                        | $0.22                   |
 | Claude Sonnet 4.5 | Anthropic | Input               | $3.00                        | $3.30                   |
 |                   | Anthropic | Output              | $15.00                       | $16.50                  |
 |                   | Anthropic | Input - Cache Write | $3.75                        | $4.125                  |
@@ -39,6 +47,18 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir
 |                   | Anthropic | Output              | $5.00                        | $5.50                   |
 |                   | Anthropic | Input - Cache Write | $1.25                        | $1.375                  |
 |                   | Anthropic | Input - Cache Read  | $0.10                        | $0.11                   |
+| GPT-5.6 Sol       | OpenAI    | Input               | $5.00                        | $5.50                   |
+|                   | OpenAI    | Output              | $30.00                       | $33.00                  |
+|                   | OpenAI    | Input - Cache Write | $6.25                        | $6.875                  |
+|                   | OpenAI    | Cached Input        | $0.50                        | $0.55                   |
+| GPT-5.6 Terra     | OpenAI    | Input               | $2.50                        | $2.75                   |
+|                   | OpenAI    | Output              | $15.00                       | $16.50                  |
+|                   | OpenAI    | Input - Cache Write | $3.125                       | $3.4375                 |
+|                   | OpenAI    | Cached Input        | $0.25                        | $0.275                  |
+| GPT-5.6 Luna      | OpenAI    | Input               | $1.00                        | $1.10                   |
+|                   | OpenAI    | Output              | $6.00                        | $6.60                   |
+|                   | OpenAI    | Input - Cache Write | $1.25                        | $1.375                  |
+|                   | OpenAI    | Cached Input        | $0.10                        | $0.11                   |
 | GPT-5.5 pro       | OpenAI    | Input               | $30.00                       | $33.00                  |
 |                   | OpenAI    | Output              | $180.00                      | $198.00                 |
 | GPT-5.5           | OpenAI    | Input               | $5.00                        | $5.50                   |
@@ -71,17 +91,22 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir
 | Gemini 3 Flash    | Google    | Input               | $0.50                        | $0.55                   |
 |                   | Google    | Output              | $3.00                        | $3.30                   |
 
-## Recent Model Retirements
+> **Warn:** Anthropic retains prompts and outputs sent to Claude Fable 5 for at least 30 days for trust and safety purposes. The no-training commitment still applies, and Zed does not retain your prompts or outputs. See [Provider Safety Retention for Designated Models](../ai/privacy-and-security.md#provider-safety-retention).
 
-As of February 19, 2026, Zed Pro serves newer model versions in place of the retired models below:
+The Claude Sonnet 5 prices shown above use Anthropic's introductory pricing through August 31, 2026.
+
+## Recent Model Retirements {#recent-model-retirements}
+
+Zed no longer offers the models below:
 
 - Claude Opus 4.1 → Claude Opus 4.5, Claude Opus 4.6, Claude Opus 4.7, or Claude Opus 4.8
 - Claude Sonnet 4 → Claude Sonnet 4.5 or Claude Sonnet 4.6
-- Claude Sonnet 3.7 (retired Feb 19) → Claude Sonnet 4.5 or Claude Sonnet 4.6
+- Claude Sonnet 3.7 (retired February 19, 2026) → Claude Sonnet 4.5 or Claude Sonnet 4.6
 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2-Codex
 - Gemini 2.5 Pro → Gemini 3.1 Pro
-- Gemini 3 Pro → Gemini 3.1 Pro
+- Gemini 3 Pro (retired March 26, 2026) → Gemini 3.1 Pro
 - Gemini 2.5 Flash → Gemini 3 Flash or Gemini 3.5 Flash
+- Grok 4, Grok 4 Fast, Grok 4 Fast (Non-Reasoning), and Grok Code Fast 1 ([retired May 15, 2026](https://docs.x.ai/developers/migration/may-15-retirement)). Zed no longer offers hosted xAI models.
 
 ## Usage {#usage}
 
@@ -97,13 +122,18 @@ A context window is the maximum span of text and code an LLM can consider at onc
 
 | Model             | Provider  | Zed-Hosted Context Window |
 | ----------------- | --------- | ------------------------- |
+| Claude Fable 5    | Anthropic | 1M                        |
 | Claude Opus 4.5   | Anthropic | 200k                      |
 | Claude Opus 4.6   | Anthropic | 1M                        |
 | Claude Opus 4.7   | Anthropic | 1M                        |
 | Claude Opus 4.8   | Anthropic | 1M                        |
+| Claude Sonnet 5   | Anthropic | 1M                        |
 | Claude Sonnet 4.5 | Anthropic | 200k                      |
 | Claude Sonnet 4.6 | Anthropic | 1M                        |
 | Claude Haiku 4.5  | Anthropic | 200k                      |
+| GPT-5.6 Sol       | OpenAI    | 272k input / 400k total   |
+| GPT-5.6 Terra     | OpenAI    | 272k input / 400k total   |
+| GPT-5.6 Luna      | OpenAI    | 272k input / 400k total   |
 | GPT-5.5 pro       | OpenAI    | 272k input / 400k total   |
 | GPT-5.5           | OpenAI    | 272k input / 400k total   |
 | GPT-5.4 pro       | OpenAI    | 272k input / 400k total   |
diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md
index e8f6115e02b..a26189de217 100644
--- a/docs/src/ai/agent-panel.md
+++ b/docs/src/ai/agent-panel.md
@@ -106,7 +106,7 @@ You can also hold `cmd`/`ctrl` when submitting a message to automatically follow
 
 If you send a prompt to the Agent and then put Zed in the background, you can choose to be notified when its generation wraps up via:
 
-- a visual notification that appears in the top right of your screen
+- a visual desktop notification from your operating system
 - a sound notification
 
 These notifications can be used together or individually, and you can use the `agent.notify_when_agent_waiting` and `agent.play_sound_when_agent_done` settings keys to customize that, including turning both off entirely.
diff --git a/docs/src/ai/ai-improvement.md b/docs/src/ai/ai-improvement.md
index 72ff46b37fa..172a70e3f97 100644
--- a/docs/src/ai/ai-improvement.md
+++ b/docs/src/ai/ai-improvement.md
@@ -10,7 +10,7 @@ Normal AI requests are not retained by Zed. For
 prohibit training on your prompts or code context and require zero data
 retention, except for
 [provider-designated models with safety retention](./privacy-and-security.md#provider-safety-retention),
-such as Anthropic's Mythos-class models.
+such as Anthropic's Covered Models.
 This page covers the cases where Zed may retain AI data because you explicitly
 shared it or opted in.
 
@@ -28,7 +28,7 @@ For the broader request path and provider data boundaries, see
 
 Zed-hosted model zero-data-retention and no-training commitments, including the
 exception for provider-designated models with safety retention (such as
-Anthropic's Mythos-class models), are documented
+Anthropic's Covered Models), are documented
 on [AI Privacy](./privacy-and-security.md#data-retention-and-training).
 
 ## Response Ratings and Feedback {#ai-feedback-with-ratings}
diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md
index 297e3c32c71..00e94b0b07f 100644
--- a/docs/src/ai/edit-prediction.md
+++ b/docs/src/ai/edit-prediction.md
@@ -41,7 +41,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../accou
 
 Edit Prediction has two display modes:
 
-1. `eager` (default): predictions are displayed inline as long as it doesn't conflict with language server completions
+1. `eager` (default): predictions are displayed inline as long as they don't conflict with language server completions
 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default)
 
 Toggle between them via the `mode` key:
diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md
index 777fd0e9b08..89d7beac865 100644
--- a/docs/src/ai/mcp.md
+++ b/docs/src/ai/mcp.md
@@ -80,7 +80,7 @@ You can connect both local and remote MCP servers from **Settings → AI → MCP
 
 Most MCP servers require configuration after installation.
 
-In the case of extensions, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up.
+In the case of an extension, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up.
 For example, the GitHub MCP extension requires you to add a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
 
 In the case of custom servers, make sure you check the provider documentation to determine what type of command, arguments, and environment variables need to be added to the JSON.
diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md
index aef97e37f5a..70f0ff4f938 100644
--- a/docs/src/ai/parallel-agents.md
+++ b/docs/src/ai/parallel-agents.md
@@ -81,7 +81,7 @@ Worktrees are managed from the title bar. Click the worktree picker (to the righ
 
 Once you're in a new worktree, use the branch picker next to the worktree picker to create a new branch or check out an existing one. If the branch you pick is already checked out in another worktree, the current worktree stays in detached HEAD until you choose a different branch.
 
-To automate setup steps whenever a new worktree is created use a [Task hook](../tasks.md#hooks). The `create_worktree` hook runs automatically after Zed creates a linked worktree, with `ZED_WORKTREE_ROOT` pointing at the new worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository.
+To automate setup steps whenever a new worktree is created, use a [Task hook](../tasks.md#hooks). The `create_worktree` hook runs automatically after Zed creates a linked worktree, with `ZED_WORKTREE_ROOT` pointing at the new worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository.
 
 After the agent finishes, review the diff and merge the changes through your normal Git workflow. If the thread was running in a linked worktree and no other active threads use it, moving the thread to Thread History saves the worktree's Git state and removes it from disk. Restoring the thread from history restores the worktree.
 
diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md
index 354feba64af..3943206a37f 100644
--- a/docs/src/ai/privacy-and-security.md
+++ b/docs/src/ai/privacy-and-security.md
@@ -15,24 +15,24 @@ Zed does not retain your prompts or code context by default. For
 commitments from model providers, and provider agreements require zero data
 retention for inference requests except for
 [provider-designated models with safety retention](#provider-safety-retention),
-such as Anthropic's Mythos-class models.
+such as Anthropic's Covered Models.
 Zed only retains AI data when you explicitly share feedback or opt in to
 training data collection.
 
 ## AI Request Paths {#ai-request-paths}
 
-| Path                                                         | Who handles model requests                        | What to know                                                                                                                                                                                                                        | Details                                                                                           |
-| ------------------------------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| [Zed-hosted models](../account/zed-hosted-models.md)         | Zed routes requests to hosted model providers     | Provider agreements prohibit training on your prompts or code context and require zero data retention for inference requests, except for provider-designated models with safety retention, such as Anthropic's Mythos-class models. | [Zed-hosted model commitments](#data-retention-and-training)                                      |
-| [Provider API keys](./use-api-access.md)                     | The configured provider                           | The provider handles requests under its own terms. Provider keys saved through Zed are stored in the system keychain, not in `settings.json`.                                                                                       | [Use API Access](./use-api-access.md)                                                             |
-| [Existing subscriptions](./use-an-existing-subscription.md)  | The subscription provider                         | The provider handles requests under the subscription terms.                                                                                                                                                                         | [Use an Existing Subscription](./use-an-existing-subscription.md)                                 |
-| [Gateways](./use-a-gateway.md)                               | The configured gateway and upstream providers     | The gateway and upstream providers handle requests under their own terms.                                                                                                                                                           | [Use a Gateway](./use-a-gateway.md)                                                               |
-| [Local models](./use-a-local-model.md)                       | The local server or self-hosted endpoint          | The local server handles requests according to how you configured that server.                                                                                                                                                      | [Use a Local Model](./use-a-local-model.md)                                                       |
-| [External Agents](./external-agents.md)                      | The External Agent and its configured providers   | The External Agent handles model requests under its own terms. Tool and MCP behavior depends on agent and ACP configuration.                                                                                                        | [External Agents](./external-agents.md)                                                           |
-| [Terminal Threads](./terminal-threads.md)                    | The CLI or TUI running in the terminal            | The CLI or TUI owns its auth, model routing, tools, instructions, MCP configuration, and data handling.                                                                                                                             | [Terminal Threads](./terminal-threads.md)                                                         |
-| [Edit Prediction](./edit-prediction.md)                      | The selected edit prediction provider             | Each keystroke can send local editing context to the selected provider. Zeta requests are processed transiently unless training data collection is enabled; third-party providers follow their own terms.                           | [Edit Prediction](./edit-prediction.md), [Feedback and Training Data](./ai-improvement.md)        |
-| [Agent tools](./tools.md), [MCP](./mcp.md), and integrations | Zed, configured MCP servers, and external systems | Tools can read, edit, search, run commands, fetch URLs, or call external systems depending on profile, MCP server, and tool permission settings.                                                                                    | [Agent Profiles](./agent-profiles.md), [Tool Permissions](./tool-permissions.md), [MCP](./mcp.md) |
-| Project trust and instructions                               | Zed and the trusted worktree                      | Project-local instructions and skills are loaded from trusted worktrees. External Agents and Terminal Threads may read their own instruction files.                                                                                 | [Worktree Trust](../worktree-trust.md), [Skills](./skills.md), [Instructions](./instructions.md)  |
+| Path                                                         | Who handles model requests                        | What to know                                                                                                                                                                                                                   | Details                                                                                           |
+| ------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
+| [Zed-hosted models](../account/zed-hosted-models.md)         | Zed routes requests to hosted model providers     | Provider agreements prohibit training on your prompts or code context and require zero data retention for inference requests, except for provider-designated models with safety retention, such as Anthropic's Covered Models. | [Zed-hosted model commitments](#data-retention-and-training)                                      |
+| [Provider API keys](./use-api-access.md)                     | The configured provider                           | The provider handles requests under its own terms. Provider keys saved through Zed are stored in the system keychain, not in `settings.json`.                                                                                  | [Use API Access](./use-api-access.md)                                                             |
+| [Existing subscriptions](./use-an-existing-subscription.md)  | The subscription provider                         | The provider handles requests under the subscription terms.                                                                                                                                                                    | [Use an Existing Subscription](./use-an-existing-subscription.md)                                 |
+| [Gateways](./use-a-gateway.md)                               | The configured gateway and upstream providers     | The gateway and upstream providers handle requests under their own terms.                                                                                                                                                      | [Use a Gateway](./use-a-gateway.md)                                                               |
+| [Local models](./use-a-local-model.md)                       | The local server or self-hosted endpoint          | The local server handles requests according to how you configured that server.                                                                                                                                                 | [Use a Local Model](./use-a-local-model.md)                                                       |
+| [External Agents](./external-agents.md)                      | The External Agent and its configured providers   | The External Agent handles model requests under its own terms. Tool and MCP behavior depends on agent and ACP configuration.                                                                                                   | [External Agents](./external-agents.md)                                                           |
+| [Terminal Threads](./terminal-threads.md)                    | The CLI or TUI running in the terminal            | The CLI or TUI owns its auth, model routing, tools, instructions, MCP configuration, and data handling.                                                                                                                        | [Terminal Threads](./terminal-threads.md)                                                         |
+| [Edit Prediction](./edit-prediction.md)                      | The selected edit prediction provider             | Each keystroke can send local editing context to the selected provider. Zeta requests are processed transiently unless training data collection is enabled; third-party providers follow their own terms.                      | [Edit Prediction](./edit-prediction.md), [Feedback and Training Data](./ai-improvement.md)        |
+| [Agent tools](./tools.md), [MCP](./mcp.md), and integrations | Zed, configured MCP servers, and external systems | Tools can read, edit, search, run commands, fetch URLs, or call external systems depending on profile, MCP server, and tool permission settings.                                                                               | [Agent Profiles](./agent-profiles.md), [Tool Permissions](./tool-permissions.md), [MCP](./mcp.md) |
+| Project trust and instructions                               | Zed and the trusted worktree                      | Project-local instructions and skills are loaded from trusted worktrees. External Agents and Terminal Threads may read their own instruction files.                                                                            | [Worktree Trust](../worktree-trust.md), [Skills](./skills.md), [Instructions](./instructions.md)  |
 
 ## Zed-Hosted Model Commitments {#data-retention-and-training}
 
@@ -40,7 +40,7 @@ For Zed-hosted models, Zed has commitments from model providers that prohibit
 training on your prompts or code context and require zero data retention for
 inference requests, except for
 [provider-designated models with safety retention](#provider-safety-retention),
-such as Anthropic's Mythos-class models. The public provider documents linked below describe provider programs or default
+such as Anthropic's Covered Models. The public provider documents linked below describe provider programs or default
 API terms; Zed-hosted model requests are governed by Zed's provider agreements.
 
 | Provider  | No training reference                                   | Zero-data-retention reference                                                                                                                                                                      |
@@ -53,11 +53,10 @@ API terms; Zed-hosted model requests are governed by Zed's provider agreements.
 
 Some providers require limited data retention for specific models as a condition
 of offering them, on every platform where those models are available. Anthropic
-retains prompts and outputs for models it designates as covered models (its
-Mythos-class models, such as Claude Fable 5) for 30 days for trust and safety
-purposes. Zed cannot opt out of this retention; it applies wherever these models
-are served. See
-[Anthropic's data retention practices for Mythos-class models](https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models).
+retains prompts and outputs for models it designates as Covered Models, including
+Claude Fable 5, for at least 30 days for trust and safety purposes. Zed cannot
+opt out of this retention; it applies wherever these models are served. See
+[Anthropic's data retention practices for Covered Models](https://support.claude.com/en/articles/15425996-data-retention-practices-for-covered-models).
 
 For these models:
 
diff --git a/docs/src/ai/sandboxing.md b/docs/src/ai/sandboxing.md
index 715e331da7f..5603d3c307f 100644
--- a/docs/src/ai/sandboxing.md
+++ b/docs/src/ai/sandboxing.md
@@ -9,9 +9,11 @@ You can restrict what operations the [Zed Agent](./zed-agent.md) can run in mult
 [Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a
 complicated script in a terminal.
 
-Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access and network access, while
-protecting Git metadata such as `.git` directories. This way, even if the agent wants to run an arbitrary script, that
-script will only be able to write to the files and folders you have allowed it to.
+Sandboxing instead uses OS features to forcibly restrict which resources a tool
+call has access to. This does _not_ rely on an agent following a particular set
+of instructions. If the agent attempts to access a resource that is restricted
+by the sandbox, the OS will block it. See [How much can I trust the
+sandbox?](#trust) for more details.
 
 [Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing:
 
@@ -23,27 +25,104 @@ terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./
 
 ## Sandboxed Tools {#sandboxed-tools}
 
-Zed Agent sandboxing currently applies to the `terminal` tool.
+Zed Agent sandboxing currently applies to the `terminal` and `fetch` tools.
 
 | Tool       | What sandboxing limits                                                                                |
 | ---------- | ----------------------------------------------------------------------------------------------------- |
 | `terminal` | Filesystem writes and outbound network access for commands the agent runs; Git metadata is protected. |
+| `fetch`    | Hosts which can be accessed.                                                                          |
 
-Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md),
-[Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox.
+Tools are still governed by [Tool Permissions](./tool-permissions.md), [Agent
+Profiles](./agent-profiles.md), and project trust, but they are not currently
+run inside this OS sandbox.
+
+## Requirements {#requirements}
+
+Sandboxing is supported, in some form, on all platforms. In order to sandbox a
+`terminal` tool call, the following is required:
+
+- On Linux, a runnable, non-setuid `bwrap` binary must be on the `$PATH`. See [Installing Bubblewrap](#installing-bubblewrap).
+- On Windows, WSL must be available.
+
+There are no extra requirements on MacOS.
+
+The `fetch` tool has no extra requirements on any platform.
 
 ## Default Access {#default-access}
 
 By default, sandboxed Zed Agent tool actions have these restrictions:
 
-| Access type         | Default behavior                                                                                                                                                    |
-| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Filesystem reads    | Terminal commands can read most of the filesystem, including protected Git metadata.                                                                                |
-| Project writes      | Terminal commands can write inside open project directories, except for protected Git metadata.                                                                     |
-| Git metadata        | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed.                                                           |
-| Temporary files     | Terminal commands receive a writable temporary location. The exact behavior differs by platform.                                                                    |
-| Other writes        | Writes outside the default writable locations are blocked unless you approve a broader sandbox request.                                                             |
-| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. |
+| Access type         | Default behavior                                                                                                                                                                       |
+| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Filesystem reads    | Terminal commands can read most of the filesystem, including protected Git metadata.                                                                                                   |
+| Project writes      | Terminal commands can write inside open project directories, except for protected Git metadata.                                                                                        |
+| Git metadata        | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed.                                                                              |
+| Temporary files     | Terminal commands receive a writable temporary location. The exact behavior differs by platform.                                                                                       |
+| Other writes        | Writes outside the default writable locations are blocked unless you approve a broader sandbox request.                                                                                |
+| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform.                    |
+| Local IPC sockets   | Sandboxed commands cannot open Unix-domain sockets (for example, to the desktop session bus or a container daemon), which could otherwise be used to run commands outside the sandbox. |
+
+## How much can I trust the sandbox? {#trust}
+
+Enabling sandboxing dramatically reduces the risk of various kinds of attacks.
+However, it does not fully eliminate them.
+
+Firstly, sandboxing relies on OS-level features, which may contain bugs.
+Operating systems have historically had bugs in security features. And while we
+have tested thoroughly, there may also be bugs in Zed's implementation. These
+could allow privilege escalation - for example, allow an agent to write to a
+file that it should only have read access to.
+
+Sandboxing also only applies the restrictions that the user requested. If an
+agent requests write access to your home directory, sandboxing will (and
+should!) do nothing to prevent an agent adding a malicious key to `$HOME/.ssh`.
+
+Be careful with what you grant the agent. At any point in time, you can view the
+state of the sandbox by hovering the padlock icon in the top right of the
+thread. If an agent is requesting an overly broad permission, deny it, and ask
+it to use a smaller grant. When requesting elevated privileges, agents must
+provide a `reason`, which is displayed in the prompt. Read it, and decide
+whether it makes sense before approving.
+
+Also, sandboxing restricts **only** what the `terminal` and `fetch` tools in the
+Zed agent can do. It has **no effect** on other parts of Zed, including:
+
+- Language servers
+- The built-in git client
+- The regular terminal
+- And more...
+
+Even when sandboxing is enabled, you should remain vigilant. A malicious or
+unaligned agent may use these side channels to escalate privileges. For example:
+
+- An agent may add a malicious Rust procedural macro to your codebase, which
+  will be automatically executed by `rust-analyzer` **outside the sandbox**.
+- An agent may modify a `Makefile` to inject a malicious script, which is
+  executed **outside the sandbox** when you next run `make` in the built-in
+  terminal.
+- The agent cannot write to your repository's protected `.git` directory, but it
+  can create a submodule under your project whose Git metadata (including config
+  such as `core.fsmonitor`) it fully controls. That metadata may then be executed
+  **outside the sandbox** when you subsequently run Git commands in a regular
+  terminal. Your shell prompt may even execute Git commands every time it
+  renders!
+
+There are steps you can take to mitigate these issues. For example:
+
+- disable language servers that execute user-defined code from the project (such
+  as Rust procedural macros).
+- use a shell prompt that reports Git status without executing
+  repository-defined programs.
+- review the diff before running `git commit`
+
+But none of this changes the fundamental principle: **a sandbox is not a
+substitute for good security practices**. It is one layer in a defense-in-depth
+strategy.
+
+Zed's default profile aims to strike a balance between security and convenience,
+but we encourage you to tune your settings based on your own security
+requirements and risk profile. A disabled sandbox is not a very effective
+sandbox.
 
 ## Approval Prompts {#approval-prompts}
 
@@ -88,7 +167,7 @@ The available options are:
 | `allow_all_hosts`    | Allow sandboxed tools to reach any host without prompting.                                                        |
 | `write_paths`        | Directory subtrees that sandboxed terminal commands may write to without prompting. Paths are absolute.           |
 | `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere except protected Git metadata without prompting.              |
-| `allow_unsandboxed`  | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it.       |
+| `allow_unsandboxed`  | Turn sandboxing off entirely for Zed Agent terminal commands. The fetch tool will have no restrictions.           |
 
 Prefer narrow grants, such as a specific host or write path, over `allow_all_hosts`, `allow_fs_write_all`, or
 `allow_unsandboxed`.
@@ -99,10 +178,6 @@ Git metadata writes are not grantable while a terminal command is sandboxed. Thi
 linked worktree metadata, refs, the index, hooks, local Git config, and other Git-controlled metadata files. Approving a
 specific writable path or `allow_fs_write_all` does not make Git metadata writable.
 
-When a Git command genuinely needs to update Git metadata, such as `git commit`, `git fetch`, `git checkout`, or `git
-rebase`, approve unsandboxed execution instead. For read-only operations, prefer Git flags that avoid optional metadata
-writes when possible. For example, use `git --no-optional-locks status` instead of `git status`.
-
 ## Platform Support {#platform-support}
 
 Sandboxing uses different operating system mechanisms on each platform. The user-facing prompts are similar, but the
@@ -121,6 +196,7 @@ Sandboxed terminal commands:
 - cannot write protected Git metadata, even if you approve broader write access
 - cannot write elsewhere unless you approve additional paths or broader write access
 - cannot reach the network unless you approve network access
+- can reach only an allowlist of macOS system (Mach) services that developer tooling needs; services that could be abused to escape the sandbox (LaunchServices and launchd, which can launch processes outside it), read the clipboard (the pasteboard), or capture audio are not reachable
 
 When network access is approved on macOS, Zed uses an HTTP/HTTPS proxy so access can be limited to approved hosts.
 Tools that do not honor proxy environment variables, such as SSH, FTP, and raw socket clients, may not work even after host-specific network access is approved.
@@ -128,7 +204,7 @@ For networked terminal commands, prefer HTTPS URLs over SSH URLs when possible.
 
 ### Linux {#linux}
 
-On Linux, Zed uses Bubblewrap (`bwrap`) for sandboxing.
+On Linux, Zed uses [Bubblewrap][bubblewrap] (`bwrap`) for sandboxing.
 
 Zed only uses a non-setuid `bwrap` binary. Its sandbox is built entirely on unprivileged user namespaces, so a setuid-root
 `bwrap` provides no extra functionality, and running one would mean executing root-privileged setup with arguments partly
@@ -139,7 +215,7 @@ Sandboxed terminal commands:
 
 - can read the filesystem, including protected Git metadata contents
 - can write inside open project directories, except protected Git metadata
-- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls
+- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls (when you approve unrestricted filesystem writes, `/tmp` is instead your real host `/tmp` rather than a fresh temporary filesystem)
 - cannot write protected Git metadata
 - cannot write elsewhere unless you approve additional paths or broader write access
 - cannot reach the network unless you approve network access
@@ -151,6 +227,54 @@ after host-specific network access is approved.
 If Bubblewrap is unavailable or cannot create a sandbox in the current environment, Zed may run the command without the OS
 sandbox and show a warning in the tool output.
 
+#### Installing Bubblewrap {#installing-bubblewrap}
+
+Zed needs a runnable, non-setuid `bwrap` binary on your `$PATH`. Installing
+`bubblewrap` from your distribution's package manager is usually all you need.
+
+You can test whether it's working with:
+
+```sh
+bwrap --ro-bind / / -- echo "working"
+```
+
+"Non-setuid" here refers to the [setuid bit][setuid bit]. Historically,
+bubblewrap has shipped both a setuid and non-setuid binary. The setuid binary is
+being phased out for security concerns, and so Zed's sandbox _explicitly rejects
+setuid `bwrap` binaries_.
+
+##### Ubuntu-specific requirements {#installing-bubblewrap-ubuntu}
+
+> **Note:** The following does not affect Ubuntu on WSL.
+
+Bubblewrap relies on a Linux kernel feature known as "namespaces". Unprivileged
+users on many systems can create namespaces, but historically, this feature has
+been used for a variety of attacks.
+
+In response to this, in Ubuntu 23.10, Canonical [added a security
+measure][ubuntu blog] that restricts unprivileged user namespaces. These
+restrictions are enforced by AppArmor.
+
+Because of this, you may also need to install an AppArmor profile for bubblewrap
+after you install it. This is a configuration file that gives bubblewrap the
+ability to create namespaces without needing `sudo`.
+
+```sh
+sudo apt install bubblewrap
+
+# On Ubuntu 25.04 and later, `apparmor` ships with a profile for bubblewrap by default.
+# Make sure you're up-to-date
+sudo apt install --only-upgrade apparmor
+
+# On older versions, manually install the profile
+sudo apt update
+sudo apt install apparmor-profiles apparmor-utils
+sudo install -m 0644 \
+  /usr/share/apparmor/extra-profiles/bwrap-userns-restrict \
+  /etc/apparmor.d/bwrap-userns-restrict
+sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
+```
+
 ### Windows {#windows}
 
 On Windows, Zed Agent sandboxing is supported only when the agent action runs inside WSL.
@@ -167,8 +291,8 @@ When running inside WSL, the Linux sandboxing behavior applies, including the re
 - network access is all-or-nothing rather than host-specific, so host-specific network requests are rejected and the agent must request unrestricted network access when network access is needed
 
 If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal
-behavior of running in your native shell. It selects the shell using the usual preference order: Git Bash (or scoop's
-bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Because the command then runs against native
+behavior of running in your native shell. It selects the shell using the usual preference order: a bash (scoop's bash or
+Git Bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Because the command then runs against native
 Windows paths instead of WSL's Linux filesystem, path conventions change accordingly (for example `C:\...` or `/c/...`
 rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shell may behave differently.
 
@@ -184,3 +308,7 @@ When reviewing a sandbox prompt, prefer the narrowest permission that lets the t
 
 If a command fails because the sandbox blocked access, ask the agent why it needs that access before approving a broader
 request.
+
+[bubblewrap]: https://github.com/containers/bubblewrap
+[setuid bit]: https://en.wikipedia.org/wiki/Setuid
+[ubuntu blog]: https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces
diff --git a/docs/src/ai/use-a-gateway.md b/docs/src/ai/use-a-gateway.md
index e010cfe8159..6cd1a7a614d 100644
--- a/docs/src/ai/use-a-gateway.md
+++ b/docs/src/ai/use-a-gateway.md
@@ -213,6 +213,38 @@ Some AWS environments require a guardrail on every Bedrock API call. Add `guardr
 }
 ```
 
+### Bedrock Mantle Models {#bedrock-mantle-models}
+
+Some models, such as GPT-5.5, GPT-5.4, and Grok 4.3, aren't available through Bedrock's Converse API and are only reachable through `bedrock-mantle`, AWS's OpenAI-compatible inference endpoint. Zed routes these models through `bedrock-mantle` automatically; they appear alongside the rest of the Bedrock models in the model picker once you're authenticated, with no extra configuration required.
+
+Mantle models require IAM permissions for the `bedrock-mantle` endpoint (for example via the `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever permissions your existing Bedrock credentials already have, and `bedrock-mantle` is only available in [some AWS Regions](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html#regions). Zed surfaces an error naming the current Region and the supported ones if you try to use a Mantle model outside of them.
+
+#### Custom Bedrock Mantle Models {#bedrock-mantle-custom-models}
+
+You can add custom models served through `bedrock-mantle` with `mantle_available_models`:
+
+```json [settings]
+{
+  "language_models": {
+    "bedrock": {
+      "mantle_available_models": [
+        {
+          "name": "openai.gpt-oss-120b",
+          "display_name": "GPT-OSS 120B",
+          "max_tokens": 128000,
+          "protocol": "chat_completions",
+          "supports_tools": true,
+          "supports_images": false,
+          "supports_thinking": true
+        }
+      ]
+    }
+  }
+}
+```
+
+`protocol` selects which OpenAI-compatible API the model is called through, and must be either `chat_completions` or `responses`. Set `supports_thinking` to `true` for custom Mantle models that accept OpenAI reasoning effort parameters; Zed will then expose `low`, `medium`, `high`, and `xhigh` in the thinking effort picker, while disabling thinking sends `none`.
+
 ## OpenAI-Compatible Gateways {#openai-compatible}
 
 If your gateway exposes an OpenAI-compatible API, configure it with [Use API Access](./use-api-access.md#openai-compatible).
diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md
index 0ddf85ba33b..7af0c0e68a3 100644
--- a/docs/src/ai/use-api-access.md
+++ b/docs/src/ai/use-api-access.md
@@ -406,8 +406,8 @@ The available configuration options for custom OpenCode models are:
 - `display_name` (optional): human-readable model name shown in the UI, such as `Custom GLM 9000`
 - `max_tokens` (required): maximum model context window size, such as `1000000`
 - `max_output_tokens` (optional): maximum tokens the model can generate, such as `64000`
-- `protocol` (required): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"`
-- `reasoning_effort_levels` (optional): list of supported reasoning effort levels, such as `["low", "medium", "high", "max"]`. The last value in the list is used as the default
+- `protocol` (optional, default `"openai_chat"`): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"`
+- `reasoning_effort_levels` (optional): list of supported reasoning effort levels, such as `["none", "low", "medium", "high", "xhigh", "max"]`. The last value in the list is used as the default
 - `interleaved_reasoning` (optional, default `false`): whether thinking tokens are sent as a dedicated `reasoning_content` field. Applies only when using the `openai_chat` protocol
 - `subscription` (optional): `"zen"`, `"go"`, or `"free"`; defaults to `"zen"`
 - `custom_model_api_url` (optional): custom API base URL to use instead of the default OpenCode API
diff --git a/docs/src/authentication.md b/docs/src/authentication.md
index 8d0b0b7c0dc..2565afc2ddf 100644
--- a/docs/src/authentication.md
+++ b/docs/src/authentication.md
@@ -38,5 +38,5 @@ Stripe is used for billing, and will use your Zed account's email address when s
 
 ## Hiding Sign In button from the interface
 
-In case the Sign In feature is not used, it's possible to hide that from the interface by using `show_sign_in` settings property.
+In case the Sign In feature is not used, it's possible to hide that from the interface by using the `show_sign_in` settings property.
 Refer to [Visual Customization page](./visual-customization.md) for more details.
diff --git a/docs/src/business/privacy.md b/docs/src/business/privacy.md
index 5b2e7e6e2e9..aeaaf3aa088 100644
--- a/docs/src/business/privacy.md
+++ b/docs/src/business/privacy.md
@@ -36,7 +36,7 @@ Neither option is available to Zed Business members.
 
 ## What data still leaves the organization
 
-These controls cover what Zed stores and trains on. They don't change how AI inference works: when members use Zed's hosted models, prompts and code context are still sent to the relevant provider (Anthropic, OpenAI, Google, etc.) to generate responses. Zed maintains no-training commitments with these providers, and zero-data-retention commitments for all models except [provider-designated models with safety retention](../ai/privacy-and-security.md#provider-safety-retention), such as Anthropic's Mythos-class models. See [AI Privacy](../ai/privacy-and-security.md#data-retention-and-training) for details.
+These controls cover what Zed stores and trains on. They don't change how AI inference works: when members use Zed's hosted models, prompts and code context are still sent to the relevant provider (Anthropic, OpenAI, Google, etc.) to generate responses. Zed maintains no-training commitments with these providers, and zero-data-retention commitments for all models except [provider-designated models with safety retention](../ai/privacy-and-security.md#provider-safety-retention), such as Anthropic's Covered Models. See [AI Privacy](../ai/privacy-and-security.md#data-retention-and-training) for details.
 
 [Bring-your-own-key](../ai/llm-providers.md), [gateways](../ai/use-a-gateway.md), [local or self-hosted models](../ai/use-a-local-model.md), [External Agents](../ai/external-agents.md), and [Terminal Threads](../ai/terminal-threads.md) are subject to each provider, gateway, server, agent, or CLI's own terms.
 
diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md
index cff57ca2c06..789e463d9c3 100644
--- a/docs/src/command-palette.md
+++ b/docs/src/command-palette.md
@@ -11,4 +11,4 @@ The Command Palette is the main way to access actions in Zed. Its keybinding is
 
 To try it, open the Command Palette and type `new file`. The command list should narrow to {#action workspace::NewFile}. Press Return to create a new buffer.
 
-Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on that means you need to execute them in the Command Palette.
+Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on, it means you need to execute them in the Command Palette.
diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md
index 5113453a7ba..835fcd789d4 100644
--- a/docs/src/configuring-languages.md
+++ b/docs/src/configuring-languages.md
@@ -214,7 +214,7 @@ Here's how you would structure these settings in Zed's `settings.json`:
 
 #### Possible configuration options
 
-Depending on how a particular language server is implemented, they may depend on different configuration options, both specified in the LSP.
+Language servers may use different configuration options depending on the implementation.
 
 - [initializationOptions](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#version_3_17_0)
 
diff --git a/docs/src/debugger.md b/docs/src/debugger.md
index b503ff09849..4c7c21e5e38 100644
--- a/docs/src/debugger.md
+++ b/docs/src/debugger.md
@@ -74,7 +74,7 @@ Populate this file with the same array of objects you would place in `.zed/debug
 
 ### Launching & Attaching
 
-Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process.
+The Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process.
 Which one you choose depends on what you are trying to achieve.
 
 When launching a new instance, Zed (and the underlying debug adapter) can often do a better job at picking up the debug information compared to attaching to an existing process, since it controls the lifetime of a whole program.
diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md
index 4e14aceba40..4b57a30d5ba 100644
--- a/docs/src/development/glossary.md
+++ b/docs/src/development/glossary.md
@@ -113,7 +113,7 @@ h_flex()
 
 - `DapStore`: Is an entity that manages debugger sessions
 - `debugger::Session`: An entity that manages the lifecycle of a debug session and communication with DAPs.
-- `BreakpointStore`: Is an entity that manages breakpoints states in local and remote instances of Zed
+- `BreakpointStore`: Is an entity that manages breakpoint states in local and remote instances of Zed
 - `DebugSession`: Manages a debug session's UI and running state
 - `RunningState`: Directly manages all the views of a debug session
 - `VariableList`: The variable and watch list view of a debug session
diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md
index 38f578b36b8..b9882c51354 100644
--- a/docs/src/development/windows.md
+++ b/docs/src/development/windows.md
@@ -21,6 +21,8 @@ Clone the [Zed repository](https://github.com/zed-industries/zed).
 - Install the Windows 11 or 10 SDK for your system, and make sure at least `Windows 10 SDK version 2104 (10.0.20348.0)` is installed. You can download it from the [Windows SDK Archive](https://developer.microsoft.com/windows/downloads/windows-sdk/).
 - Install [CMake](https://cmake.org/download) (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)). Or you can install it through Visual Studio Installer, then manually add the `bin` directory to your `PATH`, for example: `C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin`.
 
+> Starting with Visual Studio 2026 (or MSVC 14.50), you need to install optional components `MSVC Build Tools for x64/x86 (Latest)` and `C++ Spectre-mitigated libraries for x64/x86 (Latest MSVC)`, since Microsoft has decoupled the MSVC version from the Visual Studio version. The traditional naming format `MSVC v*** - VS YYYY C++ x64/x86 ...` will no longer be applicable to newer MSVC toolchain. See [this blog](https://devblogs.microsoft.com/cppblog/new-release-cadence-and-support-lifecycle-for-msvc-build-tools/) for more details.
+
 If you cannot compile Zed, make sure a Visual Studio installation includes at least the following components:
 
 ```json
diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md
index 5c019ecac36..7af28e062c5 100644
--- a/docs/src/diagnostics.md
+++ b/docs/src/diagnostics.md
@@ -29,12 +29,12 @@ The scrollbar ones are configured with the
 
 configuration (possible values: `"none"`, `"error"`, `"warning"`, `"information"`, `"all"` (default))
 
-The diagnostics could be hovered to display a tooltip with full, rendered diagnostic message.
+The diagnostics could be hovered to display a tooltip with a full, rendered diagnostic message.
 Or, `editor::GoToDiagnostic` and `editor::GoToPreviousDiagnostic` could be used to navigate between diagnostics in the editor, showing a popover for the currently active diagnostic.
 
 # Inline diagnostics (Error lens)
 
-Zed supports showing diagnostic as lens to the right of the code.
+Zed supports showing diagnostics as a lens to the right of the code.
 This is disabled by default, but can either be temporarily turned on (or off) using the editor menu, or permanently, using the
 
 ```json [settings]
diff --git a/docs/src/environment.md b/docs/src/environment.md
index bc39c00fdd0..538134694ac 100644
--- a/docs/src/environment.md
+++ b/docs/src/environment.md
@@ -89,7 +89,7 @@ For this look-up, Zed uses the following environment:
 
 ### Language servers
 
-After looking up a language server, Zed starts them.
+After looking up a language server, Zed starts it.
 
 These language server processes always inherit Zed's process environment. But, depending on the language server look-up, additional environment variables might be set or overwrite the process environment.
 
diff --git a/docs/src/extensions/debugger-extensions.md b/docs/src/extensions/debugger-extensions.md
index 57b98234406..08d95119077 100644
--- a/docs/src/extensions/debugger-extensions.md
+++ b/docs/src/extensions/debugger-extensions.md
@@ -81,7 +81,7 @@ Your extension can define one or more debug locators. Each debug locator must be
 ```
 
 Locators have two components.
-First, each locator is ran on each available task to figure out if any of the available locators can provide a debug scenario for a given task. This is done by calling `dap_locator_create_scenario`.
+First, each locator is run on each available task to figure out if any of the available locators can provide a debug scenario for a given task. This is done by calling `dap_locator_create_scenario`.
 
 ```rust
 impl zed::Extension for MyExtension {
diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md
index d341684f89c..13dfa2a4aaf 100644
--- a/docs/src/extensions/developing-extensions.md
+++ b/docs/src/extensions/developing-extensions.md
@@ -68,7 +68,7 @@ my-extension/
 
 ## Rust and WebAssembly
 
-> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence custom Rust in order to function properly.
+> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence of custom Rust in order to function properly.
 
 Procedural parts of extensions are written in Rust and compiled to WebAssembly. To develop an extension that includes custom code, include a `Cargo.toml` like this:
 
@@ -103,7 +103,7 @@ impl zed::Extension for MyExtension {
 zed::register_extension!(MyExtension);
 ```
 
-> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the users `PATH`.
+> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the user's `PATH`.
 
 ### Debugging your Rust extension
 
@@ -158,15 +158,15 @@ Also, ensure that you have filled out all the required fields in the manifest.
 Furthermore, please make sure that your extension fulfills the following preconditions before you move on to publishing your extension:
 
 - Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions.
-- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced.
+- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule is an extension that provides support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced.
 - Your extension must only include the resources it requires to function and nothing else.
   - See the [directory structure of a Zed extension](#directory-structure-of-a-zed-extension) and the [Rust and WebAssembly](#rust-and-webassembly) sections for more information.
 - Extensions must in no way attempt to read nor modify the environment outside of the environment designated to them by Zed. Should they need to read the environment, they should use methods as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/) and may fall back to appropriate methods from the Rust standard library. Should they need changes to the environment, they must instead ask the user to perform these for them using an appropriate method within the context (e.g. provide information for doing so using the `ContextServerConfiguration` for context servers).
   - Please make sure to have read the [Rust and WebAssembly section above](#rust-and-webassembly) for more information and help regarding this topic.
 - Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately.
   - If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis.
-- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/).
-- Themes and icon themes should not be published as part of extensions that provide other features, e.g. language support. Instead, they should be published as a distinct extension. This also applies to theme and icon themes living in the same repository.
+- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the user's environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/).
+- Themes and icon themes should not be published as part of extensions that provide other features, e.g. language support. Instead, they should be published as a distinct extension. This also applies to themes and icon themes living in the same repository.
 
 Non-compliance with these rules will be raised during the publishing process by reviewers. If you fail to comply with the laid out guidelines, the publishing of your extension will either be delayed or rejected.
 
@@ -185,7 +185,7 @@ git submodule add https://github.com/your-username/foobar-zed.git extensions/my-
 git add extensions/my-extension
 ```
 
-> All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). Furthermore, your extension repository must be publicly available and the checked out submodule commit must be on a branch and thus not be detached commit.
+> All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). Furthermore, your extension repository must be publicly available and the checked out submodule commit must be on a branch and thus not be a detached commit.
 
 2. Add a new entry to the top-level `extensions.toml` file containing your extension:
 
diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md
index 23401ed93d0..a801da61317 100644
--- a/docs/src/finding-navigating.md
+++ b/docs/src/finding-navigating.md
@@ -23,6 +23,10 @@ The Project Panel ({#kb project_panel::ToggleFocus}) shows a tree view of your w
 
 Open any file in your project with {#kb file_finder::Toggle}. Type part of the filename or path to narrow results.
 
+## Text Finder
+
+Quickly find any string in your project and open the file with {#kb project_search::OpenTextFinder}. Changed your mind and want a more detailed search with extra filters? Move to the project search using the button in the Actions menu in the right bottom corner.
+
 ## Project Search
 
 Search across all files with {#kb pane::DeploySearch}. Type the query in the search field, then press Enter to run the search.
@@ -52,15 +56,16 @@ Quickly switch between open tabs with {#kb tab_switcher::Toggle}. Tabs are sorte
 
 ## Quick Reference
 
-| Task              | Keybinding                       |
-| ----------------- | -------------------------------- |
-| Command Palette   | {#kb command_palette::Toggle}    |
-| Open file         | {#kb file_finder::Toggle}        |
-| Project search    | {#kb pane::DeploySearch}         |
-| Go to definition  | {#kb editor::GoToDefinition}     |
-| Find references   | {#kb editor::FindAllReferences}  |
-| Symbol in file    | {#kb outline::Toggle}            |
-| Symbol in project | {#kb project_symbols::Toggle}    |
-| Outline Panel     | {#kb outline_panel::ToggleFocus} |
-| Tab Switcher      | {#kb tab_switcher::Toggle}       |
-| Project Panel     | {#kb project_panel::ToggleFocus} |
+| Task               | Keybinding                           |
+| ------------------ | ------------------------------------ |
+| Command Palette    | {#kb command_palette::Toggle}        |
+| Open file          | {#kb file_finder::Toggle}            |
+| Project search     | {#kb pane::DeploySearch}             |
+| Text search picker | {#kb project_search::OpenTextFinder} |
+| Go to definition   | {#kb editor::GoToDefinition}         |
+| Find references    | {#kb editor::FindAllReferences}      |
+| Symbol in file     | {#kb outline::Toggle}                |
+| Symbol in project  | {#kb project_symbols::Toggle}        |
+| Outline Panel      | {#kb outline_panel::ToggleFocus}     |
+| Tab Switcher       | {#kb tab_switcher::Toggle}           |
+| Project Panel      | {#kb project_panel::ToggleFocus}     |
diff --git a/docs/src/git.md b/docs/src/git.md
index 68514d44092..07f2a00556e 100644
--- a/docs/src/git.md
+++ b/docs/src/git.md
@@ -293,7 +293,7 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo
 }
 ```
 
-To add custom commit instructions for the model, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows.
+To add custom commit instructions for the model, use the global `AGENTS.md` file located at `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows.
 
 To add custom instructions that apply only to commit message generation, use the `commit_message_instructions` field in your agent settings:
 
@@ -305,7 +305,7 @@ To add custom instructions that apply only to commit message generation, use the
 }
 ```
 
-These instructions are sent to the model in addition to any instruction files, such as `.rules` or `AGENTS.md`. To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows.
+These instructions are sent to the model in addition to any instruction files, such as `.rules` or `AGENTS.md`. To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located at `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows.
 
 > Before Zed v1.4.0, this was done through the Rules Library, which has been removed.
 > See [Migrating from Rules](./ai/instructions.md#migrating-from-rules) for more information.
diff --git a/docs/src/languages/ansible.md b/docs/src/languages/ansible.md
index fd595bc7e33..cf85b68fbe2 100644
--- a/docs/src/languages/ansible.md
+++ b/docs/src/languages/ansible.md
@@ -57,7 +57,7 @@ If your inventory file is in the YAML format, you can either:
 # yaml-language-server: $schema=https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json
 ```
 
-- or, configure the YAML language server settings to set this schema for all your inventory files, that match your inventory pattern, under your Zed settings ([ref](https://zed.dev/docs/languages/yaml)):
+- or, configure the YAML language server settings to set this schema for all your inventory files that match your inventory pattern, under your Zed settings ([ref](https://zed.dev/docs/languages/yaml)):
 
 ```json [settings]
 {
diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md
index 44025da5544..e8e8acf9f61 100644
--- a/docs/src/languages/cpp.md
+++ b/docs/src/languages/cpp.md
@@ -176,7 +176,7 @@ Automatically dims inactive sections of code due to preprocessor directives, suc
 
 ### Switch Between Source and Header Files
 
-Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`).
+Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`)
 by running the command {#action editor::SwitchSourceHeader} from the command palette or by setting
 a keybinding for the `editor::SwitchSourceHeader` action.
 
diff --git a/docs/src/languages/elixir.md b/docs/src/languages/elixir.md
index cc9f498a3d5..8a290a0d7a7 100644
--- a/docs/src/languages/elixir.md
+++ b/docs/src/languages/elixir.md
@@ -163,7 +163,7 @@ Enable Next LS by adding the following to your settings file:
 
 Next LS can accept initialization options.
 
-Completions are an experimental feature within Next LS, they are enabled by default in Zed. Disable them by adding the following to your settings file:
+Completions are an experimental feature within Next LS, and are enabled by default in Zed. Disable them by adding the following to your settings file:
 
 ```json [settings]
   "lsp": {
diff --git a/docs/src/languages/gleam.md b/docs/src/languages/gleam.md
index dba403d4ede..af19b77886a 100644
--- a/docs/src/languages/gleam.md
+++ b/docs/src/languages/gleam.md
@@ -10,6 +10,31 @@ Gleam support is available through the [Gleam extension](https://github.com/glea
 - Tree-sitter: [gleam-lang/tree-sitter-gleam](https://github.com/gleam-lang/tree-sitter-gleam)
 - Language Server: [gleam lsp](https://github.com/gleam-lang/gleam/tree/main/compiler-core/src/language_server)
 
+## Using the Tailwind CSS Language Server with Gleam
+
+To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Gleam files, you need to enable the language server for Gleam and configure where it should look for CSS classes by adding the following to your `settings.json`:
+
+```json [settings]
+{
+  "languages": {
+    "Gleam": {
+      "language_servers": ["tailwindcss-language-server", "..."]
+    }
+  },
+  "lsp": {
+    "tailwindcss-language-server": {
+      "settings": {
+        "experimental": {
+          "classRegex": ["\"([^\"]*)\""]
+        }
+      }
+    }
+  }
+}
+```
+
+This works with plain string literals and with [Lustre](https://github.com/lustre-labs/lustre) view templates where class names are passed as string arguments.
+
 See also:
 
 - [Elixir](./elixir.md)
diff --git a/docs/src/languages/go.md b/docs/src/languages/go.md
index c535acd80f0..c7ed310b3bd 100644
--- a/docs/src/languages/go.md
+++ b/docs/src/languages/go.md
@@ -234,3 +234,33 @@ In such case Zed won't spawn a new instance of Delve, as it opts to use an exist
 - Tree-sitter:
   [tree-sitter-go-work](https://github.com/d1y/tree-sitter-go-work)
 - Language Server: N/A
+
+## Using the Tailwind CSS Language Server with Templ
+
+To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in [Templ](https://github.com/a-h/templ) files, you need to enable the language server for Templ and configure where it should look for CSS classes by adding the following to your `settings.json`:
+
+```json [settings]
+{
+  "languages": {
+    "Templ": {
+      "language_servers": ["tailwindcss-language-server", "..."]
+    }
+  },
+  "lsp": {
+    "tailwindcss-language-server": {
+      "settings": {
+        "includeLanguages": {
+          "templ": "html"
+        },
+        "experimental": {
+          "classRegex": ["class=\"([^\"]*)\""]
+        }
+      }
+    }
+  }
+}
+```
+
+> Note: Unlike other languages, you need to tell Tailwind to treat `.templ` files as HTML explicitly.
+
+This gives you Tailwind CSS completions inside `class="..."` attributes in your `.templ` files.
diff --git a/docs/src/languages/java.md b/docs/src/languages/java.md
index a789506e1c5..a9bc465e2e0 100644
--- a/docs/src/languages/java.md
+++ b/docs/src/languages/java.md
@@ -82,7 +82,7 @@ You should then be able to start a new Debug Session with the "Launch Debugger"
 
 This extension provides tasks for running your application and tests from within Zed via little play buttons next to tests/entry points. However, due to current limitations of Zed's extension interface, we can not provide scripts that will work across Maven and Gradle on both Windows and Unix-compatible systems, so out of the box the launch scripts only work on Mac and Linux.
 
-There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zeds documentation](https://zed.dev/docs/tasks) for more information.
+There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zed's documentation](https://zed.dev/docs/tasks) for more information.
 
 ## Advanced Configuration/JDTLS initialization Options
 
diff --git a/docs/src/languages/javascript.md b/docs/src/languages/javascript.md
index 47511ec713d..16e5c2ad604 100644
--- a/docs/src/languages/javascript.md
+++ b/docs/src/languages/javascript.md
@@ -107,7 +107,7 @@ You can also only execute a single ESLint rule when using `fixAll`:
 ```
 
 > **Note:** the other formatter you have configured will still run, after ESLint.
-> So if your language server or Prettier configuration don't format according to
+> So if your language server or Prettier configuration doesn't format according to
 > ESLint's rules, then they will overwrite what ESLint fixed and you end up with
 > errors.
 
diff --git a/docs/src/languages/json.md b/docs/src/languages/json.md
index 41644a8b055..dcede992407 100644
--- a/docs/src/languages/json.md
+++ b/docs/src/languages/json.md
@@ -43,7 +43,7 @@ Zed automatically out of the box supports JSON Schema validation of `package.jso
 
 To specify a schema inline with your JSON files, add a `$schema` top level key linking to your json schema file.
 
-For example to for a `.luarc.json` for use with [lua-language-server](https://github.com/LuaLS/lua-language-server/):
+For example, for a `.luarc.json` for use with [lua-language-server](https://github.com/LuaLS/lua-language-server/):
 
 ```json
 {
diff --git a/docs/src/languages/kotlin.md b/docs/src/languages/kotlin.md
index 262dce5ac45..93f48980a70 100644
--- a/docs/src/languages/kotlin.md
+++ b/docs/src/languages/kotlin.md
@@ -9,10 +9,45 @@ Kotlin language support in Zed is provided by the community-maintained [Kotlin e
 Report issues to: [https://github.com/zed-extensions/kotlin/issues](https://github.com/zed-extensions/kotlin/issues)
 
 - Tree-sitter: [fwcd/tree-sitter-kotlin](https://github.com/fwcd/tree-sitter-kotlin)
-- Language Server: [fwcd/kotlin-language-server](https://github.com/fwcd/kotlin-language-server)
-- Alternate Language Server: [kotlin/kotlin-lsp](https://github.com/kotlin/kotlin-lsp)
+- Language Server: [kotlin/kotlin-lsp](https://github.com/kotlin/kotlin-lsp)
+- Alternate Language Server: [fwcd/kotlin-language-server](https://github.com/fwcd/kotlin-language-server)
 
-## Configuration
+## Kotlin LSP
+
+[Kotlin LSP](https://github.com/kotlin/kotlin-lsp) is the official language server for Kotlin, built by JetBrains. It is used by default.
+
+It is downloaded and updated automatically. If you want to use a manually installed version instead, set the path to the `kotlin-lsp.sh` script from the release assets in your `settings.json`:
+
+```json [settings]
+{
+  "lsp": {
+    "kotlin-lsp": {
+      "binary": {
+        "path": "path/to/kotlin-lsp.sh",
+        "arguments": ["--stdio"]
+      }
+    }
+  }
+}
+```
+
+Note that the `kotlin-lsp.sh` script expects to be run from within the unzipped release zip file, and should not be moved elsewhere.
+
+## Kotlin Language Server
+
+The community-maintained [Kotlin Language Server](https://github.com/fwcd/kotlin-language-server) can be used instead of Kotlin LSP by explicitly enabling it in your `settings.json`:
+
+```json [settings]
+{
+  "languages": {
+    "Kotlin": {
+      "language_servers": ["kotlin-language-server", "!kotlin-lsp", "..."]
+    }
+  }
+}
+```
+
+### Configuration
 
 Workspace configuration options can be passed to the language server via lsp
 settings in `settings.json`.
@@ -21,7 +56,7 @@ The full list of lsp `settings` can be found
 [here](https://github.com/fwcd/kotlin-language-server/blob/main/server/src/main/kotlin/org/javacs/kt/Configuration.kt)
 under `class Configuration` and initialization_options under `class InitializationOptions`.
 
-### JVM Target
+#### JVM Target
 
 The following example changes the JVM target from `default` (which is 1.8) to
 `17`:
@@ -42,7 +77,7 @@ The following example changes the JVM target from `default` (which is 1.8) to
 }
 ```
 
-### JAVA_HOME
+#### JAVA_HOME
 
 To use a specific java installation, just specify the `JAVA_HOME` environment variable with:
 
diff --git a/docs/src/languages/powershell.md b/docs/src/languages/powershell.md
index 27e5daf09c5..79cf7d65795 100644
--- a/docs/src/languages/powershell.md
+++ b/docs/src/languages/powershell.md
@@ -25,9 +25,9 @@ The Zed PowerShell extension will default to the `pwsh` executable found in your
 
 ### Install PowerShell Editor Services (Optional) {#powershell-editor-services}
 
-The Zed PowerShell extensions will attempt to download [PowerShell Editor Services](https://github.com/PowerShell/PowerShellEditorServices) automatically.
+The Zed PowerShell extension will attempt to download [PowerShell Editor Services](https://github.com/PowerShell/PowerShellEditorServices) automatically.
 
-If want to use a specific binary, you can specify in your that in your Zed settings.json:
+If you want to use a specific binary, you can specify that in your Zed settings.json:
 
 ```json [settings]
   "lsp": {
diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md
index 0dd931f5140..4ca22cd9f83 100644
--- a/docs/src/languages/python.md
+++ b/docs/src/languages/python.md
@@ -444,4 +444,4 @@ If a language server isn't responding or features like diagnostics or autocomple
 - Verify your `settings.json` or `pyrightconfig.json` is syntactically correct.
 - Restart Zed to reinitialize language server connections, or try restarting the language server using the {#action editor::RestartLanguageServer}
 
-If the language server is failing to resolve imports, and you're using a virtual environment, make sure that the right environment is chosen in the selector. You can use "Server Info" view to confirm which virtual environment Zed is sending to the language server—look for the `* Configuration` section at the end.
+If the language server is failing to resolve imports, and you're using a virtual environment, make sure that the right environment is chosen in the selector. You can use the "Server Info" view to confirm which virtual environment Zed is sending to the language server—look for the `* Configuration` section at the end.
diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md
index 475c7e26cd0..585eee0b6e2 100644
--- a/docs/src/languages/ruby.md
+++ b/docs/src/languages/ruby.md
@@ -15,6 +15,8 @@ Ruby support is available through the [Ruby extension](https://github.com/zed-ex
   - [solargraph](https://github.com/castwide/solargraph)
   - [rubocop](https://github.com/rubocop/rubocop)
   - [Herb](https://herb-tools.dev)
+  - [kanayago](https://github.com/S-H-GAMELINKS/kanayago)
+  - [fuzzy-ruby-server](https://github.com/doompling/fuzzy_ruby_server)
 - Debug Adapter: [`rdbg`](https://github.com/ruby/debug)
 
 The Ruby extension also provides support for ERB files.
@@ -34,6 +36,8 @@ In addition to these two language servers, Zed also supports:
 - [sorbet](https://sorbet.org/) which is a static type checker for Ruby with a custom gradual type system.
 - [steep](https://github.com/soutaro/steep) which is a static type checker for Ruby that uses Ruby Signature (RBS).
 - [Herb](https://herb-tools.dev) which is a language server for ERB files.
+- [kanayago](https://github.com/S-H-GAMELINKS/kanayago) which is a Ruby language server that makes Ruby's parser available as a gem.
+- [fuzzy-ruby-server](https://github.com/doompling/fuzzy_ruby_server) which is a Ruby language server designed for large codebases, using full-text search for fast, fuzzy search results that approximate Ruby's behavior.
 
 When configuring a language server, it helps to open the LSP Logs window using the 'dev: Open Language Server Logs' command. You can then choose the corresponding language instance to see any logged information.
 
@@ -43,7 +47,7 @@ The [Ruby extension](https://github.com/zed-extensions/ruby) offers both `solarg
 
 ### Language Server Activation
 
-For all supported Ruby language servers (`solargraph`, `ruby-lsp`, `rubocop`, `sorbet`, and `steep`), the Ruby extension follows this activation sequence:
+For all supported Ruby language servers (`solargraph`, `ruby-lsp`, `rubocop`, `sorbet` and `steep`), the Ruby extension follows this activation sequence:
 
 1. If the language server is found in your project's `Gemfile`, it will be used through `bundle exec`.
 2. If not found in the `Gemfile`, the Ruby extension will look for the executable in your system `PATH`.
diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md
index 8568ca27ffd..bc6a97a6d96 100644
--- a/docs/src/languages/rust.md
+++ b/docs/src/languages/rust.md
@@ -70,7 +70,7 @@ You can configure which `rust-analyzer` binary Zed should use.
 
 By default, Zed will try to find a `rust-analyzer` in your `$PATH` and try to use that. If that binary successfully executes `rust-analyzer --help`, it's used. Otherwise, Zed will fall back to installing its own stable `rust-analyzer` version and use that.
 
-If you want to install pre-release `rust-analyzer` version instead you can instruct Zed to do so by setting `pre_release` to `true` in your `settings.json`:
+If you want to install a pre-release `rust-analyzer` version instead, you can instruct Zed to do so by setting `pre_release` to `true` in your `settings.json`:
 
 ```json [settings]
 {
@@ -163,7 +163,7 @@ If disabled with `checkOnSave: false` (see the example of the server configurati
 TBD: Is it possible to specify RUSTFLAGS? https://github.com/zed-industries/zed/issues/14334
 -->
 
-Rust-analyzer [manual](https://rust-analyzer.github.io/book/) describes various features and configuration options for rust-analyzer language server.
+The Rust-analyzer [manual](https://rust-analyzer.github.io/book/) describes various features and configuration options for the rust-analyzer language server.
 Rust-analyzer in Zed runs with the default parameters.
 
 ### Large projects and performance
diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md
index e461aa2d7fa..2c95d85a6ca 100644
--- a/docs/src/languages/tailwindcss.md
+++ b/docs/src/languages/tailwindcss.md
@@ -14,7 +14,8 @@ Languages which can be used with Tailwind CSS in Zed:
 - [Astro](./astro.md#using-the-tailwind-css-language-server-with-astro)
 - [CSS](./css.md)
 - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby)
-- [Gleam](./gleam.md)
+- [Gleam](./gleam.md#using-the-tailwind-css-language-server-with-gleam)
+- [Go (Templ)](./go.md#using-the-tailwind-css-language-server-with-templ)
 - [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates)
 - [HTML](./html.md#using-the-tailwind-css-language-server-with-html)
 - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript)
diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md
index c4c454118ec..d67c65c248b 100644
--- a/docs/src/languages/typescript.md
+++ b/docs/src/languages/typescript.md
@@ -254,13 +254,13 @@ If your use-case isn't covered by any of these, you can take full control by add
 
 ### Configuring JavaScript debug tasks
 
-JavaScript debugging is more complicated than other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`.
+JavaScript debugging is more complicated than for other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`.
 
 - [vscode-js-debug configuration documentation](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md)
 
 ### Attach debugger to a server running in web browser (`npx serve`)
 
-Given an externally-ran web server (e.g., with `npx serve` or `npx live-server`) one can attach to it and open it with a browser.
+Given an externally-run web server (e.g., with `npx serve` or `npx live-server`) one can attach to it and open it with a browser.
 
 ```json [debug]
 [
diff --git a/docs/src/linux.md b/docs/src/linux.md
index 410e8f153f0..bfc61e90455 100644
--- a/docs/src/linux.md
+++ b/docs/src/linux.md
@@ -134,7 +134,7 @@ If Zed was installed using a package manager, please consult the documentation f
 
 ## Troubleshooting
 
-Linux works on a large variety of systems configured in many different ways. We primarily test Zed on a vanilla Ubuntu setup, as it is the most common distribution our users use, that said we do expect it to work on a wide variety of machines.
+Linux works on a large variety of systems configured in many different ways. We primarily test Zed on a vanilla Ubuntu setup, as it is the most common distribution our users use. That said, we do expect it to work on a wide variety of machines.
 
 ### Zed fails to start
 
diff --git a/docs/src/performance.md b/docs/src/performance.md
index c97dd23d195..5ecb906289e 100644
--- a/docs/src/performance.md
+++ b/docs/src/performance.md
@@ -22,7 +22,7 @@ The profile.json does not contain any symbols. Firefox profiler can add the loca
 See how long each annotated function call took and its arguments (if
 configured).
 
-Annotate any function you need appear in the profile with instrument. For more
+Annotate any function you need to appear in the profile with instrument. For more
 details see
 [tracing-instrument](https://docs.rs/tracing/latest/tracing/attr.instrument.html):
 
@@ -33,7 +33,7 @@ fn should_appear_in_profile(kitty: Cat) {
 }
 ```
 
-Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zeds performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release.
+Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zed's performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release.
 
 ## One time Setup/Building the profiler:
 
@@ -55,7 +55,7 @@ Open the profiler (tracy-profiler), you should see zed in the list of `Discovere
 
 image
 
-Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow.
+Tracy is an incredibly powerful profiler which can do a lot; however, its UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow.
 
 Here are the steps:
 
@@ -83,7 +83,7 @@ Here are the steps:
 
 Scroll to zoom in
 
-7. Click on a caller to to get statistics on _it_.
+7. Click on a caller to get statistics on _it_.
 
 Click on any of the zones to get statistics
 
diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md
index c1a190d8905..030177af8f6 100644
--- a/docs/src/project-panel.md
+++ b/docs/src/project-panel.md
@@ -21,10 +21,12 @@ project_panel::CollapseAllEntries} collapses every directory at once. {#kb
 project_panel::ExpandAllEntries} expands every directory at once. Press {#kb
 project_panel::Open} or click to preview a selected file, without giving it a
 permanent tab. Editing the file or double-clicking it promotes it to a permanent tab.
+Middle-clicking a file skips the preview and opens it in a permanent, focused tab
+right away.
 
 ### Auto-reveal
 
-By default, switching files in the editor will automatically highlight it in the
+By default, switching to a file in the editor will automatically highlight it in the
 project panel and scroll it into view. This can be disabled with the
 `project_panel.auto_reveal_entries` setting.
 
diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md
index ebeacf2bb96..2ded300ce5d 100644
--- a/docs/src/reference/all-settings.md
+++ b/docs/src/reference/all-settings.md
@@ -1905,7 +1905,7 @@ While other options may be changed at a runtime and should be placed under `sett
 }
 ```
 
-## Format On Save
+## Format On Save {#format-on-save}
 
 - Description: Whether or not to perform a buffer format before saving.
 - Setting: `format_on_save`
@@ -1929,6 +1929,26 @@ While other options may be changed at a runtime and should be placed under `sett
 }
 ```
 
+3. `modifications`, formats only lines with unstaged changes:
+
+```json [settings]
+{
+  "format_on_save": "modifications"
+}
+```
+
+This mode requires source control and LSP range formatting support. If no git diff is available or if the LSP doesn't support range formatting, formatting is skipped. This is useful for editing legacy codebases where you want to avoid formatting changes in unrelated code.
+
+4. `modifications_if_available`, formats only modified lines with fallback to full file formatting:
+
+```json [settings]
+{
+  "format_on_save": "modifications_if_available"
+}
+```
+
+Similar to `modifications`, but behaves like `on` when range formatting cannot be applied: when no git diff is available (e.g., when source control is unavailable) or when the language server does not support range formatting. When a git diff is available but contains no unstaged changes, nothing is formatted.
+
 ## Formatter
 
 - Description: How to perform a buffer format.
@@ -2740,7 +2760,7 @@ Example:
 
 **Options**
 
-Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon themes names.
+Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon theme names.
 
 ### Light
 
@@ -2750,7 +2770,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s
 
 **Options**
 
-Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon themes names.
+Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon theme names.
 
 ## Image Viewer
 
@@ -3279,7 +3299,7 @@ Examples:
 
 - Description:
   Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \
-   There are several ways to convert a preview tab into a regular tab:
+  There are several ways to convert a preview tab into a regular tab:
 
   - Double-clicking on the file
   - Double-clicking on the tab header
@@ -4158,6 +4178,7 @@ List of `integer` column numbers
     "blinking": "terminal_controlled",
     "copy_on_select": false,
     "keep_selection_on_copy": true,
+    "open_links_in_mouse_mode": true,
     "dock": "bottom",
     "default_width": 640,
     "default_height": 320,
@@ -4352,6 +4373,26 @@ List of `integer` column numbers
 }
 ```
 
+### Terminal: Open Links In Mouse Mode
+
+- Description: 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).
+- Setting: `open_links_in_mouse_mode`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+**Example**
+
+```json [settings]
+{
+  "terminal": {
+    "open_links_in_mouse_mode": false
+  }
+}
+```
+
 ### Terminal: Env
 
 - Description: Any key-value pairs added to this object will be added to the terminal's environment. Keys must be unique, use `:` to separate multiple values in a single variable
@@ -4855,7 +4896,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";`
 
 **Options**
 
-Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid themes names.
+Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid theme names.
 
 ### Light
 
@@ -4865,7 +4906,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 
 **Options**
 
-Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid themes names.
+Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid theme names.
 
 ## Title Bar
 
diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md
index 9c9e8152fe6..d23f9520aa7 100644
--- a/docs/src/remote-development.md
+++ b/docs/src/remote-development.md
@@ -18,7 +18,7 @@ Remote development requires two computers, your local machine that runs the Zed
 
 On your local machine, Zed runs its UI, talks to language models, uses Tree-sitter to parse and syntax-highlight code, and stores unsaved changes and recent projects. The source code, language servers, tasks, and the terminal all run on the remote server. [AI features](./ai/overview.md) work in remote sessions, including the Agent Panel and Inline Assistant.
 
-> **Note:** The original version of remote development sent traffic via Zed's servers. As of Zed v0.157 you can no-longer use that mode.
+> **Note:** The original version of remote development sent traffic via Zed's servers. As of Zed v0.157 you can no longer use that mode.
 
 ## Setup
 
@@ -244,7 +244,7 @@ If you do this, you must upload it to `~/.zed_server/zed-remote-server-{RELEASE_
 
 ## Maintaining the SSH connection
 
-Once the server is initialized. Zed will create new SSH connections (reusing the existing ControlMaster) to run the remote development server.
+Once the server is initialized, Zed will create new SSH connections (reusing the existing ControlMaster) to run the remote development server.
 
 Each connection tries to run the development server in proxy mode. This mode will start the daemon if it is not running, and reconnect to it if it is. This way when your connection drops and is restarted, you can continue to work without interruption.
 
diff --git a/docs/src/repl.md b/docs/src/repl.md
index b23bb27094f..255e1a40add 100644
--- a/docs/src/repl.md
+++ b/docs/src/repl.md
@@ -79,7 +79,7 @@ On macOS, your system Python will _not_ work. Either set up [pyenv](https://gith
 
 
 
-To setup your current Python to have an available kernel, run:
+To set up your current Python to have an available kernel, run:
 
 ```sh
 pip install ipykernel
@@ -217,4 +217,4 @@ Available kernels:
   rust                  /Users/z/Library/Jupyter/kernels/rust
 ```
 
-> Note: Zed makes best effort usage of `sys.prefix` and `CONDA_PREFIX` to find kernels in Python environments. If you want to explicitly control run `python -m ipykernel install --user --name myenv --display-name "Python (myenv)"` to install the kernel directly while in the environment.
+> Note: Zed makes best effort usage of `sys.prefix` and `CONDA_PREFIX` to find kernels in Python environments. If you want to explicitly control this, run `python -m ipykernel install --user --name myenv --display-name "Python (myenv)"` to install the kernel directly while in the environment.
diff --git a/docs/src/terminal.md b/docs/src/terminal.md
index e4e876ab2db..f1e82ca0955 100644
--- a/docs/src/terminal.md
+++ b/docs/src/terminal.md
@@ -282,6 +282,16 @@ Common formats recognized:
 - `src/main.rs:42:10` — Opens at line 42, column 10
 - `File "script.py", line 10` — Python tracebacks
 
+By default, `Cmd+Click`/`Ctrl+Click` opens links even when the running application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). If you prefer those clicks to be forwarded to the application instead, disable `open_links_in_mouse_mode`; links can then still be opened with `Shift+Cmd+Click` (`Shift+Ctrl+Click`):
+
+```json
+{
+  "terminal": {
+    "open_links_in_mouse_mode": false
+  }
+}
+```
+
 ## Panel Configuration
 
 ### Dock Position
diff --git a/docs/src/vim.md b/docs/src/vim.md
index b8447b83e56..db8bc03d40e 100644
--- a/docs/src/vim.md
+++ b/docs/src/vim.md
@@ -297,11 +297,11 @@ These ex commands open Zed's various panels and windows.
 
 These commands navigate diagnostics.
 
-| Command                  | Description                    |
-| ------------------------ | ------------------------------ |
-| `:cn[ext]` or `:ln[ext]` | Go to the next diagnostic      |
-| `:cp[rev]` or `:lp[rev]` | Go to the previous diagnostics |
-| `:cc` or `:ll`           | Open the errors page           |
+| Command                  | Description                   |
+| ------------------------ | ----------------------------- |
+| `:cn[ext]` or `:ln[ext]` | Go to the next diagnostic     |
+| `:cp[rev]` or `:lp[rev]` | Go to the previous diagnostic |
+| `:cc` or `:ll`           | Open the errors page          |
 
 ### Git
 
diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md
index 4d5a18d7b20..4c190143c5e 100644
--- a/docs/src/worktree-trust.md
+++ b/docs/src/worktree-trust.md
@@ -15,7 +15,7 @@ To let users choose based on their own threat model and risk tolerance, all work
 
 Zed still trusts tools it installs globally. Global MCP servers and global language servers such as Prettier and Copilot are installed and started as usual, independent of worktree trust.
 
-If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree.
+If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using the `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree.
 
 Trusting a worktree persists that decision between restarts. You can clear all trusted worktrees with the `workspace::ClearTrustedWorktrees` command.
 This command will restart Zed, to ensure no untrusted settings, language servers or MCP servers persist.
diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css
index 8f5b40cc19e..f0169fd7c87 100644
--- a/docs/theme/css/chrome.css
+++ b/docs/theme/css/chrome.css
@@ -434,6 +434,7 @@ ul#searchresults span.teaser em {
 
 .sidebar {
   position: relative;
+  order: 0;
   width: var(--sidebar-width);
   flex-shrink: 0;
   display: flex;
diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css
index 9c8077bad52..d32edaa8d61 100644
--- a/docs/theme/css/general.css
+++ b/docs/theme/css/general.css
@@ -201,6 +201,7 @@ hr {
 
 .page {
   outline: 0;
+  order: 1;
   flex: 1;
   display: flex;
   flex-direction: column;
diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs
index 2c7786817aa..d5e0b620051 100644
--- a/docs/theme/index.hbs
+++ b/docs/theme/index.hbs
@@ -27,6 +27,7 @@
             })();
         
         {{ title }}
+        
         {{#if is_print }}
         
         {{/if}}
@@ -86,6 +87,9 @@
             document.body.classList.remove('no-js');
             document.body.classList.add('js');
         
+        
+ Agent documentation index: llms.txt. Markdown versions are available for docs pages. +
@@ -155,6 +159,95 @@
{{/if}} +
+
+
+ {{{ content }}} + + +
+
+ +
+ + +
+
+