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