Compare commits
177 Commits
improve-gi
...
fix-keep-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585110b2cb | ||
|
|
4059a21422 | ||
|
|
3a651c546b | ||
|
|
87014cec71 | ||
|
|
2b671a46f2 | ||
|
|
eaccd542fd | ||
|
|
5a530ecd39 | ||
|
|
233e66d35f | ||
|
|
15353630e4 | ||
|
|
5289b815fe | ||
|
|
8515487bbc | ||
|
|
19ab1eb792 | ||
|
|
722a05bc21 | ||
|
|
5b3e371812 | ||
|
|
8eca7f32e2 | ||
|
|
1a1715766f | ||
|
|
241acbe4be | ||
|
|
6ea09beea8 | ||
|
|
3e50d997dd | ||
|
|
da8bf9ad79 | ||
|
|
589af59dfe | ||
|
|
254c7a330a | ||
|
|
b6cf398eab | ||
|
|
bc5c5cf5d6 | ||
|
|
bf8aba566c | ||
|
|
e14c9479e4 | ||
|
|
97af7e1bd9 | ||
|
|
c251f2a2d4 | ||
|
|
cc56196152 | ||
|
|
405244d422 | ||
|
|
35b4a918c9 | ||
|
|
56fd950d94 | ||
|
|
88af35fe47 | ||
|
|
57ab09c2da | ||
|
|
caa4b529e4 | ||
|
|
137081f050 | ||
|
|
7c1040bc93 | ||
|
|
ff79b29f38 | ||
|
|
2e41e312ad | ||
|
|
5f92ac25a7 | ||
|
|
fb88de9223 | ||
|
|
29111304dd | ||
|
|
0ffd93774c | ||
|
|
2da2ae65a0 | ||
|
|
4bdac8026c | ||
|
|
70bde54a2c | ||
|
|
43486c416c | ||
|
|
d197c96cdc | ||
|
|
7b6b75b63f | ||
|
|
fd64ee1bb6 | ||
|
|
e1d28ff957 | ||
|
|
64ce696aae | ||
|
|
87555d3f0b | ||
|
|
5b18ce79ab | ||
|
|
1dd470ca48 | ||
|
|
8bc8d61fa6 | ||
|
|
750ceeb760 | ||
|
|
4476860664 | ||
|
|
9a20843ba2 | ||
|
|
e421fc7a2d | ||
|
|
f461290ac3 | ||
|
|
6a24b2479c | ||
|
|
2ac99e7a11 | ||
|
|
d604b3b291 | ||
|
|
4002801034 | ||
|
|
fd8480a9dc | ||
|
|
1070de47ec | ||
|
|
cfe1adc792 | ||
|
|
fd05f17fa7 | ||
|
|
7e3fd7bb02 | ||
|
|
00097df0d5 | ||
|
|
c13322397e | ||
|
|
c1307cead4 | ||
|
|
8a7bd5f47b | ||
|
|
c287397a18 | ||
|
|
a7284adafa | ||
|
|
4314b35288 | ||
|
|
ed4deaa738 | ||
|
|
f0a91502a9 | ||
|
|
1ab659c71f | ||
|
|
d470411725 | ||
|
|
6c741292df | ||
|
|
29030a243c | ||
|
|
237ce5c8e8 | ||
|
|
0c88189aab | ||
|
|
1e60ebb2c6 | ||
|
|
9efe9df800 | ||
|
|
dab0b3509d | ||
|
|
0f72d7ed52 | ||
|
|
1ceda2babd | ||
|
|
ae0d4f6a0d | ||
|
|
8980526a85 | ||
|
|
b4dc7f8a8a | ||
|
|
b94649ce1e | ||
|
|
948c1f22bb | ||
|
|
b0eac4267d | ||
|
|
8e4555455c | ||
|
|
5b97cd1900 | ||
|
|
ceab139f54 | ||
|
|
4df7f52bf3 | ||
|
|
1e67e30034 | ||
|
|
758c5fb955 | ||
|
|
acb3ecef0c | ||
|
|
ad2bfa3edd | ||
|
|
1d72fa8e9e | ||
|
|
1ce384bbda | ||
|
|
9f302df6d6 | ||
|
|
ebad5ca50e | ||
|
|
b9ff538747 | ||
|
|
c0261a1ea9 | ||
|
|
f43bcc1492 | ||
|
|
e23a4564cc | ||
|
|
f82ef1f76f | ||
|
|
b4c2ae5196 | ||
|
|
0023773c68 | ||
|
|
0bde929d54 | ||
|
|
6f60939d30 | ||
|
|
a6a7a1cc28 | ||
|
|
13f4a093c8 | ||
|
|
573836a654 | ||
|
|
048dc47d87 | ||
|
|
ffc69b07e5 | ||
|
|
dc8d0868ec | ||
|
|
58807f0dd2 | ||
|
|
313f5968eb | ||
|
|
9ab3d55211 | ||
|
|
e339566dab | ||
|
|
8ee5bf2c38 | ||
|
|
b0e0485b32 | ||
|
|
2a49f40cf5 | ||
|
|
21b4a2ecdd | ||
|
|
2a9a82d757 | ||
|
|
6e147b3b91 | ||
|
|
875c86e3ef | ||
|
|
406ffb1e20 | ||
|
|
257bedf09b | ||
|
|
37927a5dc8 | ||
|
|
d4110fd2ab | ||
|
|
3d160a6e26 | ||
|
|
c29c46d3b6 | ||
|
|
312369c84f | ||
|
|
42b2b65241 | ||
|
|
a529103825 | ||
|
|
1ed3f9eb42 | ||
|
|
59d524427e | ||
|
|
ee4b9a27a2 | ||
|
|
ae65ff95a6 | ||
|
|
fc24102491 | ||
|
|
7ca3d969e0 | ||
|
|
afbd2b760f | ||
|
|
0a3ef40c2f | ||
|
|
0ebbeec11c | ||
|
|
0ada4ce900 | ||
|
|
572d3d637a | ||
|
|
3751737621 | ||
|
|
ec52e9281a | ||
|
|
af0031ae8b | ||
|
|
b398935081 | ||
|
|
78b7737368 | ||
|
|
57e8f5c5b9 | ||
|
|
729cde33f1 | ||
|
|
ebbf02e25b | ||
|
|
3ecdfc9b5a | ||
|
|
f9561da673 | ||
|
|
b3747d9a21 | ||
|
|
95de2bfc74 | ||
|
|
d7bb1c1d0e | ||
|
|
5f3e7a5f91 | ||
|
|
0671a4d5ae | ||
|
|
bd78f2c493 | ||
|
|
d1abba0d33 | ||
|
|
05065985e7 | ||
|
|
7ab8f431a7 | ||
|
|
050ed85d71 | ||
|
|
858e176a1c | ||
|
|
a65c0b2bff | ||
|
|
848a86a385 |
@@ -23,6 +23,8 @@ workspace-members = [
|
||||
]
|
||||
third-party = [
|
||||
{ name = "reqwest", version = "0.11.27" },
|
||||
# build of remote_server should not include scap / its x11 dependency
|
||||
{ name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" },
|
||||
]
|
||||
|
||||
[final-excludes]
|
||||
|
||||
30
.github/actionlint.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Configuration related to self-hosted runner.
|
||||
self-hosted-runner:
|
||||
# Labels of self-hosted runner in array of strings.
|
||||
labels:
|
||||
# GitHub-hosted Runners
|
||||
- github-8vcpu-ubuntu-2404
|
||||
- github-16vcpu-ubuntu-2404
|
||||
- windows-2025-16
|
||||
- windows-2025-32
|
||||
- windows-2025-64
|
||||
# Buildjet Ubuntu 20.04 - AMD x86_64
|
||||
- buildjet-2vcpu-ubuntu-2004
|
||||
- buildjet-4vcpu-ubuntu-2004
|
||||
- buildjet-8vcpu-ubuntu-2004
|
||||
- buildjet-16vcpu-ubuntu-2004
|
||||
- buildjet-32vcpu-ubuntu-2004
|
||||
# Buildjet Ubuntu 22.04 - AMD x86_64
|
||||
- buildjet-2vcpu-ubuntu-2204
|
||||
- buildjet-4vcpu-ubuntu-2204
|
||||
- buildjet-8vcpu-ubuntu-2204
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
- buildjet-32vcpu-ubuntu-2204
|
||||
# Buildjet Ubuntu 22.04 - Graviton aarch64
|
||||
- buildjet-8vcpu-ubuntu-2204-arm
|
||||
- buildjet-16vcpu-ubuntu-2204-arm
|
||||
- buildjet-32vcpu-ubuntu-2204-arm
|
||||
- buildjet-64vcpu-ubuntu-2204-arm
|
||||
# Self Hosted Runners
|
||||
- self-mini-macos
|
||||
- self-32vcpu-windows-2022
|
||||
8
.github/workflows/bump_patch_version.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
channel="$(cat crates/zed/RELEASE_CHANNEL)"
|
||||
|
||||
tag_suffix=""
|
||||
case $channel in
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
which cargo-set-version > /dev/null || cargo install cargo-edit
|
||||
output=$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')
|
||||
output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
|
||||
export GIT_COMMITTER_NAME="Zed Bot"
|
||||
export GIT_COMMITTER_EMAIL="hi@zed.dev"
|
||||
git commit -am "Bump to $output for @$GITHUB_ACTOR" --author "Zed Bot <hi@zed.dev>"
|
||||
git tag v${output}${tag_suffix}
|
||||
git push origin HEAD v${output}${tag_suffix}
|
||||
git tag "v${output}${tag_suffix}"
|
||||
git push origin HEAD "v${output}${tag_suffix}"
|
||||
|
||||
108
.github/workflows/ci.yml
vendored
@@ -34,6 +34,7 @@ jobs:
|
||||
run_license: ${{ steps.filter.outputs.run_license }}
|
||||
run_docs: ${{ steps.filter.outputs.run_docs }}
|
||||
run_nix: ${{ steps.filter.outputs.run_nix }}
|
||||
run_actionlint: ${{ steps.filter.outputs.run_actionlint }}
|
||||
runs-on:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
@@ -47,39 +48,40 @@ jobs:
|
||||
run: |
|
||||
if [ -z "$GITHUB_BASE_REF" ]; then
|
||||
echo "Not in a PR context (i.e., push to main/stable/preview)"
|
||||
COMPARE_REV=$(git rev-parse HEAD~1)
|
||||
COMPARE_REV="$(git rev-parse HEAD~1)"
|
||||
else
|
||||
echo "In a PR context comparing to pull_request.base.ref"
|
||||
git fetch origin "$GITHUB_BASE_REF" --depth=350
|
||||
COMPARE_REV=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)
|
||||
COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
|
||||
fi
|
||||
# Specify anything which should skip full CI in this regex:
|
||||
CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})"
|
||||
|
||||
# Specify anything which should potentially skip full test suite in this regex:
|
||||
# - docs/
|
||||
# - script/update_top_ranking_issues/
|
||||
# - .github/ISSUE_TEMPLATE/
|
||||
# - .github/workflows/ (except .github/workflows/ci.yml)
|
||||
SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))'
|
||||
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -vP "$SKIP_REGEX") ]]; then
|
||||
echo "run_tests=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "run_tests=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then
|
||||
echo "run_docs=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "run_docs=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then
|
||||
echo "run_license=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "run_license=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
NIX_REGEX='^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)'
|
||||
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P "$NIX_REGEX") ]]; then
|
||||
echo "run_nix=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "run_nix=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qvP "$SKIP_REGEX" && \
|
||||
echo "run_tests=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_tests=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qP '^docs/' && \
|
||||
echo "run_docs=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_docs=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qP '^\.github/(workflows/|actions/|actionlint.yml)' && \
|
||||
echo "run_actionlint=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_actionlint=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qP '^(Cargo.lock|script/.*licenses)' && \
|
||||
echo "run_license=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_license=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
|
||||
echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_nix=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
migration_checks:
|
||||
name: Check Postgres and Protobuf migrations, mergability
|
||||
@@ -89,8 +91,7 @@ jobs:
|
||||
needs.job_spec.outputs.run_tests == 'true'
|
||||
timeout-minutes: 60
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- macOS
|
||||
- self-mini-macos
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -112,11 +113,11 @@ jobs:
|
||||
run: |
|
||||
if [ -z "$GITHUB_BASE_REF" ];
|
||||
then
|
||||
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
|
||||
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV"
|
||||
else
|
||||
git checkout -B temp
|
||||
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
|
||||
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
|
||||
git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp"
|
||||
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
@@ -140,7 +141,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install cargo-hakari
|
||||
uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2
|
||||
with:
|
||||
@@ -178,7 +179,7 @@ jobs:
|
||||
- name: Prettier Check on /docs
|
||||
working-directory: ./docs
|
||||
run: |
|
||||
pnpm dlx prettier@${PRETTIER_VERSION} . --check || {
|
||||
pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || {
|
||||
echo "To fix, run from the root of the Zed repo:"
|
||||
echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .."
|
||||
false
|
||||
@@ -188,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Prettier Check on default.json
|
||||
run: |
|
||||
pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --check || {
|
||||
pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || {
|
||||
echo "To fix, run from the root of the Zed repo:"
|
||||
echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write"
|
||||
false
|
||||
@@ -234,6 +235,20 @@ jobs:
|
||||
- name: Build docs
|
||||
uses: ./.github/actions/build_docs
|
||||
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true'
|
||||
needs: [job_spec]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download actionlint
|
||||
id: get_actionlint
|
||||
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
|
||||
shell: bash
|
||||
- name: Check workflow files
|
||||
run: ${{ steps.get_actionlint.outputs.executable }} -color
|
||||
shell: bash
|
||||
|
||||
macos_tests:
|
||||
timeout-minutes: 60
|
||||
name: (macOS) Run Clippy and tests
|
||||
@@ -242,8 +257,7 @@ jobs:
|
||||
github.repository_owner == 'zed-industries' &&
|
||||
needs.job_spec.outputs.run_tests == 'true'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- macOS
|
||||
- self-mini-macos
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -312,7 +326,7 @@ jobs:
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -364,7 +378,7 @@ jobs:
|
||||
- buildjet-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -444,6 +458,7 @@ jobs:
|
||||
- job_spec
|
||||
- style
|
||||
- check_docs
|
||||
- actionlint
|
||||
- migration_checks
|
||||
# run_tests: If adding required tests, add them here and to script below.
|
||||
- workspace_hack
|
||||
@@ -465,6 +480,11 @@ jobs:
|
||||
if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then
|
||||
[[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; }
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.job_spec.outputs.run_actionlint }}" == "true" ]]; then
|
||||
[[ "${{ needs.actionlint.result }}" != 'success' ]] && { RET_CODE=1; echo "actionlint checks failed"; }
|
||||
fi
|
||||
|
||||
# Only check test jobs if they were supposed to run
|
||||
if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then
|
||||
[[ "${{ needs.workspace_hack.result }}" != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; }
|
||||
@@ -484,8 +504,7 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
name: Create a macOS bundle
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
- self-mini-macos
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
|
||||
@@ -679,8 +698,10 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: github-8vcpu-ubuntu-2404
|
||||
if: |
|
||||
false && (
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
|
||||
)
|
||||
needs: [linux_tests]
|
||||
name: Build Zed on FreeBSD
|
||||
steps:
|
||||
@@ -746,7 +767,7 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
name: Create a Windows installer
|
||||
runs-on: [self-hosted, Windows, X64]
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
|
||||
if: false && (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))
|
||||
needs: [windows_tests]
|
||||
env:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
|
||||
@@ -785,7 +806,7 @@ jobs:
|
||||
- name: Upload Artifacts to release
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
# Re-enable when we are ready to publish windows preview releases
|
||||
if: false && ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
|
||||
if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
|
||||
@@ -798,12 +819,11 @@ jobs:
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
&& endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
|
||||
needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd]
|
||||
needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64]
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
- self-mini-macos
|
||||
steps:
|
||||
- name: gh release
|
||||
run: gh release edit $GITHUB_REF_NAME --draft=false
|
||||
run: gh release edit "$GITHUB_REF_NAME" --draft=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
URL="https://zed.dev/releases/stable/latest"
|
||||
fi
|
||||
|
||||
echo "URL=$URL" >> $GITHUB_OUTPUT
|
||||
echo "URL=$URL" >> "$GITHUB_OUTPUT"
|
||||
- name: Get content
|
||||
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1
|
||||
id: get-content
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
PREVIEW_TAG="${VERSION}-pre"
|
||||
|
||||
if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then
|
||||
echo "was_promoted_from_preview=true" >> $GITHUB_OUTPUT
|
||||
echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "was_promoted_from_preview=false" >> $GITHUB_OUTPUT
|
||||
echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Send release notes email
|
||||
|
||||
9
.github/workflows/deploy_collab.yml
vendored
@@ -79,12 +79,12 @@ jobs:
|
||||
- name: Build docker image
|
||||
run: |
|
||||
docker build -f Dockerfile-collab \
|
||||
--build-arg GITHUB_SHA=$GITHUB_SHA \
|
||||
--tag registry.digitalocean.com/zed/collab:$GITHUB_SHA \
|
||||
--build-arg "GITHUB_SHA=$GITHUB_SHA" \
|
||||
--tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \
|
||||
.
|
||||
|
||||
- name: Publish docker image
|
||||
run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
|
||||
run: docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
|
||||
|
||||
- name: Prune Docker system
|
||||
run: docker system prune --filter 'until=72h' -f
|
||||
@@ -131,7 +131,8 @@ jobs:
|
||||
source script/lib/deploy-helpers.sh
|
||||
export_vars_for_environment $ZED_KUBE_NAMESPACE
|
||||
|
||||
export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
|
||||
ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)"
|
||||
export ZED_DO_CERTIFICATE_ID
|
||||
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
|
||||
|
||||
export ZED_SERVICE_NAME=collab
|
||||
|
||||
2
.github/workflows/eval.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
10
.github/workflows/nix.yml
vendored
@@ -43,8 +43,8 @@ jobs:
|
||||
- name: Set path
|
||||
if: ${{ ! matrix.system.install_nix }}
|
||||
run: |
|
||||
echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
|
||||
echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
|
||||
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
||||
echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
|
||||
if: ${{ matrix.system.install_nix }}
|
||||
@@ -56,11 +56,13 @@ jobs:
|
||||
name: zed
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
pushFilter: "${{ inputs.cachix-filter }}"
|
||||
cachixArgs: '-v'
|
||||
cachixArgs: "-v"
|
||||
|
||||
- run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config
|
||||
|
||||
- name: Limit /nix/store to 50GB on macs
|
||||
if: ${{ ! matrix.system.install_nix }}
|
||||
run: |
|
||||
[ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d || :
|
||||
if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then
|
||||
nix-collect-garbage -d || true
|
||||
fi
|
||||
|
||||
7
.github/workflows/release_nightly.yml
vendored
@@ -85,8 +85,7 @@ jobs:
|
||||
name: Create a macOS bundle
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
- self-mini-macos
|
||||
needs: tests
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
@@ -132,7 +131,7 @@ jobs:
|
||||
clean: false
|
||||
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: ./script/linux && ./script/install-mold 2.34.0
|
||||
@@ -187,7 +186,7 @@ jobs:
|
||||
|
||||
freebsd:
|
||||
timeout-minutes: 60
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
if: false && github.repository_owner == 'zed-industries'
|
||||
runs-on: github-8vcpu-ubuntu-2404
|
||||
needs: tests
|
||||
name: Build Zed on FreeBSD
|
||||
|
||||
2
.github/workflows/unit_evals.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
180
Cargo.lock
generated
@@ -3,10 +3,9 @@
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acp"
|
||||
name = "acp_thread"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent_servers",
|
||||
"agentic-coding-protocol",
|
||||
"anyhow",
|
||||
"assistant_tool",
|
||||
@@ -21,6 +20,7 @@ dependencies = [
|
||||
"language",
|
||||
"markdown",
|
||||
"project",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
@@ -139,16 +139,33 @@ dependencies = [
|
||||
name = "agent_servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp_thread",
|
||||
"agentic-coding-protocol",
|
||||
"anyhow",
|
||||
"collections",
|
||||
"context_server",
|
||||
"env_logger 0.11.8",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.29.0",
|
||||
"paths",
|
||||
"project",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"strum 0.27.1",
|
||||
"tempfile",
|
||||
"ui",
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
"which 6.0.3",
|
||||
"workspace-hack",
|
||||
]
|
||||
@@ -176,11 +193,12 @@ dependencies = [
|
||||
name = "agent_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp",
|
||||
"acp_thread",
|
||||
"agent",
|
||||
"agent_servers",
|
||||
"agent_settings",
|
||||
"agentic-coding-protocol",
|
||||
"ai_onboarding",
|
||||
"anyhow",
|
||||
"assistant_context",
|
||||
"assistant_slash_command",
|
||||
@@ -264,16 +282,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agentic-coding-protocol"
|
||||
version = "0.0.7"
|
||||
version = "0.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75f520bcc049ebe40c8c99427aa61b48ad78a01bcc96a13b350b903dcfb9438"
|
||||
checksum = "a3e6ae951b36fa2f8d9dd6e1af6da2fcaba13d7c866cf6a9e65deda9dc6c5fe4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"derive_more 2.0.1",
|
||||
"futures 0.3.31",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"schemars",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -313,6 +333,22 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ai_onboarding"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"client",
|
||||
"component",
|
||||
"gpui",
|
||||
"language_model",
|
||||
"proto",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"ui",
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alacritty_terminal"
|
||||
version = "0.25.1-dev"
|
||||
@@ -676,7 +712,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"extension",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
@@ -739,10 +775,11 @@ dependencies = [
|
||||
"clock",
|
||||
"collections",
|
||||
"ctor",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"icons",
|
||||
"indoc",
|
||||
"language",
|
||||
"language_model",
|
||||
"log",
|
||||
@@ -775,7 +812,8 @@ dependencies = [
|
||||
"clock",
|
||||
"collections",
|
||||
"component",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"diffy",
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"fs",
|
||||
@@ -1232,7 +1270,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"gpui",
|
||||
"parking_lot",
|
||||
"rodio",
|
||||
@@ -2144,7 +2182,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "blade-graphics"
|
||||
version = "0.6.0"
|
||||
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
|
||||
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"ash-window",
|
||||
@@ -2177,7 +2215,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "blade-macros"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
|
||||
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2187,7 +2225,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "blade-util"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
|
||||
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
|
||||
dependencies = [
|
||||
"blade-graphics",
|
||||
"bytemuck",
|
||||
@@ -2925,7 +2963,7 @@ dependencies = [
|
||||
"cocoa 0.26.0",
|
||||
"collections",
|
||||
"credentials_provider",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"feature_flags",
|
||||
"fs",
|
||||
"futures 0.3.31",
|
||||
@@ -3113,7 +3151,7 @@ dependencies = [
|
||||
"dap_adapters",
|
||||
"dashmap 6.1.0",
|
||||
"debugger_ui",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"editor",
|
||||
"envy",
|
||||
"extension",
|
||||
@@ -3166,6 +3204,7 @@ dependencies = [
|
||||
"session",
|
||||
"settings",
|
||||
"sha2",
|
||||
"smol",
|
||||
"sqlx",
|
||||
"strum 0.27.1",
|
||||
"subtle",
|
||||
@@ -3318,7 +3357,7 @@ name = "command_palette_hooks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"gpui",
|
||||
"workspace-hack",
|
||||
]
|
||||
@@ -3406,12 +3445,14 @@ dependencies = [
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"log",
|
||||
"net",
|
||||
"parking_lot",
|
||||
"postage",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"url",
|
||||
"util",
|
||||
"workspace-hack",
|
||||
@@ -4227,6 +4268,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"smol",
|
||||
"task",
|
||||
"util",
|
||||
"workspace-hack",
|
||||
@@ -4407,6 +4449,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"rpc",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_json_lenient",
|
||||
@@ -4525,6 +4568,27 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_refineable"
|
||||
version = "0.1.0"
|
||||
@@ -6223,7 +6287,7 @@ dependencies = [
|
||||
"askpass",
|
||||
"async-trait",
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"futures 0.3.31",
|
||||
"git2",
|
||||
"gpui",
|
||||
@@ -7240,7 +7304,7 @@ dependencies = [
|
||||
"core-video",
|
||||
"cosmic-text",
|
||||
"ctor",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"embed-resource",
|
||||
"env_logger 0.11.8",
|
||||
"etagere",
|
||||
@@ -7335,9 +7399,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grid"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d196ffc1627db18a531359249b2bf8416178d84b729f3cebeb278f285fb9b58c"
|
||||
checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
@@ -7786,7 +7850,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes 1.10.1",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"futures 0.3.31",
|
||||
"http 1.3.1",
|
||||
"log",
|
||||
@@ -8224,7 +8288,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"cargo_metadata",
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"extension",
|
||||
"fs",
|
||||
"futures 0.3.31",
|
||||
@@ -9022,6 +9086,7 @@ dependencies = [
|
||||
name = "language_models"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ai_onboarding",
|
||||
"anthropic",
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -9070,6 +9135,7 @@ dependencies = [
|
||||
"util",
|
||||
"vercel",
|
||||
"workspace-hack",
|
||||
"x_ai",
|
||||
"zed_llm_client",
|
||||
]
|
||||
|
||||
@@ -9102,7 +9168,6 @@ dependencies = [
|
||||
"collections",
|
||||
"copilot",
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"itertools 0.14.0",
|
||||
@@ -9662,7 +9727,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.95.1"
|
||||
source = "git+https://github.com/zed-industries/lsp-types?rev=6add7052b598ea1f40f7e8913622c3958b009b60#6add7052b598ea1f40f7e8913622c3958b009b60"
|
||||
source = "git+https://github.com/zed-industries/lsp-types?rev=39f629bdd03d59abd786ed9fc27e8bca02c0c0ec#39f629bdd03d59abd786ed9fc27e8bca02c0c0ec"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"serde",
|
||||
@@ -10261,6 +10326,17 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.31",
|
||||
"net",
|
||||
"smol",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
@@ -10909,6 +10985,23 @@ dependencies = [
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onboarding"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"command_palette_hooks",
|
||||
"db",
|
||||
"feature_flags",
|
||||
"fs",
|
||||
"gpui",
|
||||
"settings",
|
||||
"theme",
|
||||
"ui",
|
||||
"workspace",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -14111,7 +14204,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "scap"
|
||||
version = "0.0.8"
|
||||
source = "git+https://github.com/zed-industries/scap?rev=270538dc780f5240723233ff901e1054641ed318#270538dc780f5240723233ff901e1054641ed318"
|
||||
source = "git+https://github.com/zed-industries/scap?rev=808aa5c45b41e8f44729d02e38fd00a2fe2722e7#808aa5c45b41e8f44729d02e38fd00a2fe2722e7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cocoa 0.25.0",
|
||||
@@ -14163,6 +14256,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -14684,6 +14778,7 @@ dependencies = [
|
||||
"language",
|
||||
"log",
|
||||
"menu",
|
||||
"notifications",
|
||||
"paths",
|
||||
"project",
|
||||
"schemars",
|
||||
@@ -14691,10 +14786,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"theme",
|
||||
"tree-sitter-json",
|
||||
"tree-sitter-rust",
|
||||
"ui",
|
||||
"ui_input",
|
||||
"util",
|
||||
"workspace",
|
||||
"workspace-hack",
|
||||
@@ -15857,9 +15955,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.4.4"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ec17858c2d465b2f734b798b920818a974faf0babb15d7fef81818a4b2d16f1"
|
||||
checksum = "e8b61630cba2afd2c851821add2e1bb1b7851a2436e839ab73b56558b009035e"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"grid",
|
||||
@@ -16122,7 +16220,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collections",
|
||||
"derive_more",
|
||||
"derive_more 0.99.19",
|
||||
"fs",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
@@ -16405,6 +16503,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
name = "title_bar"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_update",
|
||||
"call",
|
||||
"chrono",
|
||||
@@ -16421,6 +16520,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"settings",
|
||||
"settings_ui",
|
||||
"smallvec",
|
||||
"story",
|
||||
"telemetry",
|
||||
@@ -17588,6 +17688,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
name = "vim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent_ui",
|
||||
"anyhow",
|
||||
"assets",
|
||||
"async-compat",
|
||||
@@ -18649,8 +18750,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "windows-capture"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59d10b4be8b907c7055bc7270dd68d2b920978ffacc1599dcb563a79f0e68d16"
|
||||
source = "git+https://github.com/zed-industries/windows-capture.git?rev=f0d6c1b6691db75461b732f6d5ff56eed002eeb9#f0d6c1b6691db75461b732f6d5ff56eed002eeb9"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -19660,7 +19760,6 @@ dependencies = [
|
||||
"rustix 1.0.7",
|
||||
"rustls 0.23.26",
|
||||
"rustls-webpki 0.103.1",
|
||||
"scap",
|
||||
"schemars",
|
||||
"scopeguard",
|
||||
"sea-orm",
|
||||
@@ -19708,9 +19807,7 @@ dependencies = [
|
||||
"wasmtime-cranelift",
|
||||
"wasmtime-environ",
|
||||
"winapi",
|
||||
"windows 0.61.1",
|
||||
"windows-core 0.61.0",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -19816,6 +19913,17 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
|
||||
|
||||
[[package]]
|
||||
name = "x_ai"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"schemars",
|
||||
"serde",
|
||||
"strum 0.27.1",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "0.2.3"
|
||||
@@ -20057,7 +20165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.196.0"
|
||||
version = "0.197.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"agent",
|
||||
@@ -20131,9 +20239,11 @@ dependencies = [
|
||||
"menu",
|
||||
"migrator",
|
||||
"mimalloc",
|
||||
"nc",
|
||||
"nix 0.29.0",
|
||||
"node_runtime",
|
||||
"notifications",
|
||||
"onboarding",
|
||||
"outline",
|
||||
"outline_panel",
|
||||
"parking_lot",
|
||||
@@ -20440,6 +20550,7 @@ dependencies = [
|
||||
name = "zeta"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ai_onboarding",
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"call",
|
||||
@@ -20447,6 +20558,7 @@ dependencies = [
|
||||
"clock",
|
||||
"collections",
|
||||
"command_palette_hooks",
|
||||
"copilot",
|
||||
"ctor",
|
||||
"db",
|
||||
"editor",
|
||||
@@ -20461,8 +20573,6 @@ dependencies = [
|
||||
"language_model",
|
||||
"log",
|
||||
"menu",
|
||||
"migrator",
|
||||
"paths",
|
||||
"postage",
|
||||
"project",
|
||||
"proto",
|
||||
|
||||
31
Cargo.toml
@@ -2,10 +2,11 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/activity_indicator",
|
||||
"crates/acp",
|
||||
"crates/acp_thread",
|
||||
"crates/agent_ui",
|
||||
"crates/agent",
|
||||
"crates/agent_settings",
|
||||
"crates/ai_onboarding",
|
||||
"crates/agent_servers",
|
||||
"crates/anthropic",
|
||||
"crates/askpass",
|
||||
@@ -102,10 +103,12 @@ members = [
|
||||
"crates/migrator",
|
||||
"crates/mistral",
|
||||
"crates/multi_buffer",
|
||||
"crates/nc",
|
||||
"crates/net",
|
||||
"crates/node_runtime",
|
||||
"crates/notifications",
|
||||
"crates/ollama",
|
||||
"crates/onboarding",
|
||||
"crates/open_ai",
|
||||
"crates/open_router",
|
||||
"crates/outline",
|
||||
@@ -179,6 +182,7 @@ members = [
|
||||
"crates/welcome",
|
||||
"crates/workspace",
|
||||
"crates/worktree",
|
||||
"crates/x_ai",
|
||||
"crates/zed",
|
||||
"crates/zed_actions",
|
||||
"crates/zeta",
|
||||
@@ -218,13 +222,14 @@ edition = "2024"
|
||||
# Workspace member crates
|
||||
#
|
||||
|
||||
acp = { path = "crates/acp" }
|
||||
acp_thread = { path = "crates/acp_thread" }
|
||||
agent = { path = "crates/agent" }
|
||||
activity_indicator = { path = "crates/activity_indicator" }
|
||||
agent_ui = { path = "crates/agent_ui" }
|
||||
agent_settings = { path = "crates/agent_settings" }
|
||||
agent_servers = { path = "crates/agent_servers" }
|
||||
ai = { path = "crates/ai" }
|
||||
ai_onboarding = { path = "crates/ai_onboarding" }
|
||||
anthropic = { path = "crates/anthropic" }
|
||||
askpass = { path = "crates/askpass" }
|
||||
assets = { path = "crates/assets" }
|
||||
@@ -316,10 +321,12 @@ menu = { path = "crates/menu" }
|
||||
migrator = { path = "crates/migrator" }
|
||||
mistral = { path = "crates/mistral" }
|
||||
multi_buffer = { path = "crates/multi_buffer" }
|
||||
nc = { path = "crates/nc" }
|
||||
net = { path = "crates/net" }
|
||||
node_runtime = { path = "crates/node_runtime" }
|
||||
notifications = { path = "crates/notifications" }
|
||||
ollama = { path = "crates/ollama" }
|
||||
onboarding = { path = "crates/onboarding" }
|
||||
open_ai = { path = "crates/open_ai" }
|
||||
open_router = { path = "crates/open_router", features = ["schemars"] }
|
||||
outline = { path = "crates/outline" }
|
||||
@@ -394,6 +401,7 @@ web_search_providers = { path = "crates/web_search_providers" }
|
||||
welcome = { path = "crates/welcome" }
|
||||
workspace = { path = "crates/workspace" }
|
||||
worktree = { path = "crates/worktree" }
|
||||
x_ai = { path = "crates/x_ai" }
|
||||
zed = { path = "crates/zed" }
|
||||
zed_actions = { path = "crates/zed_actions" }
|
||||
zeta = { path = "crates/zeta" }
|
||||
@@ -404,7 +412,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agentic-coding-protocol = "0.0.7"
|
||||
agentic-coding-protocol = "0.0.10"
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -432,9 +440,9 @@ aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
|
||||
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
|
||||
base64 = "0.22"
|
||||
bitflags = "2.6.0"
|
||||
blade-graphics = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
|
||||
blade-macros = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
|
||||
blade-util = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
|
||||
blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
|
||||
blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
|
||||
blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
|
||||
blake3 = "1.5.3"
|
||||
bytes = "1.0"
|
||||
cargo_metadata = "0.19"
|
||||
@@ -487,18 +495,18 @@ json_dotpath = "1.1"
|
||||
jsonschema = "0.30.0"
|
||||
jsonwebtoken = "9.3"
|
||||
jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
libc = "0.2"
|
||||
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
|
||||
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "6add7052b598ea1f40f7e8913622c3958b009b60" }
|
||||
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" }
|
||||
markup5ever_rcdom = "0.3.0"
|
||||
metal = "0.29"
|
||||
moka = { version = "0.12.10", features = ["sync"] }
|
||||
naga = { version = "25.0", features = ["wgsl-in"] }
|
||||
nanoid = "0.4"
|
||||
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
|
||||
nix = "0.29"
|
||||
num-format = "0.4.4"
|
||||
objc = "0.2"
|
||||
@@ -539,7 +547,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77
|
||||
"stream",
|
||||
] }
|
||||
rsa = "0.9.6"
|
||||
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
|
||||
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
|
||||
"async-dispatcher-runtime",
|
||||
] }
|
||||
rust-embed = { version = "8.4", features = ["include-exclude"] }
|
||||
@@ -547,7 +555,7 @@ rustc-demangle = "0.1.23"
|
||||
rustc-hash = "2.1.0"
|
||||
rustls = { version = "0.23.26" }
|
||||
rustls-platform-verifier = "0.5.0"
|
||||
scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false }
|
||||
scap = { git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7", default-features = false }
|
||||
schemars = { version = "1.0", features = ["indexmap2"] }
|
||||
semver = "1.0"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
@@ -701,6 +709,7 @@ features = [
|
||||
[patch.crates-io]
|
||||
notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
|
||||
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
|
||||
windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" }
|
||||
|
||||
# Makes the workspace hack crate refer to the local one, but only when you're building locally
|
||||
workspace-hack = { path = "tooling/workspace-hack" }
|
||||
|
||||
3
assets/icons/ai_claude.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.35443 9.97775L6.71495 8.65418L6.75443 8.53883L6.71495 8.47508H6.59948L6.20456 8.45081L4.8557 8.41436L3.68608 8.36579L2.55291 8.30507L2.26734 8.24438L2 7.89224L2.02734 7.71617L2.26734 7.55528L2.61063 7.58564L3.37013 7.63724L4.50937 7.71617L5.3357 7.76474L6.56 7.89224H6.75443L6.78176 7.81331L6.71495 7.76474L6.66329 7.71617L5.48456 6.91778L4.20861 6.07388L3.54025 5.58815L3.17873 5.34227L2.99646 5.11157L2.91747 4.60764L3.24557 4.24639L3.68608 4.27675L3.79848 4.30711L4.24506 4.65014L5.19899 5.38781L6.44456 6.30458L6.62684 6.45635L6.69974 6.40475L6.70886 6.36833L6.62684 6.23171L5.94938 5.00834L5.22632 3.76372L4.9043 3.24766L4.81924 2.93802C4.78886 2.81053 4.7676 2.70427 4.7676 2.57374L5.14127 2.06678L5.34785 2L5.84609 2.06678L6.0557 2.24893L6.36557 2.95624L6.86684 4.07033L7.64456 5.58512L7.87241 6.0344L7.99391 6.45029L8.03948 6.57779H8.11847V6.50492L8.18228 5.6519L8.30075 4.6046L8.41619 3.25677L8.4557 2.87731L8.64404 2.42196L9.01772 2.17607L9.30938 2.31571L9.54938 2.65874L9.51596 2.88034L9.37316 3.80622L9.09368 5.25728L8.9114 6.22868H9.01772L9.13925 6.10727L9.6314 5.45459L10.4577 4.42246L10.8223 4.01265L11.2476 3.56033L11.521 3.3448H12.0375L12.4172 3.90944L12.2471 4.49228L11.7154 5.1662L11.275 5.73692L10.643 6.58691L10.2481 7.26689L10.2846 7.32152L10.3787 7.31243L11.8066 7.00886L12.5782 6.86921L13.4987 6.71135L13.915 6.90563L13.9605 7.10297L13.7965 7.50671L12.8122 7.74956L11.6577 7.98026L9.93824 8.38706L9.91697 8.40224L9.94127 8.43257L10.716 8.50544L11.0471 8.52365H11.8582L13.3681 8.63597L13.763 8.89703L14 9.21578L13.9605 9.45863L13.3529 9.76829L12.5327 9.57398L10.6187 9.11864L9.96254 8.95472H9.8714V9.00935L10.4182 9.54365L11.4208 10.4483L12.6754 11.614L12.7393 11.9023L12.5782 12.13L12.4081 12.1057L11.3053 11.277L10.88 10.9036L9.91697 10.0931H9.85316V10.1781L10.075 10.5029L11.2476 12.2636L11.3083 12.804L11.2233 12.98L10.9195 13.0863L10.5853 13.0255L9.89873 12.0632L9.19088 10.9795L8.61974 10.0081L8.54987 10.0476L8.21267 13.6752L8.05469 13.8604L7.69013 14L7.38632 13.7693L7.22531 13.3959L7.38632 12.6582L7.58075 11.6959L7.73873 10.9309L7.88153 9.98078L7.96658 9.66506L7.96052 9.64382L7.89062 9.65291L7.17368 10.6365L6.08303 12.1088L5.22026 13.0316L5.01368 13.1136L4.65519 12.9284L4.68861 12.5975L4.88911 12.303L6.08303 10.7852L6.80303 9.84416L7.26785 9.30077L7.26482 9.22187H7.23746L4.06582 11.2801L3.50076 11.3529L3.25772 11.1252L3.2881 10.7518L3.40354 10.6304L4.35747 9.97469L4.35443 9.97775Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.44 12.27C7.81333 13.1217 8 14.0317 8 15C8 14.0317 8.18083 13.1217 8.5425 12.27C8.91583 11.4183 9.4175 10.6775 10.0475 10.0475C10.6775 9.4175 11.4183 8.92167 12.27 8.56C13.1217 8.18667 14.0317 8 15 8C14.0317 8 13.1217 7.81917 12.27 7.4575C11.4411 7.1001 10.6871 6.5895 10.0475 5.9525C9.4105 5.31293 8.8999 4.55891 8.5425 3.73C8.18083 2.87833 8 1.96833 8 1C8 1.96833 7.81333 2.87833 7.44 3.73C7.07833 4.58167 6.5825 5.3225 5.9525 5.9525C5.31293 6.5895 4.55891 7.1001 3.73 7.4575C2.87833 7.81917 1.96833 8 1 8C1.96833 8 2.87833 8.18667 3.73 8.56C4.58167 8.92167 5.3225 9.4175 5.9525 10.0475C6.5825 10.6775 7.07833 11.4183 7.44 12.27Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 762 B |
@@ -1,5 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
|
||||
<g clip-path="url(#clip0_205_3)">
|
||||
<g>
|
||||
<path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
|
||||
<path d="m15.969 3.797 -4.805 2.774V1.023z" />
|
||||
<path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />
|
||||
|
||||
|
Before Width: | Height: | Size: 575 B After Width: | Height: | Size: 545 B |
3
assets/icons/ai_x_ai.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m12.414 5.47.27 9.641h2.157l.27-13.15zM15.11.889h-3.293L6.651 7.613l1.647 2.142zM.889 15.11H4.18l1.647-2.142-1.647-2.143zm0-9.641 7.409 9.641h3.292L4.181 5.47z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
1
assets/icons/equal.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-equal-icon lucide-equal"><line x1="5" x2="19" y1="9" y2="9"/><line x1="5" x2="19" y1="15" y2="15"/></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
7
assets/icons/new_from_summary.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 4H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.66667 8H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.66667 12H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.00016 12C8.41993 12.5597 9.00515 12.9731 9.67294 13.1817C10.3407 13.3903 11.0572 13.3835 11.7209 13.1623C12.3846 12.941 12.9619 12.5166 13.371 11.949C13.78 11.3815 14.0002 10.6996 14.0002 10C14.0002 9.20435 13.6841 8.44129 13.1215 7.87868C12.5589 7.31607 11.7958 7 11.0002 7C10.1135 7 9.30683 7.36 8.72683 7.94L7.3335 9.33333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.3335 6.66669V9.33335H10.0002" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 964 B |
7
assets/icons/new_text_thread.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.33333 8H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.6667 5H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 11H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 7V11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 9H10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 620 B |
3
assets/icons/new_thread.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 609 B |
3
assets/icons/play_filled.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 4L10 7L5 10V4Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
4
assets/icons/todo_complete.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 8L7.33333 9L10 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
10
assets/icons/todo_pending.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
11
assets/icons/todo_progress.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.00016 8.66665C8.36835 8.66665 8.66683 8.36817 8.66683 7.99998C8.66683 7.63179 8.36835 7.33331 8.00016 7.33331C7.63197 7.33331 7.3335 7.63179 7.3335 7.99998C7.3335 8.36817 7.63197 8.66665 8.00016 8.66665Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -269,15 +269,15 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && acp_thread",
|
||||
"context": "AgentPanel && external_agent_thread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewAcpThread",
|
||||
"ctrl-n": "agent::NewExternalAgentThread",
|
||||
"ctrl-alt-t": "agent::NewThread"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "MessageEditor > Editor",
|
||||
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
|
||||
"bindings": {
|
||||
"enter": "agent::Chat",
|
||||
"ctrl-enter": "agent::ChatWithFollow",
|
||||
@@ -287,6 +287,17 @@
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
|
||||
"bindings": {
|
||||
"ctrl-enter": "agent::Chat",
|
||||
"enter": "editor::Newline",
|
||||
"ctrl-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "EditMessageEditor > Editor",
|
||||
"bindings": {
|
||||
@@ -419,7 +430,7 @@
|
||||
"ctrl-shift-pagedown": "pane::SwapItemRight",
|
||||
"ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }],
|
||||
"ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }],
|
||||
"alt-ctrl-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
|
||||
"alt-ctrl-t": ["pane::CloseOtherItems", { "close_pinned": false }],
|
||||
"alt-ctrl-shift-w": "workspace::CloseInactiveTabsAndPanes",
|
||||
"ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
|
||||
"ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
|
||||
@@ -472,11 +483,10 @@
|
||||
"ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip
|
||||
"ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
|
||||
"ctrl-k ctrl-i": "editor::Hover",
|
||||
"ctrl-k ctrl-b": "editor::BlameHover",
|
||||
"ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
|
||||
"ctrl-u": "editor::UndoSelection",
|
||||
"ctrl-shift-u": "editor::RedoSelection",
|
||||
"f8": "editor::GoToDiagnostic",
|
||||
"shift-f8": "editor::GoToPreviousDiagnostic",
|
||||
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
|
||||
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
|
||||
"f2": "editor::Rename",
|
||||
"f12": "editor::GoToDefinition",
|
||||
"alt-f12": "editor::GoToDefinitionSplit",
|
||||
@@ -586,7 +596,7 @@
|
||||
"ctrl-shift-f": "pane::DeploySearch",
|
||||
"ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }],
|
||||
"ctrl-shift-t": "pane::ReopenClosedItem",
|
||||
"ctrl-k ctrl-s": "zed::OpenKeymap",
|
||||
"ctrl-k ctrl-s": "zed::OpenKeymapEditor",
|
||||
"ctrl-k ctrl-t": "theme_selector::Toggle",
|
||||
"ctrl-t": "project_symbols::Toggle",
|
||||
"ctrl-p": "file_finder::Toggle",
|
||||
@@ -652,6 +662,8 @@
|
||||
{
|
||||
"context": "Editor",
|
||||
"bindings": {
|
||||
"ctrl-u": "editor::UndoSelection",
|
||||
"ctrl-shift-u": "editor::RedoSelection",
|
||||
"ctrl-shift-j": "editor::JoinLines",
|
||||
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
|
||||
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
|
||||
@@ -917,7 +929,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "GitPanel > Editor",
|
||||
"context": "CommitEditor > Editor",
|
||||
"bindings": {
|
||||
"escape": "git_panel::FocusChanges",
|
||||
"tab": "git_panel::FocusChanges",
|
||||
@@ -1118,7 +1130,34 @@
|
||||
"ctrl-f": "search::FocusSearch",
|
||||
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeystrokeInput",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "keystroke_input::StartRecording",
|
||||
"escape escape escape": "keystroke_input::StopRecording",
|
||||
"delete": "keystroke_input::ClearKeystrokes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeybindEditorModal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-enter": "menu::Confirm",
|
||||
"escape": "menu::Cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeybindEditorModal > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"up": "menu::SelectPrevious",
|
||||
"down": "menu::SelectNext"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -310,15 +310,15 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && acp_thread",
|
||||
"context": "AgentPanel && external_agent_thread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-n": "agent::NewAcpThread",
|
||||
"cmd-n": "agent::NewExternalAgentThread",
|
||||
"cmd-alt-t": "agent::NewThread"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "MessageEditor > Editor",
|
||||
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "agent::Chat",
|
||||
@@ -329,6 +329,18 @@
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-enter": "agent::Chat",
|
||||
"enter": "editor::Newline",
|
||||
"cmd-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "EditMessageEditor > Editor",
|
||||
"use_key_equivalents": true,
|
||||
@@ -477,7 +489,7 @@
|
||||
"ctrl-shift-pageup": "pane::SwapItemLeft",
|
||||
"ctrl-shift-pagedown": "pane::SwapItemRight",
|
||||
"cmd-w": ["pane::CloseActiveItem", { "close_pinned": false }],
|
||||
"alt-cmd-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
|
||||
"alt-cmd-t": ["pane::CloseOtherItems", { "close_pinned": false }],
|
||||
"ctrl-alt-cmd-w": "workspace::CloseInactiveTabsAndPanes",
|
||||
"cmd-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
|
||||
"cmd-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
|
||||
@@ -525,11 +537,10 @@
|
||||
"ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch
|
||||
"cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
|
||||
"cmd-k cmd-i": "editor::Hover",
|
||||
"cmd-k cmd-b": "editor::BlameHover",
|
||||
"cmd-/": ["editor::ToggleComments", { "advance_downwards": false }],
|
||||
"cmd-u": "editor::UndoSelection",
|
||||
"cmd-shift-u": "editor::RedoSelection",
|
||||
"f8": "editor::GoToDiagnostic",
|
||||
"shift-f8": "editor::GoToPreviousDiagnostic",
|
||||
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
|
||||
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
|
||||
"f2": "editor::Rename",
|
||||
"f12": "editor::GoToDefinition",
|
||||
"alt-f12": "editor::GoToDefinitionSplit",
|
||||
@@ -652,7 +663,7 @@
|
||||
"cmd-shift-f": "pane::DeploySearch",
|
||||
"cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }],
|
||||
"cmd-shift-t": "pane::ReopenClosedItem",
|
||||
"cmd-k cmd-s": "zed::OpenKeymap",
|
||||
"cmd-k cmd-s": "zed::OpenKeymapEditor",
|
||||
"cmd-k cmd-t": "theme_selector::Toggle",
|
||||
"cmd-t": "project_symbols::Toggle",
|
||||
"cmd-p": "file_finder::Toggle",
|
||||
@@ -714,6 +725,8 @@
|
||||
"context": "Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-u": "editor::UndoSelection",
|
||||
"cmd-shift-u": "editor::RedoSelection",
|
||||
"ctrl-j": "editor::JoinLines",
|
||||
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
|
||||
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
|
||||
@@ -963,7 +976,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "GitPanel > Editor",
|
||||
"context": "CommitEditor > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "editor::Newline",
|
||||
@@ -1105,7 +1118,9 @@
|
||||
"ctrl-enter": "assistant::InlineAssist",
|
||||
"ctrl-_": null, // emacs undo
|
||||
// Some nice conveniences
|
||||
"cmd-backspace": ["terminal::SendText", "\u0015"],
|
||||
"cmd-backspace": ["terminal::SendText", "\u0015"], // ctrl-u: clear line
|
||||
"alt-delete": ["terminal::SendText", "\u001bd"], // alt-d: delete word forward
|
||||
"cmd-delete": ["terminal::SendText", "\u000b"], // ctrl-k: delete to end of line
|
||||
"cmd-right": ["terminal::SendText", "\u0005"],
|
||||
"cmd-left": ["terminal::SendText", "\u0001"],
|
||||
// Terminal.app compatibility
|
||||
@@ -1214,8 +1229,36 @@
|
||||
"context": "KeymapEditor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-f": "search::FocusSearch",
|
||||
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeystrokeInput",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "keystroke_input::StartRecording",
|
||||
"escape escape escape": "keystroke_input::StopRecording",
|
||||
"delete": "keystroke_input::ClearKeystrokes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeybindEditorModal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-enter": "menu::Confirm",
|
||||
"escape": "menu::Cancel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "KeybindEditorModal > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"up": "menu::SelectPrevious",
|
||||
"down": "menu::SelectNext"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"ctrl-x o": "workspace::ActivateNextPane", // other-window
|
||||
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
|
||||
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
|
||||
"ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
|
||||
"ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
|
||||
"ctrl-x 2": "pane::SplitDown", // split-window-below
|
||||
"ctrl-x 3": "pane::SplitRight", // split-window-right
|
||||
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file
|
||||
|
||||
@@ -66,22 +66,51 @@
|
||||
"context": "Editor && mode == full",
|
||||
"bindings": {
|
||||
"ctrl-f12": "outline::Toggle",
|
||||
"alt-7": "outline::Toggle",
|
||||
"ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }],
|
||||
"ctrl-shift-n": "file_finder::Toggle",
|
||||
"ctrl-g": "go_to_line::Toggle",
|
||||
"alt-enter": "editor::ToggleCodeActions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar",
|
||||
"bindings": {
|
||||
"shift-enter": "search::SelectPreviousMatch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar || ProjectSearchBar",
|
||||
"bindings": {
|
||||
"alt-c": "search::ToggleCaseSensitive",
|
||||
"alt-e": "search::ToggleSelection",
|
||||
"alt-x": "search::ToggleRegex",
|
||||
"alt-w": "search::ToggleWholeWord"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"ctrl-shift-f12": "workspace::CloseAllDocks",
|
||||
"ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
|
||||
"alt-shift-f10": "task::Spawn",
|
||||
"ctrl-e": "file_finder::Toggle",
|
||||
"ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
|
||||
"ctrl-shift-n": "file_finder::Toggle",
|
||||
"ctrl-shift-a": "command_palette::Toggle",
|
||||
"shift shift": "command_palette::Toggle",
|
||||
"ctrl-alt-shift-n": "project_symbols::Toggle",
|
||||
"alt-0": "git_panel::ToggleFocus",
|
||||
"alt-1": "workspace::ToggleLeftDock",
|
||||
"ctrl-e": "tab_switcher::Toggle",
|
||||
"alt-6": "diagnostics::Deploy"
|
||||
"alt-5": "debug_panel::ToggleFocus",
|
||||
"alt-6": "diagnostics::Deploy",
|
||||
"alt-7": "outline_panel::ToggleFocus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace || Editor",
|
||||
"bindings": {
|
||||
"alt-f12": "terminal_panel::ToggleFocus",
|
||||
"ctrl-shift-k": "git::Push"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,10 +124,33 @@
|
||||
"context": "ProjectPanel",
|
||||
"bindings": {
|
||||
"enter": "project_panel::Open",
|
||||
"ctrl-shift-f": "project_panel::NewSearchInDirectory",
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"delete": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"shift-f6": "project_panel::Rename"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Terminal",
|
||||
"bindings": {
|
||||
"ctrl-shift-t": "workspace::NewTerminal",
|
||||
"alt-f12": "workspace::CloseActiveDock",
|
||||
"alt-left": "pane::ActivatePreviousItem",
|
||||
"alt-right": "pane::ActivateNextItem",
|
||||
"ctrl-up": "terminal::ScrollLineUp",
|
||||
"ctrl-down": "terminal::ScrollLineDown",
|
||||
"shift-pageup": "terminal::ScrollPageUp",
|
||||
"shift-pagedown": "terminal::ScrollPageDown"
|
||||
}
|
||||
},
|
||||
{ "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } },
|
||||
{ "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } },
|
||||
{ "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } },
|
||||
{ "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } },
|
||||
{ "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } },
|
||||
{
|
||||
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
|
||||
"bindings": { "escape": "editor::ToggleFocus" }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"ctrl-x o": "workspace::ActivateNextPane", // other-window
|
||||
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
|
||||
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
|
||||
"ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
|
||||
"ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
|
||||
"ctrl-x 2": "pane::SplitDown", // split-window-below
|
||||
"ctrl-x 3": "pane::SplitRight", // split-window-right
|
||||
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"bindings": {
|
||||
"cmd-{": "pane::ActivatePreviousItem",
|
||||
"cmd-}": "pane::ActivateNextItem",
|
||||
"cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset
|
||||
"ctrl-f2": "debugger::Stop",
|
||||
"f6": "debugger::Pause",
|
||||
"f7": "debugger::StepInto",
|
||||
@@ -63,28 +64,55 @@
|
||||
"context": "Editor && mode == full",
|
||||
"bindings": {
|
||||
"cmd-f12": "outline::Toggle",
|
||||
"cmd-7": "outline::Toggle",
|
||||
"cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }],
|
||||
"cmd-shift-o": "file_finder::Toggle",
|
||||
"cmd-l": "go_to_line::Toggle",
|
||||
"alt-enter": "editor::ToggleCodeActions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar > Editor",
|
||||
"context": "BufferSearchBar",
|
||||
"bindings": {
|
||||
"shift-enter": "search::SelectPreviousMatch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar || ProjectSearchBar",
|
||||
"bindings": {
|
||||
"alt-c": "search::ToggleCaseSensitive",
|
||||
"alt-e": "search::ToggleSelection",
|
||||
"alt-x": "search::ToggleRegex",
|
||||
"alt-w": "search::ToggleWholeWord",
|
||||
"ctrl-alt-c": "search::ToggleCaseSensitive",
|
||||
"ctrl-alt-e": "search::ToggleSelection",
|
||||
"ctrl-alt-w": "search::ToggleWholeWord",
|
||||
"ctrl-alt-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"cmd-shift-f12": "workspace::CloseAllDocks",
|
||||
"cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
|
||||
"ctrl-alt-r": "task::Spawn",
|
||||
"cmd-e": "file_finder::Toggle",
|
||||
"cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
|
||||
"cmd-shift-o": "file_finder::Toggle",
|
||||
"cmd-shift-a": "command_palette::Toggle",
|
||||
"shift shift": "command_palette::Toggle",
|
||||
"cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol
|
||||
"cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class
|
||||
"cmd-1": "workspace::ToggleLeftDock",
|
||||
"cmd-6": "diagnostics::Deploy"
|
||||
"cmd-1": "project_panel::ToggleFocus",
|
||||
"cmd-5": "debug_panel::ToggleFocus",
|
||||
"cmd-6": "diagnostics::Deploy",
|
||||
"cmd-7": "outline_panel::ToggleFocus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace || Editor",
|
||||
"bindings": {
|
||||
"alt-f12": "terminal_panel::ToggleFocus",
|
||||
"cmd-shift-k": "git::Push"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -98,11 +126,31 @@
|
||||
"context": "ProjectPanel",
|
||||
"bindings": {
|
||||
"enter": "project_panel::Open",
|
||||
"cmd-shift-f": "project_panel::NewSearchInDirectory",
|
||||
"cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"delete": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"shift-f6": "project_panel::Rename"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Terminal",
|
||||
"bindings": {
|
||||
"cmd-t": "workspace::NewTerminal",
|
||||
"alt-f12": "workspace::CloseActiveDock",
|
||||
"cmd-up": "terminal::ScrollLineUp",
|
||||
"cmd-down": "terminal::ScrollLineDown",
|
||||
"shift-pageup": "terminal::ScrollPageUp",
|
||||
"shift-pagedown": "terminal::ScrollPageDown"
|
||||
}
|
||||
},
|
||||
{ "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } },
|
||||
{ "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } },
|
||||
{ "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } },
|
||||
{ "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } },
|
||||
{
|
||||
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
|
||||
"bindings": { "escape": "editor::ToggleFocus" }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"g r a": "editor::ToggleCodeActions",
|
||||
"g g": "vim::StartOfDocument",
|
||||
"g h": "editor::Hover",
|
||||
"g B": "editor::BlameHover",
|
||||
"g t": "pane::ActivateNextItem",
|
||||
"g shift-t": "pane::ActivatePreviousItem",
|
||||
"g d": "editor::GoToDefinition",
|
||||
@@ -377,6 +378,7 @@
|
||||
"context": "vim_mode == helix_normal && !menu",
|
||||
"bindings": {
|
||||
"ctrl-[": "editor::Cancel",
|
||||
";": "vim::HelixCollapseSelection",
|
||||
":": "command_palette::Toggle",
|
||||
"left": "vim::WrappingLeft",
|
||||
"right": "vim::WrappingRight",
|
||||
@@ -577,6 +579,13 @@
|
||||
"shift-u": "git::UnstageAndNext" // "d shift-u"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "VimControl && (AgentDiff || editor_agent_diff)",
|
||||
"bindings": {
|
||||
"d p": "agent::Reject",
|
||||
"d u": "agent::Keep"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "vim_operator == gu",
|
||||
"bindings": {
|
||||
@@ -723,7 +732,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel || GitPanel || ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || DebugPanel",
|
||||
"context": "VimControl || !Editor && !Terminal",
|
||||
"bindings": {
|
||||
// window related commands (ctrl-w X)
|
||||
"ctrl-w": null,
|
||||
@@ -781,7 +790,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "ChangesList || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome",
|
||||
"context": "!Editor && !Terminal",
|
||||
"bindings": {
|
||||
":": "command_palette::Toggle",
|
||||
"g /": "pane::DeploySearch"
|
||||
@@ -857,6 +866,14 @@
|
||||
"shift-n": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Picker > Editor",
|
||||
"bindings": {
|
||||
"ctrl-h": "editor::Backspace",
|
||||
"ctrl-u": "editor::DeleteToBeginningOfLine",
|
||||
"ctrl-w": "editor::DeleteToPreviousWordStart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "GitCommit > Editor && VimControl && vim_mode == normal",
|
||||
"bindings": {
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"bottom_dock_layout": "contained",
|
||||
// The direction that you want to split panes horizontally. Defaults to "up"
|
||||
"pane_split_direction_horizontal": "up",
|
||||
// The direction that you want to split panes horizontally. Defaults to "left"
|
||||
// The direction that you want to split panes vertically. Defaults to "left"
|
||||
"pane_split_direction_vertical": "left",
|
||||
// Centered layout related settings.
|
||||
"centered_layout": {
|
||||
@@ -197,6 +197,8 @@
|
||||
// "inline"
|
||||
// 3. Place snippets at the bottom of the completion list:
|
||||
// "bottom"
|
||||
// 4. Do not show snippets in the completion list:
|
||||
// "none"
|
||||
"snippet_sort_order": "inline",
|
||||
// How to highlight the current line in the editor.
|
||||
//
|
||||
@@ -817,7 +819,7 @@
|
||||
"edit_file": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"move_path": true,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
@@ -837,7 +839,7 @@
|
||||
"diagnostics": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
"read_file": true,
|
||||
@@ -1671,6 +1673,10 @@
|
||||
"allowed": true
|
||||
}
|
||||
},
|
||||
"SystemVerilog": {
|
||||
"format_on_save": "off",
|
||||
"use_on_type_format": false
|
||||
},
|
||||
"Vue.js": {
|
||||
"language_servers": ["vue-language-server", "..."],
|
||||
"prettier": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// command palette (cmd-shift-p / ctrl-shift-p)
|
||||
{
|
||||
"ui_font_size": 16,
|
||||
"buffer_font_size": 16,
|
||||
"buffer_font_size": 15,
|
||||
"theme": {
|
||||
"mode": "system",
|
||||
"light": "One Light",
|
||||
|
||||
@@ -59,5 +59,11 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
stripe-mock:
|
||||
image: stripe/stripe-mock:v0.178.0
|
||||
ports:
|
||||
- 12111:12111
|
||||
- 12112:12112
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "acp"
|
||||
name = "acp_thread"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
@@ -9,15 +9,13 @@ license = "GPL-3.0-or-later"
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/acp.rs"
|
||||
path = "src/acp_thread.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = ["gpui/test-support", "project/test-support"]
|
||||
gemini = []
|
||||
|
||||
[dependencies]
|
||||
agent_servers.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
assistant_tool.workspace = true
|
||||
@@ -29,6 +27,8 @@ itertools.workspace = true
|
||||
language.workspace = true
|
||||
markdown.workspace = true
|
||||
project.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
ui.workspace = true
|
||||
@@ -41,7 +41,6 @@ env_logger.workspace = true
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
indoc.workspace = true
|
||||
project = { workspace = true, "features" = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
util.workspace = true
|
||||
settings.workspace = true
|
||||
20
crates/acp_thread/src/connection.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use agentic_coding_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use futures::future::{FutureExt as _, LocalBoxFuture};
|
||||
|
||||
pub trait AgentConnection {
|
||||
fn request_any(
|
||||
&self,
|
||||
params: acp::AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>>;
|
||||
}
|
||||
|
||||
impl AgentConnection for acp::AgentConnection {
|
||||
fn request_any(
|
||||
&self,
|
||||
params: acp::AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
|
||||
let task = self.request_any(params);
|
||||
async move { Ok(task.await?) }.boxed_local()
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,6 @@ impl ActivityIndicator {
|
||||
status,
|
||||
} => {
|
||||
let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
|
||||
let project = project.clone();
|
||||
let status = status.clone();
|
||||
let server_name = server_name.clone();
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
@@ -247,8 +246,7 @@ impl ActivityIndicator {
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(cx.new(|cx| {
|
||||
let mut editor =
|
||||
Editor::for_buffer(buffer, Some(project.clone()), window, cx);
|
||||
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
||||
editor.set_read_only(true);
|
||||
editor
|
||||
})),
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Tool for ContextServerTool {
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::Cog
|
||||
IconName::ToolHammer
|
||||
}
|
||||
|
||||
fn source(&self) -> ToolSource {
|
||||
|
||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[features]
|
||||
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"]
|
||||
e2e = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -13,15 +17,38 @@ path = "src/agent_servers.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
acp_thread.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
collections.workspace = true
|
||||
context_server.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
paths.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
strum.workspace = true
|
||||
tempfile.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc.workspace = true
|
||||
nix.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
language.workspace = true
|
||||
indoc.workspace = true
|
||||
acp_thread = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -1,30 +1,80 @@
|
||||
mod claude;
|
||||
mod gemini;
|
||||
mod settings;
|
||||
mod stdio_agent_server;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
|
||||
pub use claude::*;
|
||||
pub use gemini::*;
|
||||
pub use settings::*;
|
||||
pub use stdio_agent_server::*;
|
||||
|
||||
use acp_thread::AcpThread;
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use gpui::{App, AsyncApp, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use gpui::{App, AsyncApp, Entity, SharedString};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsStore};
|
||||
use util::{ResultExt, paths};
|
||||
use util::ResultExt as _;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AllAgentServersSettings::register(cx);
|
||||
settings::init(cx);
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AllAgentServersSettings {
|
||||
gemini: Option<AgentServerSettings>,
|
||||
pub trait AgentServer: Send {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> &'static str;
|
||||
fn empty_state_headline(&self) -> &'static str;
|
||||
fn empty_state_message(&self) -> &'static str;
|
||||
fn supports_always_allow(&self) -> bool;
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>>;
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AgentServerSettings {
|
||||
#[serde(flatten)]
|
||||
command: AgentServerCommand,
|
||||
impl std::fmt::Debug for AgentServerCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let filtered_env = self.env.as_ref().map(|env| {
|
||||
env.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
if util::redact::should_redact(k) {
|
||||
"[REDACTED]"
|
||||
} else {
|
||||
v
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
f.debug_struct("AgentServerCommand")
|
||||
.field("path", &self.path)
|
||||
.field("args", &self.args)
|
||||
.field("env", &filtered_env)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AgentServerVersion {
|
||||
Supported,
|
||||
Unsupported {
|
||||
error_message: SharedString,
|
||||
upgrade_message: SharedString,
|
||||
upgrade_command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
|
||||
@@ -36,105 +86,34 @@ pub struct AgentServerCommand {
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
pub struct Gemini;
|
||||
|
||||
pub struct AgentServerVersion {
|
||||
pub current_version: SharedString,
|
||||
pub supported: bool,
|
||||
}
|
||||
|
||||
pub trait AgentServer: Send {
|
||||
fn command(
|
||||
&self,
|
||||
impl AgentServerCommand {
|
||||
pub(crate) async fn resolve(
|
||||
path_bin_name: &'static str,
|
||||
extra_args: &[&'static str],
|
||||
settings: Option<AgentServerSettings>,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> impl Future<Output = Result<AgentServerCommand>>;
|
||||
|
||||
fn version(
|
||||
&self,
|
||||
command: &AgentServerCommand,
|
||||
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
|
||||
}
|
||||
|
||||
const GEMINI_ACP_ARG: &str = "--acp";
|
||||
|
||||
impl AgentServer for Gemini {
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let custom_command = cx.read_global(|settings: &SettingsStore, _| {
|
||||
let settings = settings.get::<AllAgentServersSettings>(None);
|
||||
settings
|
||||
.gemini
|
||||
.as_ref()
|
||||
.map(|gemini_settings| AgentServerCommand {
|
||||
path: gemini_settings.command.path.clone(),
|
||||
args: gemini_settings
|
||||
.command
|
||||
.args
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(std::iter::once(GEMINI_ACP_ARG.into()))
|
||||
.collect(),
|
||||
env: gemini_settings.command.env.clone(),
|
||||
})
|
||||
})?;
|
||||
|
||||
if let Some(custom_command) = custom_command {
|
||||
return Ok(custom_command);
|
||||
}
|
||||
|
||||
if let Some(path) = find_bin_in_path("gemini", project, cx).await {
|
||||
return Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![GEMINI_ACP_ARG.into()],
|
||||
env: None,
|
||||
) -> Option<Self> {
|
||||
if let Some(agent_settings) = settings {
|
||||
return Some(Self {
|
||||
path: agent_settings.command.path,
|
||||
args: agent_settings
|
||||
.command
|
||||
.args
|
||||
.into_iter()
|
||||
.chain(extra_args.iter().map(|arg| arg.to_string()))
|
||||
.collect(),
|
||||
env: agent_settings.command.env,
|
||||
});
|
||||
} else {
|
||||
find_bin_in_path(path_bin_name, project, cx)
|
||||
.await
|
||||
.map(|path| Self {
|
||||
path,
|
||||
args: extra_args.iter().map(|arg| arg.to_string()).collect(),
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("gemini not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("gemini");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/gemini");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![GEMINI_ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?.into();
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
|
||||
|
||||
Ok(AgentServerVersion {
|
||||
current_version,
|
||||
supported,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,48 +163,3 @@ async fn find_bin_in_path(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentServerCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let filtered_env = self.env.as_ref().map(|env| {
|
||||
env.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
if util::redact::should_redact(k) {
|
||||
"[REDACTED]"
|
||||
} else {
|
||||
v
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
f.debug_struct("AgentServerCommand")
|
||||
.field("path", &self.path)
|
||||
.field("args", &self.args)
|
||||
.field("env", &filtered_env)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Settings for AllAgentServersSettings {
|
||||
const KEY: Option<&'static str> = Some("agent_servers");
|
||||
|
||||
type FileContent = Self;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for value in sources.defaults_and_customizations() {
|
||||
if value.gemini.is_some() {
|
||||
settings.gemini = value.gemini.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
805
crates/agent_servers/src/claude.rs
Normal file
@@ -0,0 +1,805 @@
|
||||
mod mcp_server;
|
||||
mod tools;
|
||||
|
||||
use collections::HashMap;
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use smol::process::Child;
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Display;
|
||||
use std::path::Path;
|
||||
use std::pin::pin;
|
||||
use std::rc::Rc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use agentic_coding_protocol::{
|
||||
self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion,
|
||||
StreamAssistantMessageChunkParams, ToolCallContent, UpdateToolCallParams,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::LocalBoxFuture;
|
||||
use futures::{AsyncBufReadExt, AsyncWriteExt, SinkExt};
|
||||
use futures::{
|
||||
AsyncRead, AsyncWrite, FutureExt, StreamExt,
|
||||
channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
io::BufReader,
|
||||
select_biased,
|
||||
};
|
||||
use gpui::{App, AppContext, Entity, Task};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::claude::mcp_server::ClaudeMcpServer;
|
||||
use crate::claude::tools::ClaudeTool;
|
||||
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
|
||||
use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeCode;
|
||||
|
||||
impl AgentServer for ClaudeCode {
|
||||
fn name(&self) -> &'static str {
|
||||
"Claude Code"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiClaude
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let project = project.clone();
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let title = self.name().into();
|
||||
cx.spawn(async move |cx| {
|
||||
let (mut delegate_tx, delegate_rx) = watch::channel(None);
|
||||
let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
|
||||
|
||||
let mcp_server = ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
|
||||
|
||||
let mut mcp_servers = HashMap::default();
|
||||
mcp_servers.insert(
|
||||
mcp_server::SERVER_NAME.to_string(),
|
||||
mcp_server.server_config()?,
|
||||
);
|
||||
let mcp_config = McpConfig { mcp_servers };
|
||||
|
||||
let mcp_config_file = tempfile::NamedTempFile::new()?;
|
||||
let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
|
||||
|
||||
let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
|
||||
mcp_config_file
|
||||
.write_all(serde_json::to_string(&mcp_config)?.as_bytes())
|
||||
.await?;
|
||||
mcp_config_file.flush().await?;
|
||||
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).claude.clone()
|
||||
})?;
|
||||
|
||||
let Some(command) =
|
||||
AgentServerCommand::resolve("claude", &[], settings, &project, cx).await
|
||||
else {
|
||||
anyhow::bail!("Failed to find claude binary");
|
||||
};
|
||||
|
||||
let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
|
||||
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::unbounded::<oneshot::Sender<Result<()>>>();
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
log::trace!("Starting session with id: {}", session_id);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut outgoing_rx = Some(outgoing_rx);
|
||||
let mut mode = ClaudeSessionMode::Start;
|
||||
|
||||
loop {
|
||||
let mut child =
|
||||
spawn_claude(&command, mode, session_id, &mcp_config_path, &root_dir)
|
||||
.await?;
|
||||
mode = ClaudeSessionMode::Resume;
|
||||
|
||||
let pid = child.id();
|
||||
log::trace!("Spawned (pid: {})", pid);
|
||||
|
||||
let mut io_fut = pin!(
|
||||
ClaudeAgentConnection::handle_io(
|
||||
outgoing_rx.take().unwrap(),
|
||||
incoming_message_tx.clone(),
|
||||
child.stdin.take().unwrap(),
|
||||
child.stdout.take().unwrap(),
|
||||
)
|
||||
.fuse()
|
||||
);
|
||||
|
||||
select_biased! {
|
||||
done_tx = cancel_rx.next() => {
|
||||
if let Some(done_tx) = done_tx {
|
||||
log::trace!("Interrupted (pid: {})", pid);
|
||||
let result = send_interrupt(pid as i32);
|
||||
outgoing_rx.replace(io_fut.await?);
|
||||
done_tx.send(result).log_err();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = io_fut => {
|
||||
result?;
|
||||
}
|
||||
}
|
||||
|
||||
log::trace!("Stopped (pid: {})", pid);
|
||||
break;
|
||||
}
|
||||
|
||||
drop(mcp_config_path);
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| {
|
||||
let end_turn_tx = Rc::new(RefCell::new(None));
|
||||
let delegate = AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async());
|
||||
delegate_tx.send(Some(delegate.clone())).log_err();
|
||||
|
||||
let handler_task = cx.foreground_executor().spawn({
|
||||
let end_turn_tx = end_turn_tx.clone();
|
||||
let tool_id_map = tool_id_map.clone();
|
||||
let delegate = delegate.clone();
|
||||
async move {
|
||||
while let Some(message) = incoming_message_rx.next().await {
|
||||
ClaudeAgentConnection::handle_message(
|
||||
delegate.clone(),
|
||||
message,
|
||||
end_turn_tx.clone(),
|
||||
tool_id_map.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut connection = ClaudeAgentConnection {
|
||||
delegate,
|
||||
outgoing_tx,
|
||||
end_turn_tx,
|
||||
cancel_tx,
|
||||
session_id,
|
||||
_handler_task: handler_task,
|
||||
_mcp_server: None,
|
||||
};
|
||||
|
||||
connection._mcp_server = Some(mcp_server);
|
||||
acp_thread::AcpThread::new(connection, title, None, project.clone(), cx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> {
|
||||
let pid = nix::unistd::Pid::from_raw(pid);
|
||||
|
||||
nix::sys::signal::kill(pid, nix::sys::signal::SIGINT)
|
||||
.map_err(|e| anyhow!("Failed to interrupt process: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn send_interrupt(_pid: i32) -> anyhow::Result<()> {
|
||||
panic!("Cancel not implemented on Windows")
|
||||
}
|
||||
|
||||
impl AgentConnection for ClaudeAgentConnection {
|
||||
/// Send a request to the agent and wait for a response.
|
||||
fn request_any(
|
||||
&self,
|
||||
params: AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
|
||||
let delegate = self.delegate.clone();
|
||||
let end_turn_tx = self.end_turn_tx.clone();
|
||||
let outgoing_tx = self.outgoing_tx.clone();
|
||||
let mut cancel_tx = self.cancel_tx.clone();
|
||||
let session_id = self.session_id;
|
||||
async move {
|
||||
match params {
|
||||
// todo: consider sending an empty request so we get the init response?
|
||||
AnyAgentRequest::InitializeParams(_) => Ok(AnyAgentResult::InitializeResponse(
|
||||
acp::InitializeResponse {
|
||||
is_authenticated: true,
|
||||
protocol_version: ProtocolVersion::latest(),
|
||||
},
|
||||
)),
|
||||
AnyAgentRequest::AuthenticateParams(_) => {
|
||||
Err(anyhow!("Authentication not supported"))
|
||||
}
|
||||
AnyAgentRequest::SendUserMessageParams(message) => {
|
||||
delegate.clear_completed_plan_entries().await?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
end_turn_tx.borrow_mut().replace(tx);
|
||||
let mut content = String::new();
|
||||
for chunk in message.chunks {
|
||||
match chunk {
|
||||
agentic_coding_protocol::UserMessageChunk::Text { text } => {
|
||||
content.push_str(&text)
|
||||
}
|
||||
agentic_coding_protocol::UserMessageChunk::Path { path } => {
|
||||
content.push_str(&format!("@{path:?}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoing_tx.unbounded_send(SdkMessage::User {
|
||||
message: Message {
|
||||
role: Role::User,
|
||||
content: Content::UntaggedText(content),
|
||||
id: None,
|
||||
model: None,
|
||||
stop_reason: None,
|
||||
stop_sequence: None,
|
||||
usage: None,
|
||||
},
|
||||
session_id: Some(session_id),
|
||||
})?;
|
||||
rx.await??;
|
||||
Ok(AnyAgentResult::SendUserMessageResponse(
|
||||
acp::SendUserMessageResponse,
|
||||
))
|
||||
}
|
||||
AnyAgentRequest::CancelSendMessageParams(_) => {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
cancel_tx.send(done_tx).await?;
|
||||
done_rx.await??;
|
||||
|
||||
Ok(AnyAgentResult::CancelSendMessageResponse(
|
||||
acp::CancelSendMessageResponse,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ClaudeSessionMode {
|
||||
Start,
|
||||
Resume,
|
||||
}
|
||||
|
||||
async fn spawn_claude(
|
||||
command: &AgentServerCommand,
|
||||
mode: ClaudeSessionMode,
|
||||
session_id: Uuid,
|
||||
mcp_config_path: &Path,
|
||||
root_dir: &Path,
|
||||
) -> Result<Child> {
|
||||
let child = util::command::new_smol_command(&command.path)
|
||||
.args([
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--print",
|
||||
"--verbose",
|
||||
"--mcp-config",
|
||||
mcp_config_path.to_string_lossy().as_ref(),
|
||||
"--permission-prompt-tool",
|
||||
&format!(
|
||||
"mcp__{}__{}",
|
||||
mcp_server::SERVER_NAME,
|
||||
mcp_server::PERMISSION_TOOL
|
||||
),
|
||||
"--allowedTools",
|
||||
"mcp__zed__Read,mcp__zed__Edit",
|
||||
"--disallowedTools",
|
||||
"Read,Edit",
|
||||
])
|
||||
.args(match mode {
|
||||
ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
|
||||
ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
|
||||
})
|
||||
.args(command.args.iter().map(|arg| arg.as_str()))
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
struct ClaudeAgentConnection {
|
||||
delegate: AcpClientDelegate,
|
||||
session_id: Uuid,
|
||||
outgoing_tx: UnboundedSender<SdkMessage>,
|
||||
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
|
||||
cancel_tx: UnboundedSender<oneshot::Sender<Result<()>>>,
|
||||
_mcp_server: Option<ClaudeMcpServer>,
|
||||
_handler_task: Task<()>,
|
||||
}
|
||||
|
||||
impl ClaudeAgentConnection {
|
||||
async fn handle_message(
|
||||
delegate: AcpClientDelegate,
|
||||
message: SdkMessage,
|
||||
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
) {
|
||||
match message {
|
||||
SdkMessage::Assistant { message, .. } | SdkMessage::User { message, .. } => {
|
||||
for chunk in message.content.chunks() {
|
||||
match chunk {
|
||||
ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
|
||||
delegate
|
||||
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
|
||||
chunk: acp::AssistantMessageChunk::Text { text },
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
ContentChunk::ToolUse { id, name, input } => {
|
||||
let claude_tool = ClaudeTool::infer(&name, input);
|
||||
|
||||
if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
|
||||
delegate
|
||||
.update_plan(acp::UpdatePlanParams {
|
||||
entries: params.todos.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
} else if let Some(resp) = delegate
|
||||
.push_tool_call(claude_tool.as_acp())
|
||||
.await
|
||||
.log_err()
|
||||
{
|
||||
tool_id_map.borrow_mut().insert(id, resp.id);
|
||||
}
|
||||
}
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
let id = tool_id_map.borrow_mut().remove(&tool_use_id);
|
||||
if let Some(id) = id {
|
||||
let content = content.to_string();
|
||||
delegate
|
||||
.update_tool_call(UpdateToolCallParams {
|
||||
tool_call_id: id,
|
||||
status: acp::ToolCallStatus::Finished,
|
||||
// Don't unset existing content
|
||||
content: (!content.is_empty()).then_some(
|
||||
ToolCallContent::Markdown {
|
||||
// For now we only include text content
|
||||
markdown: content,
|
||||
},
|
||||
),
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
ContentChunk::Image
|
||||
| ContentChunk::Document
|
||||
| ContentChunk::Thinking
|
||||
| ContentChunk::RedactedThinking
|
||||
| ContentChunk::WebSearchToolResult => {
|
||||
delegate
|
||||
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
|
||||
chunk: acp::AssistantMessageChunk::Text {
|
||||
text: format!("Unsupported content: {:?}", chunk),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SdkMessage::Result {
|
||||
is_error, subtype, ..
|
||||
} => {
|
||||
if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() {
|
||||
if is_error {
|
||||
end_turn_tx.send(Err(anyhow!("Error: {subtype}"))).ok();
|
||||
} else {
|
||||
end_turn_tx.send(Ok(())).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
SdkMessage::System { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_io(
|
||||
mut outgoing_rx: UnboundedReceiver<SdkMessage>,
|
||||
incoming_tx: UnboundedSender<SdkMessage>,
|
||||
mut outgoing_bytes: impl Unpin + AsyncWrite,
|
||||
incoming_bytes: impl Unpin + AsyncRead,
|
||||
) -> Result<UnboundedReceiver<SdkMessage>> {
|
||||
let mut output_reader = BufReader::new(incoming_bytes);
|
||||
let mut outgoing_line = Vec::new();
|
||||
let mut incoming_line = String::new();
|
||||
loop {
|
||||
select_biased! {
|
||||
message = outgoing_rx.next() => {
|
||||
if let Some(message) = message {
|
||||
outgoing_line.clear();
|
||||
serde_json::to_writer(&mut outgoing_line, &message)?;
|
||||
log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
|
||||
outgoing_line.push(b'\n');
|
||||
outgoing_bytes.write_all(&outgoing_line).await.ok();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
log::trace!("recv: {}", &incoming_line);
|
||||
match serde_json::from_str::<SdkMessage>(&incoming_line) {
|
||||
Ok(message) => {
|
||||
incoming_tx.unbounded_send(message).log_err();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
|
||||
}
|
||||
}
|
||||
incoming_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outgoing_rx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Message {
|
||||
role: Role,
|
||||
content: Content,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop_sequence: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Content {
|
||||
UntaggedText(String),
|
||||
Chunks(Vec<ContentChunk>),
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
|
||||
match self {
|
||||
Self::Chunks(chunks) => chunks.into_iter(),
|
||||
Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Content {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Content::UntaggedText(txt) => write!(f, "{}", txt),
|
||||
Content::Chunks(chunks) => {
|
||||
for chunk in chunks {
|
||||
write!(f, "{}", chunk)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ContentChunk {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
ToolResult {
|
||||
content: Content,
|
||||
tool_use_id: String,
|
||||
},
|
||||
// TODO
|
||||
Image,
|
||||
Document,
|
||||
Thinking,
|
||||
RedactedThinking,
|
||||
WebSearchToolResult,
|
||||
#[serde(untagged)]
|
||||
UntaggedText(String),
|
||||
}
|
||||
|
||||
impl Display for ContentChunk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ContentChunk::Text { text } => write!(f, "{}", text),
|
||||
ContentChunk::UntaggedText(text) => write!(f, "{}", text),
|
||||
ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
|
||||
ContentChunk::Image
|
||||
| ContentChunk::Document
|
||||
| ContentChunk::Thinking
|
||||
| ContentChunk::RedactedThinking
|
||||
| ContentChunk::ToolUse { .. }
|
||||
| ContentChunk::WebSearchToolResult => {
|
||||
write!(f, "\n{:?}\n", &self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Usage {
|
||||
input_tokens: u32,
|
||||
cache_creation_input_tokens: u32,
|
||||
cache_read_input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
service_tier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Role {
|
||||
System,
|
||||
Assistant,
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MessageParam {
|
||||
role: Role,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum SdkMessage {
|
||||
// An assistant message
|
||||
Assistant {
|
||||
message: Message, // from Anthropic SDK
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<Uuid>,
|
||||
},
|
||||
|
||||
// A user message
|
||||
User {
|
||||
message: Message, // from Anthropic SDK
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<Uuid>,
|
||||
},
|
||||
|
||||
// Emitted as the last message in a conversation
|
||||
Result {
|
||||
subtype: ResultErrorType,
|
||||
duration_ms: f64,
|
||||
duration_api_ms: f64,
|
||||
is_error: bool,
|
||||
num_turns: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<String>,
|
||||
session_id: String,
|
||||
total_cost_usd: f64,
|
||||
},
|
||||
// Emitted as the first message at the start of a conversation
|
||||
System {
|
||||
cwd: String,
|
||||
session_id: String,
|
||||
tools: Vec<String>,
|
||||
model: String,
|
||||
mcp_servers: Vec<McpServer>,
|
||||
#[serde(rename = "apiKeySource")]
|
||||
api_key_source: String,
|
||||
#[serde(rename = "permissionMode")]
|
||||
permission_mode: PermissionMode,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ResultErrorType {
|
||||
Success,
|
||||
ErrorMaxTurns,
|
||||
ErrorDuringExecution,
|
||||
}
|
||||
|
||||
impl Display for ResultErrorType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ResultErrorType::Success => write!(f, "success"),
|
||||
ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
|
||||
ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct McpServer {
|
||||
name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum PermissionMode {
|
||||
Default,
|
||||
AcceptEdits,
|
||||
BypassPermissions,
|
||||
Plan,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpConfig {
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpServerConfig {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
crate::common_e2e_tests!(ClaudeCode);
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
AgentServerCommand {
|
||||
path: "claude".into(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_content_untagged_text() {
|
||||
let json = json!("Hello, world!");
|
||||
let content: Content = serde_json::from_value(json).unwrap();
|
||||
match content {
|
||||
Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
|
||||
_ => panic!("Expected UntaggedText variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_content_chunks() {
|
||||
let json = json!([
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_123",
|
||||
"name": "calculator",
|
||||
"input": {"operation": "add", "a": 1, "b": 2}
|
||||
}
|
||||
]);
|
||||
let content: Content = serde_json::from_value(json).unwrap();
|
||||
match content {
|
||||
Content::Chunks(chunks) => {
|
||||
assert_eq!(chunks.len(), 2);
|
||||
match &chunks[0] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Hello"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
match &chunks[1] {
|
||||
ContentChunk::ToolUse { id, name, input } => {
|
||||
assert_eq!(id, "tool_123");
|
||||
assert_eq!(name, "calculator");
|
||||
assert_eq!(input["operation"], "add");
|
||||
assert_eq!(input["a"], 1);
|
||||
assert_eq!(input["b"], 2);
|
||||
}
|
||||
_ => panic!("Expected ToolUse chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Chunks variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_tool_result_untagged_text() {
|
||||
let json = json!({
|
||||
"type": "tool_result",
|
||||
"content": "Result content",
|
||||
"tool_use_id": "tool_456"
|
||||
});
|
||||
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
|
||||
match chunk {
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
match content {
|
||||
Content::UntaggedText(text) => assert_eq!(text, "Result content"),
|
||||
_ => panic!("Expected UntaggedText content"),
|
||||
}
|
||||
assert_eq!(tool_use_id, "tool_456");
|
||||
}
|
||||
_ => panic!("Expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_tool_result_chunks() {
|
||||
let json = json!({
|
||||
"type": "tool_result",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Processing complete"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Result: 42"
|
||||
}
|
||||
],
|
||||
"tool_use_id": "tool_789"
|
||||
});
|
||||
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
|
||||
match chunk {
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
match content {
|
||||
Content::Chunks(chunks) => {
|
||||
assert_eq!(chunks.len(), 2);
|
||||
match &chunks[0] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
match &chunks[1] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Chunks content"),
|
||||
}
|
||||
assert_eq!(tool_use_id, "tool_789");
|
||||
}
|
||||
_ => panic!("Expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
300
crates/agent_servers/src/claude/mcp_server.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use acp_thread::AcpClientDelegate;
|
||||
use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
|
||||
use anyhow::{Context, Result};
|
||||
use collections::HashMap;
|
||||
use context_server::{
|
||||
listener::McpServer,
|
||||
types::{
|
||||
CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
|
||||
ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
|
||||
ToolResponseContent, ToolsCapabilities, requests,
|
||||
},
|
||||
};
|
||||
use gpui::{App, AsyncApp, Task};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::debug_panic;
|
||||
|
||||
use crate::claude::{
|
||||
McpServerConfig,
|
||||
tools::{ClaudeTool, EditToolParams, EditToolResponse, ReadToolParams, ReadToolResponse},
|
||||
};
|
||||
|
||||
pub struct ClaudeMcpServer {
|
||||
server: McpServer,
|
||||
}
|
||||
|
||||
pub const SERVER_NAME: &str = "zed";
|
||||
pub const READ_TOOL: &str = "Read";
|
||||
pub const EDIT_TOOL: &str = "Edit";
|
||||
pub const PERMISSION_TOOL: &str = "Confirmation";
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
struct PermissionToolParams {
|
||||
tool_name: String,
|
||||
input: serde_json::Value,
|
||||
tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior,
|
||||
updated_input: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum PermissionToolBehavior {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl ClaudeMcpServer {
|
||||
pub async fn new(
|
||||
delegate: watch::Receiver<Option<AcpClientDelegate>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut mcp_server = McpServer::new(cx).await?;
|
||||
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
|
||||
mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
|
||||
mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
|
||||
Self::handle_call_tool(request, delegate.clone(), tool_id_map.clone(), cx)
|
||||
});
|
||||
|
||||
Ok(Self { server: mcp_server })
|
||||
}
|
||||
|
||||
pub fn server_config(&self) -> Result<McpServerConfig> {
|
||||
let zed_path = std::env::current_exe()
|
||||
.context("finding current executable path for use in mcp_server")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(McpServerConfig {
|
||||
command: zed_path,
|
||||
args: vec![
|
||||
"--nc".into(),
|
||||
self.server.socket_path().display().to_string(),
|
||||
],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
Ok(InitializeResponse {
|
||||
protocol_version: ProtocolVersion("2025-06-18".into()),
|
||||
capabilities: ServerCapabilities {
|
||||
experimental: None,
|
||||
logging: None,
|
||||
completions: None,
|
||||
prompts: None,
|
||||
resources: None,
|
||||
tools: Some(ToolsCapabilities {
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
},
|
||||
server_info: Implementation {
|
||||
name: SERVER_NAME.into(),
|
||||
version: "0.1.0".into(),
|
||||
},
|
||||
meta: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_tools(_: (), cx: &App) -> Task<Result<ListToolsResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
Ok(ListToolsResponse {
|
||||
tools: vec![
|
||||
Tool {
|
||||
name: PERMISSION_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(PermissionToolParams).into(),
|
||||
description: None,
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: READ_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(ReadToolParams).into(),
|
||||
description: Some("Read the contents of a file. In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.".to_string()),
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("Read file".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
open_world_hint: Some(false),
|
||||
// if time passes the contents might change, but it's not going to do anything different
|
||||
// true or false seem too strong, let's try a none.
|
||||
idempotent_hint: None,
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: EDIT_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(EditToolParams).into(),
|
||||
description: Some("Edits a file. In sessions with mcp__zed__Edit always use it instead of Edit as it will show the diff to the user better.".to_string()),
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("Edit file".to_string()),
|
||||
read_only_hint: Some(false),
|
||||
destructive_hint: Some(false),
|
||||
open_world_hint: Some(false),
|
||||
idempotent_hint: Some(false),
|
||||
}),
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_call_tool(
|
||||
request: CallToolParams,
|
||||
mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &App,
|
||||
) -> Task<Result<CallToolResponse>> {
|
||||
cx.spawn(async move |cx| {
|
||||
let Some(delegate) = delegate_watch.recv().await? else {
|
||||
debug_panic!("Sent None delegate");
|
||||
anyhow::bail!("Server not available");
|
||||
};
|
||||
|
||||
if request.name.as_str() == PERMISSION_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result =
|
||||
Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else if request.name.as_str() == READ_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result = Self::handle_read_tool_call(input, delegate, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else if request.name.as_str() == EDIT_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result = Self::handle_edit_tool_call(input, delegate, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!("Unsupported tool");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read_tool_call(
|
||||
params: ReadToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<ReadToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let response = delegate
|
||||
.read_text_file(ReadTextFileParams {
|
||||
path: params.abs_path,
|
||||
line: params.offset,
|
||||
limit: params.limit,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(ReadToolResponse {
|
||||
content: response.content,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_edit_tool_call(
|
||||
params: EditToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<EditToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let response = delegate
|
||||
.read_text_file_reusing_snapshot(ReadTextFileParams {
|
||||
path: params.abs_path.clone(),
|
||||
line: None,
|
||||
limit: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_content = response.content.replace(¶ms.old_text, ¶ms.new_text);
|
||||
if new_content == response.content {
|
||||
return Err(anyhow::anyhow!("The old_text was not found in the content"));
|
||||
}
|
||||
|
||||
delegate
|
||||
.write_text_file(WriteTextFileParams {
|
||||
path: params.abs_path,
|
||||
content: new_content,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(EditToolResponse)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_permissions_tool_call(
|
||||
params: PermissionToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<PermissionToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let claude_tool = ClaudeTool::infer(¶ms.tool_name, params.input.clone());
|
||||
|
||||
let tool_call_id = match params.tool_use_id {
|
||||
Some(tool_use_id) => tool_id_map
|
||||
.borrow()
|
||||
.get(&tool_use_id)
|
||||
.cloned()
|
||||
.context("Tool call ID not found")?,
|
||||
|
||||
None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
|
||||
};
|
||||
|
||||
let outcome = delegate
|
||||
.request_existing_tool_call_confirmation(
|
||||
tool_call_id,
|
||||
claude_tool.confirmation(None),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match outcome {
|
||||
acp::ToolCallConfirmationOutcome::Allow
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllow
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior::Allow,
|
||||
updated_input: params.input,
|
||||
}),
|
||||
acp::ToolCallConfirmationOutcome::Reject
|
||||
| acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior::Deny,
|
||||
updated_input: params.input,
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
793
crates/agent_servers/src/claude/tools.rs
Normal file
@@ -0,0 +1,793 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use agentic_coding_protocol::{self as acp, PushToolCallParams, ToolCallLocation};
|
||||
use itertools::Itertools;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::ResultExt;
|
||||
|
||||
pub enum ClaudeTool {
|
||||
Task(Option<TaskToolParams>),
|
||||
NotebookRead(Option<NotebookReadToolParams>),
|
||||
NotebookEdit(Option<NotebookEditToolParams>),
|
||||
Edit(Option<EditToolParams>),
|
||||
MultiEdit(Option<MultiEditToolParams>),
|
||||
ReadFile(Option<ReadToolParams>),
|
||||
Write(Option<WriteToolParams>),
|
||||
Ls(Option<LsToolParams>),
|
||||
Glob(Option<GlobToolParams>),
|
||||
Grep(Option<GrepToolParams>),
|
||||
Terminal(Option<BashToolParams>),
|
||||
WebFetch(Option<WebFetchToolParams>),
|
||||
WebSearch(Option<WebSearchToolParams>),
|
||||
TodoWrite(Option<TodoWriteToolParams>),
|
||||
ExitPlanMode(Option<ExitPlanModeToolParams>),
|
||||
Other {
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClaudeTool {
|
||||
pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
|
||||
match tool_name {
|
||||
// Known tools
|
||||
"mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
|
||||
"mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
|
||||
"MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
|
||||
"Write" => Self::Write(serde_json::from_value(input).log_err()),
|
||||
"LS" => Self::Ls(serde_json::from_value(input).log_err()),
|
||||
"Glob" => Self::Glob(serde_json::from_value(input).log_err()),
|
||||
"Grep" => Self::Grep(serde_json::from_value(input).log_err()),
|
||||
"Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
|
||||
"WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
|
||||
"WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
|
||||
"TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
|
||||
"exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
|
||||
"Task" => Self::Task(serde_json::from_value(input).log_err()),
|
||||
"NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
|
||||
"NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
|
||||
// Inferred from name
|
||||
_ => {
|
||||
let tool_name = tool_name.to_lowercase();
|
||||
|
||||
if tool_name.contains("edit") || tool_name.contains("write") {
|
||||
Self::Edit(None)
|
||||
} else if tool_name.contains("terminal") {
|
||||
Self::Terminal(None)
|
||||
} else {
|
||||
Self::Other {
|
||||
name: tool_name.to_string(),
|
||||
input,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match &self {
|
||||
Self::Task(Some(params)) => params.description.clone(),
|
||||
Self::Task(None) => "Task".into(),
|
||||
Self::NotebookRead(Some(params)) => {
|
||||
format!("Read Notebook {}", params.notebook_path.display())
|
||||
}
|
||||
Self::NotebookRead(None) => "Read Notebook".into(),
|
||||
Self::NotebookEdit(Some(params)) => {
|
||||
format!("Edit Notebook {}", params.notebook_path.display())
|
||||
}
|
||||
Self::NotebookEdit(None) => "Edit Notebook".into(),
|
||||
Self::Terminal(Some(params)) => format!("`{}`", params.command),
|
||||
Self::Terminal(None) => "Terminal".into(),
|
||||
Self::ReadFile(_) => "Read File".into(),
|
||||
Self::Ls(Some(params)) => {
|
||||
format!("List Directory {}", params.path.display())
|
||||
}
|
||||
Self::Ls(None) => "List Directory".into(),
|
||||
Self::Edit(Some(params)) => {
|
||||
format!("Edit {}", params.abs_path.display())
|
||||
}
|
||||
Self::Edit(None) => "Edit".into(),
|
||||
Self::MultiEdit(Some(params)) => {
|
||||
format!("Multi Edit {}", params.file_path.display())
|
||||
}
|
||||
Self::MultiEdit(None) => "Multi Edit".into(),
|
||||
Self::Write(Some(params)) => {
|
||||
format!("Write {}", params.file_path.display())
|
||||
}
|
||||
Self::Write(None) => "Write".into(),
|
||||
Self::Glob(Some(params)) => {
|
||||
format!("Glob `{params}`")
|
||||
}
|
||||
Self::Glob(None) => "Glob".into(),
|
||||
Self::Grep(Some(params)) => format!("`{params}`"),
|
||||
Self::Grep(None) => "Grep".into(),
|
||||
Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
|
||||
Self::WebFetch(None) => "Fetch".into(),
|
||||
Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
|
||||
Self::WebSearch(None) => "Web Search".into(),
|
||||
Self::TodoWrite(Some(params)) => format!(
|
||||
"Update TODOs: {}",
|
||||
params.todos.iter().map(|todo| &todo.content).join(", ")
|
||||
),
|
||||
Self::TodoWrite(None) => "Update TODOs".into(),
|
||||
Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
|
||||
Self::Other { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(&self) -> Option<acp::ToolCallContent> {
|
||||
match &self {
|
||||
Self::Other { input, .. } => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: format!(
|
||||
"```json\n{}```",
|
||||
serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
|
||||
),
|
||||
}),
|
||||
Self::Task(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.prompt.clone(),
|
||||
}),
|
||||
Self::NotebookRead(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.notebook_path.display().to_string(),
|
||||
}),
|
||||
Self::NotebookEdit(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.new_source.clone(),
|
||||
}),
|
||||
Self::Terminal(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: format!(
|
||||
"`{}`\n\n{}",
|
||||
params.command,
|
||||
params.description.as_deref().unwrap_or_default()
|
||||
),
|
||||
}),
|
||||
Self::ReadFile(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.abs_path.display().to_string(),
|
||||
}),
|
||||
Self::Ls(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.path.display().to_string(),
|
||||
}),
|
||||
Self::Glob(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.to_string(),
|
||||
}),
|
||||
Self::Grep(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: format!("`{params}`"),
|
||||
}),
|
||||
Self::WebFetch(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.prompt.clone(),
|
||||
}),
|
||||
Self::WebSearch(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.to_string(),
|
||||
}),
|
||||
Self::TodoWrite(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params
|
||||
.todos
|
||||
.iter()
|
||||
.map(|todo| {
|
||||
format!(
|
||||
"- {} {}: {}",
|
||||
match todo.status {
|
||||
TodoStatus::Completed => "✅",
|
||||
TodoStatus::InProgress => "🚧",
|
||||
TodoStatus::Pending => "⬜",
|
||||
},
|
||||
todo.priority,
|
||||
todo.content
|
||||
)
|
||||
})
|
||||
.join("\n"),
|
||||
}),
|
||||
Self::ExitPlanMode(Some(params)) => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: params.plan.clone(),
|
||||
}),
|
||||
Self::Edit(Some(params)) => Some(acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: params.abs_path.clone(),
|
||||
old_text: Some(params.old_text.clone()),
|
||||
new_text: params.new_text.clone(),
|
||||
},
|
||||
}),
|
||||
Self::Write(Some(params)) => Some(acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: params.file_path.clone(),
|
||||
old_text: None,
|
||||
new_text: params.content.clone(),
|
||||
},
|
||||
}),
|
||||
Self::MultiEdit(Some(params)) => {
|
||||
// todo: show multiple edits in a multibuffer?
|
||||
params.edits.first().map(|edit| acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: params.file_path.clone(),
|
||||
old_text: Some(edit.old_string.clone()),
|
||||
new_text: edit.new_string.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
Self::Task(None)
|
||||
| Self::NotebookRead(None)
|
||||
| Self::NotebookEdit(None)
|
||||
| Self::Terminal(None)
|
||||
| Self::ReadFile(None)
|
||||
| Self::Ls(None)
|
||||
| Self::Glob(None)
|
||||
| Self::Grep(None)
|
||||
| Self::WebFetch(None)
|
||||
| Self::WebSearch(None)
|
||||
| Self::TodoWrite(None)
|
||||
| Self::ExitPlanMode(None)
|
||||
| Self::Edit(None)
|
||||
| Self::Write(None)
|
||||
| Self::MultiEdit(None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> acp::Icon {
|
||||
match self {
|
||||
Self::Task(_) => acp::Icon::Hammer,
|
||||
Self::NotebookRead(_) => acp::Icon::FileSearch,
|
||||
Self::NotebookEdit(_) => acp::Icon::Pencil,
|
||||
Self::Edit(_) => acp::Icon::Pencil,
|
||||
Self::MultiEdit(_) => acp::Icon::Pencil,
|
||||
Self::Write(_) => acp::Icon::Pencil,
|
||||
Self::ReadFile(_) => acp::Icon::FileSearch,
|
||||
Self::Ls(_) => acp::Icon::Folder,
|
||||
Self::Glob(_) => acp::Icon::FileSearch,
|
||||
Self::Grep(_) => acp::Icon::Regex,
|
||||
Self::Terminal(_) => acp::Icon::Terminal,
|
||||
Self::WebSearch(_) => acp::Icon::Globe,
|
||||
Self::WebFetch(_) => acp::Icon::Globe,
|
||||
Self::TodoWrite(_) => acp::Icon::LightBulb,
|
||||
Self::ExitPlanMode(_) => acp::Icon::Hammer,
|
||||
Self::Other { .. } => acp::Icon::Hammer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn confirmation(&self, description: Option<String>) -> acp::ToolCallConfirmation {
|
||||
match &self {
|
||||
Self::Edit(_) | Self::Write(_) | Self::NotebookEdit(_) | Self::MultiEdit(_) => {
|
||||
acp::ToolCallConfirmation::Edit { description }
|
||||
}
|
||||
Self::WebFetch(params) => acp::ToolCallConfirmation::Fetch {
|
||||
urls: params
|
||||
.as_ref()
|
||||
.map(|p| vec![p.url.clone()])
|
||||
.unwrap_or_default(),
|
||||
description,
|
||||
},
|
||||
Self::Terminal(Some(BashToolParams {
|
||||
description,
|
||||
command,
|
||||
..
|
||||
})) => acp::ToolCallConfirmation::Execute {
|
||||
command: command.clone(),
|
||||
root_command: command.clone(),
|
||||
description: description.clone(),
|
||||
},
|
||||
Self::ExitPlanMode(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {}", params.plan)
|
||||
} else {
|
||||
params.plan.clone()
|
||||
},
|
||||
},
|
||||
Self::Task(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {}", params.description)
|
||||
} else {
|
||||
params.description.clone()
|
||||
},
|
||||
},
|
||||
Self::Ls(Some(LsToolParams { path, .. }))
|
||||
| Self::ReadFile(Some(ReadToolParams { abs_path: path, .. })) => {
|
||||
let path = path.display();
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {path}")
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
|
||||
let path = notebook_path.display();
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {path}")
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::Glob(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::Grep(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::WebSearch(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::TodoWrite(Some(params)) => {
|
||||
let params = params.todos.iter().map(|todo| &todo.content).join(", ");
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::Terminal(None)
|
||||
| Self::Task(None)
|
||||
| Self::NotebookRead(None)
|
||||
| Self::ExitPlanMode(None)
|
||||
| Self::Ls(None)
|
||||
| Self::Glob(None)
|
||||
| Self::Grep(None)
|
||||
| Self::ReadFile(None)
|
||||
| Self::WebSearch(None)
|
||||
| Self::TodoWrite(None)
|
||||
| Self::Other { .. } => acp::ToolCallConfirmation::Other {
|
||||
description: description.unwrap_or("".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
|
||||
match &self {
|
||||
Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![ToolCallLocation {
|
||||
path: abs_path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: file_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::Write(Some(WriteToolParams { file_path, .. })) => vec![ToolCallLocation {
|
||||
path: file_path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::ReadFile(Some(ReadToolParams {
|
||||
abs_path, offset, ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: abs_path.clone(),
|
||||
line: *offset,
|
||||
}],
|
||||
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: notebook_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: notebook_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::Glob(Some(GlobToolParams {
|
||||
path: Some(path), ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::Ls(Some(LsToolParams { path, .. })) => vec![ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::Grep(Some(GrepToolParams {
|
||||
path: Some(path), ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: PathBuf::from(path),
|
||||
line: None,
|
||||
}],
|
||||
Self::Task(_)
|
||||
| Self::NotebookRead(None)
|
||||
| Self::NotebookEdit(None)
|
||||
| Self::Edit(None)
|
||||
| Self::MultiEdit(None)
|
||||
| Self::Write(None)
|
||||
| Self::ReadFile(None)
|
||||
| Self::Ls(None)
|
||||
| Self::Glob(_)
|
||||
| Self::Grep(_)
|
||||
| Self::Terminal(_)
|
||||
| Self::WebFetch(_)
|
||||
| Self::WebSearch(_)
|
||||
| Self::TodoWrite(_)
|
||||
| Self::ExitPlanMode(_)
|
||||
| Self::Other { .. } => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_acp(&self) -> PushToolCallParams {
|
||||
PushToolCallParams {
|
||||
label: self.label(),
|
||||
content: self.content(),
|
||||
icon: self.icon(),
|
||||
locations: self.locations(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct EditToolParams {
|
||||
/// The absolute path to the file to read.
|
||||
pub abs_path: PathBuf,
|
||||
/// The old text to replace (must be unique in the file)
|
||||
pub old_text: String,
|
||||
/// The new text.
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditToolResponse;
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct ReadToolParams {
|
||||
/// The absolute path to the file to read.
|
||||
pub abs_path: PathBuf,
|
||||
/// Which line to start reading from. Omit to start from the beginning.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
/// How many lines to read. Omit for the whole file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadToolResponse {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WriteToolParams {
|
||||
/// Absolute path for new file
|
||||
pub file_path: PathBuf,
|
||||
/// File content
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct BashToolParams {
|
||||
/// Shell command to execute
|
||||
pub command: String,
|
||||
/// 5-10 word description of what command does
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Timeout in ms (max 600000ms/10min, default 120000ms)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct GlobToolParams {
|
||||
/// Glob pattern like **/*.js or src/**/*.ts
|
||||
pub pattern: String,
|
||||
/// Directory to search in (omit for current directory)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GlobToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(path) = &self.path {
|
||||
write!(f, "{}", path.display())?;
|
||||
}
|
||||
write!(f, "{}", self.pattern)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct LsToolParams {
|
||||
/// Absolute path to directory
|
||||
pub path: PathBuf,
|
||||
/// Array of glob patterns to ignore
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ignore: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct GrepToolParams {
|
||||
/// Regex pattern to search for
|
||||
pub pattern: String,
|
||||
/// File/directory to search (defaults to current directory)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// "content" (shows lines), "files_with_matches" (default), "count"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_mode: Option<GrepOutputMode>,
|
||||
/// Filter files with glob pattern like "*.js"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub glob: Option<String>,
|
||||
/// File type filter like "js", "py", "rust"
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub file_type: Option<String>,
|
||||
/// Case insensitive search
|
||||
#[serde(rename = "-i", default, skip_serializing_if = "is_false")]
|
||||
pub case_insensitive: bool,
|
||||
/// Show line numbers (content mode only)
|
||||
#[serde(rename = "-n", default, skip_serializing_if = "is_false")]
|
||||
pub line_numbers: bool,
|
||||
/// Lines after match (content mode only)
|
||||
#[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
|
||||
pub after_context: Option<u32>,
|
||||
/// Lines before match (content mode only)
|
||||
#[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
|
||||
pub before_context: Option<u32>,
|
||||
/// Lines before and after match (content mode only)
|
||||
#[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u32>,
|
||||
/// Enable multiline/cross-line matching
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub multiline: bool,
|
||||
/// Limit output to first N results
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub head_limit: Option<u32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GrepToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "grep")?;
|
||||
|
||||
// Boolean flags
|
||||
if self.case_insensitive {
|
||||
write!(f, " -i")?;
|
||||
}
|
||||
if self.line_numbers {
|
||||
write!(f, " -n")?;
|
||||
}
|
||||
|
||||
// Context options
|
||||
if let Some(after) = self.after_context {
|
||||
write!(f, " -A {}", after)?;
|
||||
}
|
||||
if let Some(before) = self.before_context {
|
||||
write!(f, " -B {}", before)?;
|
||||
}
|
||||
if let Some(context) = self.context {
|
||||
write!(f, " -C {}", context)?;
|
||||
}
|
||||
|
||||
// Output mode
|
||||
if let Some(mode) = &self.output_mode {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
|
||||
GrepOutputMode::Count => write!(f, " -c")?,
|
||||
GrepOutputMode::Content => {} // Default mode
|
||||
}
|
||||
}
|
||||
|
||||
// Head limit
|
||||
if let Some(limit) = self.head_limit {
|
||||
write!(f, " | head -{}", limit)?;
|
||||
}
|
||||
|
||||
// Glob pattern
|
||||
if let Some(glob) = &self.glob {
|
||||
write!(f, " --include=\"{}\"", glob)?;
|
||||
}
|
||||
|
||||
// File type
|
||||
if let Some(file_type) = &self.file_type {
|
||||
write!(f, " --type={}", file_type)?;
|
||||
}
|
||||
|
||||
// Multiline
|
||||
if self.multiline {
|
||||
write!(f, " -P")?; // Perl-compatible regex for multiline
|
||||
}
|
||||
|
||||
// Pattern (escaped if contains special characters)
|
||||
write!(f, " \"{}\"", self.pattern)?;
|
||||
|
||||
// Path
|
||||
if let Some(path) = &self.path {
|
||||
write!(f, " {}", path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TodoPriority {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl Into<acp::PlanEntryPriority> for TodoPriority {
|
||||
fn into(self) -> acp::PlanEntryPriority {
|
||||
match self {
|
||||
TodoPriority::High => acp::PlanEntryPriority::High,
|
||||
TodoPriority::Medium => acp::PlanEntryPriority::Medium,
|
||||
TodoPriority::Low => acp::PlanEntryPriority::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TodoStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl Into<acp::PlanEntryStatus> for TodoStatus {
|
||||
fn into(self) -> acp::PlanEntryStatus {
|
||||
match self {
|
||||
TodoStatus::Pending => acp::PlanEntryStatus::Pending,
|
||||
TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
|
||||
TodoStatus::Completed => acp::PlanEntryStatus::Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
pub struct Todo {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// Task description
|
||||
pub content: String,
|
||||
/// Priority level of the todo
|
||||
pub priority: TodoPriority,
|
||||
/// Current status of the todo
|
||||
pub status: TodoStatus,
|
||||
}
|
||||
|
||||
impl Into<acp::PlanEntry> for Todo {
|
||||
fn into(self) -> acp::PlanEntry {
|
||||
acp::PlanEntry {
|
||||
content: self.content,
|
||||
priority: self.priority.into(),
|
||||
status: self.status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct TodoWriteToolParams {
|
||||
pub todos: Vec<Todo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct ExitPlanModeToolParams {
|
||||
/// Implementation plan in markdown format
|
||||
pub plan: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct TaskToolParams {
|
||||
/// Short 3-5 word description of task
|
||||
pub description: String,
|
||||
/// Detailed task for agent to perform
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct NotebookReadToolParams {
|
||||
/// Absolute path to .ipynb file
|
||||
pub notebook_path: PathBuf,
|
||||
/// Specific cell ID to read
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CellType {
|
||||
Code,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditMode {
|
||||
Replace,
|
||||
Insert,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct NotebookEditToolParams {
|
||||
/// Absolute path to .ipynb file
|
||||
pub notebook_path: PathBuf,
|
||||
/// New cell content
|
||||
pub new_source: String,
|
||||
/// Cell ID to edit
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_id: Option<String>,
|
||||
/// Type of cell (code or markdown)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_type: Option<CellType>,
|
||||
/// Edit operation mode
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edit_mode: Option<EditMode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
pub struct MultiEditItem {
|
||||
/// The text to search for and replace
|
||||
pub old_string: String,
|
||||
/// The replacement text
|
||||
pub new_string: String,
|
||||
/// Whether to replace all occurrences or just the first
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct MultiEditToolParams {
|
||||
/// Absolute path to file
|
||||
pub file_path: PathBuf,
|
||||
/// List of edits to apply
|
||||
pub edits: Vec<MultiEditItem>,
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrepOutputMode {
|
||||
Content,
|
||||
FilesWithMatches,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WebFetchToolParams {
|
||||
/// Valid URL to fetch
|
||||
#[serde(rename = "url")]
|
||||
pub url: String,
|
||||
/// What to extract from content
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WebSearchToolParams {
|
||||
/// Search query (min 2 chars)
|
||||
pub query: String,
|
||||
/// Only include these domains
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Exclude these domains
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub blocked_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WebSearchToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "\"{}\"", self.query)?;
|
||||
|
||||
if !self.allowed_domains.is_empty() {
|
||||
write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
|
||||
}
|
||||
|
||||
if !self.blocked_domains.is_empty() {
|
||||
write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
412
crates/agent_servers/src/e2e_tests.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
use std::{path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings};
|
||||
use acp_thread::{
|
||||
AcpThread, AgentThreadEntry, ToolCall, ToolCallConfirmation, ToolCallContent, ToolCallStatus,
|
||||
};
|
||||
use agentic_coding_protocol as acp;
|
||||
use futures::{FutureExt, StreamExt, channel::mpsc, select};
|
||||
use gpui::{Entity, TestAppContext};
|
||||
use indoc::indoc;
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use util::path;
|
||||
|
||||
pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.entries().len(), 2);
|
||||
assert!(matches!(
|
||||
thread.entries()[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
thread.entries()[1],
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let _fs = init_test(cx).await;
|
||||
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tempdir.path().join("foo.rs"),
|
||||
indoc! {"
|
||||
fn main() {
|
||||
println!(\"Hello, world!\");
|
||||
}
|
||||
"},
|
||||
)
|
||||
.expect("failed to write file");
|
||||
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(
|
||||
acp::SendUserMessageParams {
|
||||
chunks: vec![
|
||||
acp::UserMessageChunk::Text {
|
||||
text: "Read the file ".into(),
|
||||
},
|
||||
acp::UserMessageChunk::Path {
|
||||
path: Path::new("foo.rs").into(),
|
||||
},
|
||||
acp::UserMessageChunk::Text {
|
||||
text: " and tell me what the content of the println! is".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries().len(), 3);
|
||||
assert!(matches!(
|
||||
thread.entries()[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(thread.entries()[1], AgentThreadEntry::ToolCall(_)));
|
||||
let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries()[2] else {
|
||||
panic!("Expected AssistantMessage")
|
||||
};
|
||||
assert!(
|
||||
assistant_message.to_markdown(cx).contains("Hello, world!"),
|
||||
"unexpected assistant message: {:?}",
|
||||
assistant_message.to_markdown(cx)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
fs.insert_tree(
|
||||
path!("/private/tmp"),
|
||||
json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(
|
||||
"Read the '/private/tmp/foo' file and tell me what you see.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
assert!(thread.entries().iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}));
|
||||
assert!(
|
||||
thread
|
||||
.entries()
|
||||
.iter()
|
||||
.any(|entry| { matches!(entry, AgentThreadEntry::AssistantMessage(_)) })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_tool_call_with_confirmation(
|
||||
server: impl AgentServer + 'static,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let fs = init_test(cx).await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(
|
||||
r#"Run `touch hello.txt && echo "Hello, world!" | tee hello.txt`"#,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
run_until_first_tool_call(
|
||||
&thread,
|
||||
|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::WaitingForConfirmation { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let tool_call_id = thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
|
||||
.unwrap()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
assert!(root_command.contains("touch"));
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
|
||||
|
||||
assert!(thread.entries().iter().any(|entry| matches!(
|
||||
entry,
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
)));
|
||||
});
|
||||
|
||||
full_turn.await.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
content: Some(ToolCallContent::Markdown { markdown }),
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
}) = thread
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
|
||||
.unwrap()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
markdown.read_with(cx, |md, _cx| {
|
||||
assert!(
|
||||
md.source().contains("Hello"),
|
||||
r#"Expected '{}' to contain "Hello""#,
|
||||
md.source()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(
|
||||
r#"Run `touch hello.txt && echo "Hello, world!" >> hello.txt`"#,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let first_tool_call_ix = run_until_first_tool_call(
|
||||
&thread,
|
||||
|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::WaitingForConfirmation { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!("{:?}", thread.entries()[1]);
|
||||
};
|
||||
|
||||
assert!(root_command.contains("touch"));
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.cancel(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
full_turn.await.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Canceled,
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(matches!(
|
||||
&thread.entries().last().unwrap(),
|
||||
AgentThreadEntry::AssistantMessage(..),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! common_e2e_tests {
|
||||
($server:expr) => {
|
||||
mod common_e2e {
|
||||
use super::*;
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn basic(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_basic($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn path_mentions(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_path_mentions($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn tool_call(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_tool_call($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn tool_call_with_confirmation(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_tool_call_with_confirmation($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn cancel(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_cancel($server, cx).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
|
||||
env_logger::try_init().ok();
|
||||
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
Project::init_settings(cx);
|
||||
language::init(cx);
|
||||
crate::settings::init(cx);
|
||||
|
||||
crate::AllAgentServersSettings::override_global(
|
||||
AllAgentServersSettings {
|
||||
claude: Some(AgentServerSettings {
|
||||
command: crate::claude::tests::local_command(),
|
||||
}),
|
||||
gemini: Some(AgentServerSettings {
|
||||
command: crate::gemini::tests::local_command(),
|
||||
}),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
FakeFs::new(cx.executor())
|
||||
}
|
||||
|
||||
pub async fn new_test_thread(
|
||||
server: impl AgentServer + 'static,
|
||||
project: Entity<Project>,
|
||||
current_dir: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<AcpThread> {
|
||||
let thread = cx
|
||||
.update(|cx| server.new_thread(current_dir.as_ref(), &project, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread
|
||||
.update(cx, |thread, _| thread.initialize())
|
||||
.await
|
||||
.unwrap();
|
||||
thread
|
||||
}
|
||||
|
||||
pub async fn run_until_first_tool_call(
|
||||
thread: &Entity<AcpThread>,
|
||||
wait_until: impl Fn(&AgentThreadEntry) -> bool + 'static,
|
||||
cx: &mut TestAppContext,
|
||||
) -> usize {
|
||||
let (mut tx, mut rx) = mpsc::channel::<usize>(1);
|
||||
|
||||
let subscription = cx.update(|cx| {
|
||||
cx.subscribe(thread, move |thread, _, cx| {
|
||||
for (ix, entry) in thread.read(cx).entries().iter().enumerate() {
|
||||
if wait_until(entry) {
|
||||
return tx.try_send(ix).unwrap();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
select! {
|
||||
// We have to use a smol timer here because
|
||||
// cx.background_executor().timer isn't real in the test context
|
||||
_ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))) => {
|
||||
panic!("Timeout waiting for tool call")
|
||||
}
|
||||
ix = rx.next().fuse() => {
|
||||
drop(subscription);
|
||||
ix.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
123
crates/agent_servers/src/gemini.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use crate::stdio_agent_server::StdioAgentServer;
|
||||
use crate::{AgentServerCommand, AgentServerVersion};
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Entity};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
|
||||
use crate::AllAgentServersSettings;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Gemini;
|
||||
|
||||
const ACP_ARG: &str = "--experimental-acp";
|
||||
|
||||
impl StdioAgentServer for Gemini {
|
||||
fn name(&self) -> &'static str {
|
||||
"Gemini"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
"Welcome to Gemini"
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
"Ask questions, edit files, run commands.\nBe specific for the best results."
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiGemini
|
||||
}
|
||||
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).gemini.clone()
|
||||
})?;
|
||||
|
||||
if let Some(command) =
|
||||
AgentServerCommand::resolve("gemini", &[ACP_ARG], settings, &project, cx).await
|
||||
{
|
||||
return Ok(command);
|
||||
};
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("gemini not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("gemini");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/gemini");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?;
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
|
||||
|
||||
if supported {
|
||||
Ok(AgentServerVersion::Supported)
|
||||
} else {
|
||||
Ok(AgentServerVersion::Unsupported {
|
||||
error_message: format!(
|
||||
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
|
||||
current_version
|
||||
).into(),
|
||||
upgrade_message: "Upgrade Gemini to Latest".into(),
|
||||
upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::AgentServerCommand;
|
||||
use std::path::Path;
|
||||
|
||||
crate::common_e2e_tests!(Gemini);
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../gemini-cli/packages/cli")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
AgentServerCommand {
|
||||
path: "node".into(),
|
||||
args: vec![cli_path, ACP_ARG.into()],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/agent_servers/src/settings.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::AgentServerCommand;
|
||||
use anyhow::Result;
|
||||
use gpui::App;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AllAgentServersSettings::register(cx);
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AllAgentServersSettings {
|
||||
pub gemini: Option<AgentServerSettings>,
|
||||
pub claude: Option<AgentServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AgentServerSettings {
|
||||
#[serde(flatten)]
|
||||
pub command: AgentServerCommand,
|
||||
}
|
||||
|
||||
impl settings::Settings for AllAgentServersSettings {
|
||||
const KEY: Option<&'static str> = Some("agent_servers");
|
||||
|
||||
type FileContent = Self;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
|
||||
if gemini.is_some() {
|
||||
settings.gemini = gemini.clone();
|
||||
}
|
||||
if claude.is_some() {
|
||||
settings.claude = claude.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
119
crates/agent_servers/src/stdio_agent_server.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use crate::{AgentServer, AgentServerCommand, AgentServerVersion};
|
||||
use acp_thread::{AcpClientDelegate, AcpThread, LoadError};
|
||||
use agentic_coding_protocol as acp;
|
||||
use anyhow::{Result, anyhow};
|
||||
use gpui::{App, AsyncApp, Entity, Task, prelude::*};
|
||||
use project::Project;
|
||||
use std::path::Path;
|
||||
use util::ResultExt;
|
||||
|
||||
pub trait StdioAgentServer: Send + Clone {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> &'static str;
|
||||
fn empty_state_headline(&self) -> &'static str;
|
||||
fn empty_state_message(&self) -> &'static str;
|
||||
fn supports_always_allow(&self) -> bool;
|
||||
|
||||
fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> impl Future<Output = Result<AgentServerCommand>>;
|
||||
|
||||
fn version(
|
||||
&self,
|
||||
command: &AgentServerCommand,
|
||||
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
|
||||
}
|
||||
|
||||
impl<T: StdioAgentServer + 'static> AgentServer for T {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
self.empty_state_headline()
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
self.empty_state_message()
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
self.logo()
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
self.supports_always_allow()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let project = project.clone();
|
||||
let this = self.clone();
|
||||
let title = self.name().into();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let command = this.command(&project, cx).await?;
|
||||
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
cx.new(|cx| {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| foreground_executor.spawn(fut).detach(),
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
});
|
||||
|
||||
let child_status = cx.background_spawn(async move {
|
||||
let result = match child.status().await {
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
Ok(result) if result.success() => Ok(()),
|
||||
Ok(result) => {
|
||||
if let Some(AgentServerVersion::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command,
|
||||
}) = this.version(&command).await.log_err()
|
||||
{
|
||||
Err(anyhow!(LoadError::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(io_task);
|
||||
result
|
||||
});
|
||||
|
||||
AcpThread::new(connection, title, Some(child_status), project.clone(), cx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ pub struct AgentSettings {
|
||||
pub enable_feedback: bool,
|
||||
pub expand_edit_card: bool,
|
||||
pub expand_terminal_card: bool,
|
||||
pub use_modifier_to_send: bool,
|
||||
}
|
||||
|
||||
impl AgentSettings {
|
||||
@@ -174,6 +175,10 @@ impl AgentSettingsContent {
|
||||
self.single_file_review = Some(allow);
|
||||
}
|
||||
|
||||
pub fn set_use_modifier_to_send(&mut self, always_use: bool) {
|
||||
self.use_modifier_to_send = Some(always_use);
|
||||
}
|
||||
|
||||
pub fn set_profile(&mut self, profile_id: AgentProfileId) {
|
||||
self.default_profile = Some(profile_id);
|
||||
}
|
||||
@@ -301,6 +306,10 @@ pub struct AgentSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
expand_terminal_card: Option<bool>,
|
||||
/// Whether to always use cmd-enter (or ctrl-enter on Linux) to send messages in the agent panel.
|
||||
///
|
||||
/// Default: false
|
||||
use_modifier_to_send: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
|
||||
@@ -456,6 +465,10 @@ impl Settings for AgentSettings {
|
||||
&mut settings.expand_terminal_card,
|
||||
value.expand_terminal_card,
|
||||
);
|
||||
merge(
|
||||
&mut settings.use_modifier_to_send,
|
||||
value.use_modifier_to_send,
|
||||
);
|
||||
|
||||
settings
|
||||
.model_parameters
|
||||
|
||||
@@ -16,11 +16,12 @@ doctest = false
|
||||
test-support = ["gpui/test-support", "language/test-support"]
|
||||
|
||||
[dependencies]
|
||||
acp.workspace = true
|
||||
acp_thread.workspace = true
|
||||
agent.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
agent_settings.workspace = true
|
||||
agent_servers.workspace = true
|
||||
ai_onboarding.workspace = true
|
||||
anyhow.workspace = true
|
||||
assistant_context.workspace = true
|
||||
assistant_slash_command.workspace = true
|
||||
|
||||
@@ -996,30 +996,57 @@ impl ActiveThread {
|
||||
| ThreadEvent::SummaryChanged => {
|
||||
self.save_thread(cx);
|
||||
}
|
||||
ThreadEvent::Stopped(reason) => match reason {
|
||||
Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
|
||||
let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(
|
||||
if used_tools {
|
||||
"Finished running tools"
|
||||
} else {
|
||||
"New message"
|
||||
},
|
||||
IconName::ZedAssistant,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
ThreadEvent::Stopped(reason) => {
|
||||
match reason {
|
||||
Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
|
||||
let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
|
||||
self.notify_with_sound(
|
||||
if used_tools {
|
||||
"Finished running tools"
|
||||
} else {
|
||||
"New message"
|
||||
},
|
||||
IconName::ZedAssistant,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Ok(StopReason::ToolUse) => {
|
||||
// Don't notify for intermediate tool use
|
||||
}
|
||||
Ok(StopReason::Refusal) => {
|
||||
self.notify_with_sound(
|
||||
"Language model refused to respond",
|
||||
IconName::Warning,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify_with_sound(
|
||||
"Agent stopped due to an error",
|
||||
IconName::Warning,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let error_message = error
|
||||
.chain()
|
||||
.map(|err| err.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.last_error = Some(ThreadError::Message {
|
||||
header: "Error".into(),
|
||||
message: error_message.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
ThreadEvent::ToolConfirmationNeeded => {
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx);
|
||||
self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
|
||||
}
|
||||
ThreadEvent::ToolUseLimitReached => {
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(
|
||||
self.notify_with_sound(
|
||||
"Consecutive tool use limit reached.",
|
||||
IconName::Warning,
|
||||
window,
|
||||
@@ -1162,9 +1189,6 @@ impl ActiveThread {
|
||||
self.save_thread(cx);
|
||||
cx.notify();
|
||||
}
|
||||
ThreadEvent::RetriesFailed { message } => {
|
||||
self.show_notification(message, ui::IconName::Warning, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,6 +1243,17 @@ impl ActiveThread {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_with_sound(
|
||||
&mut self,
|
||||
caption: impl Into<SharedString>,
|
||||
icon: IconName,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ActiveThread>,
|
||||
) {
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(caption, icon, window, cx);
|
||||
}
|
||||
|
||||
fn pop_up(
|
||||
&mut self,
|
||||
icon: IconName,
|
||||
@@ -3167,7 +3202,10 @@ impl ActiveThread {
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.rounded_b_lg()
|
||||
.child(
|
||||
LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
|
||||
div()
|
||||
.min_w(rems_from_px(145.))
|
||||
.child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
|
||||
)
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -3212,7 +3250,6 @@ impl ActiveThread {
|
||||
},
|
||||
))
|
||||
})
|
||||
.child(ui::Divider::vertical())
|
||||
.child({
|
||||
let tool_id = tool_use.id.clone();
|
||||
Button::new("allow-tool-action", "Allow")
|
||||
@@ -3687,8 +3724,11 @@ pub(crate) fn open_context(
|
||||
|
||||
AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_thread(thread_context.thread.clone(), window, cx);
|
||||
let thread = thread_context.thread.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_thread(thread, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -3696,8 +3736,11 @@ pub(crate) fn open_context(
|
||||
AgentContextHandle::TextThread(text_thread_context) => {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_prompt_editor(text_thread_context.context.clone(), window, cx)
|
||||
let context = text_thread_context.context.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_prompt_editor(context, window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,9 +24,10 @@ use project::{
|
||||
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
|
||||
project_settings::{ContextServerSettings, ProjectSettings},
|
||||
};
|
||||
use proto::Plan;
|
||||
use settings::{Settings, update_settings_file};
|
||||
use ui::{
|
||||
ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
|
||||
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
|
||||
Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
@@ -171,6 +172,15 @@ impl AgentConfiguration {
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID;
|
||||
let current_plan = if is_zed_provider {
|
||||
self.workspace
|
||||
.upgrade()
|
||||
.and_then(|workspace| workspace.read(cx).user_store().read(cx).current_plan())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.when(is_expanded, |this| this.mb_2())
|
||||
.child(
|
||||
@@ -208,14 +218,31 @@ impl AgentConfiguration {
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(provider_name.clone()).size(LabelSize::Large))
|
||||
.when(
|
||||
provider.is_authenticated(cx) && !is_expanded,
|
||||
|parent| {
|
||||
parent.child(
|
||||
Icon::new(IconName::Check).color(Color::Success),
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new(provider_name.clone())
|
||||
.size(LabelSize::Large),
|
||||
)
|
||||
},
|
||||
.map(|this| {
|
||||
if is_zed_provider {
|
||||
this.gap_2().child(
|
||||
self.render_zed_plan_info(current_plan, cx),
|
||||
)
|
||||
} else {
|
||||
this.when(
|
||||
provider.is_authenticated(cx)
|
||||
&& !is_expanded,
|
||||
|parent| {
|
||||
parent.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Success),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -431,6 +458,37 @@ impl AgentConfiguration {
|
||||
.child(self.render_sound_notification(cx))
|
||||
}
|
||||
|
||||
fn render_zed_plan_info(&self, plan: Option<Plan>, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(plan) = plan {
|
||||
let free_chip_bg = cx
|
||||
.theme()
|
||||
.colors()
|
||||
.editor_background
|
||||
.opacity(0.5)
|
||||
.blend(cx.theme().colors().text_accent.opacity(0.05));
|
||||
|
||||
let pro_chip_bg = cx
|
||||
.theme()
|
||||
.colors()
|
||||
.editor_background
|
||||
.opacity(0.5)
|
||||
.blend(cx.theme().colors().text_accent.opacity(0.2));
|
||||
|
||||
let (plan_name, label_color, bg_color) = match plan {
|
||||
Plan::Free => ("Free", Color::Default, free_chip_bg),
|
||||
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
|
||||
};
|
||||
|
||||
Chip::new(plan_name.to_string())
|
||||
.bg_color(bg_color)
|
||||
.label_color(label_color)
|
||||
.into_any_element()
|
||||
} else {
|
||||
div().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_context_servers_section(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
@@ -491,6 +549,7 @@ impl AgentConfiguration {
|
||||
category_filter: Some(
|
||||
ExtensionCategoryFilter::ContextServers,
|
||||
),
|
||||
id: None,
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -188,7 +189,7 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
|
||||
}
|
||||
None => (
|
||||
"some-mcp-server".to_string(),
|
||||
"".to_string(),
|
||||
PathBuf::new(),
|
||||
"[]".to_string(),
|
||||
"{}".to_string(),
|
||||
),
|
||||
@@ -199,13 +200,14 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
|
||||
/// The name of your MCP server
|
||||
"{name}": {{
|
||||
/// The command which runs the MCP server
|
||||
"command": "{command}",
|
||||
"command": "{}",
|
||||
/// The arguments to pass to the MCP server
|
||||
"args": {args},
|
||||
/// The environment variables to set
|
||||
"env": {env}
|
||||
}}
|
||||
}}"#
|
||||
}}"#,
|
||||
command.display()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
|
||||
use acp::{AcpThread, AcpThreadEvent};
|
||||
use acp_thread::{AcpThread, AcpThreadEvent};
|
||||
use agent::{Thread, ThreadEvent, ThreadSummary};
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::Result;
|
||||
@@ -81,7 +81,7 @@ impl AgentDiffThread {
|
||||
match self {
|
||||
AgentDiffThread::Native(thread) => thread.read(cx).is_generating(),
|
||||
AgentDiffThread::AcpThread(thread) => {
|
||||
thread.read(cx).status() == acp::ThreadStatus::Generating
|
||||
thread.read(cx).status() == acp_thread::ThreadStatus::Generating
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1488,7 +1488,6 @@ impl AgentDiff {
|
||||
| ThreadEvent::ToolConfirmationNeeded
|
||||
| ThreadEvent::ToolUseLimitReached
|
||||
| ThreadEvent::CancelEditing
|
||||
| ThreadEvent::RetriesFailed { .. }
|
||||
| ThreadEvent::ProfileChanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::{
|
||||
ModelUsageContext,
|
||||
language_model_selector::{
|
||||
LanguageModelSelector, ToggleModelSelector, language_model_selector,
|
||||
},
|
||||
language_model_selector::{LanguageModelSelector, language_model_selector},
|
||||
};
|
||||
use agent_settings::AgentSettings;
|
||||
use fs::Fs;
|
||||
@@ -12,6 +10,7 @@ use picker::popover_menu::PickerPopoverMenu;
|
||||
use settings::update_settings_file;
|
||||
use std::sync::Arc;
|
||||
use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
pub struct AgentModelSelector {
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
@@ -96,22 +95,18 @@ impl Render for AgentModelSelector {
|
||||
let model_name = model
|
||||
.as_ref()
|
||||
.map(|model| model.model.name().0)
|
||||
.unwrap_or_else(|| SharedString::from("No model selected"));
|
||||
let provider_icon = model
|
||||
.as_ref()
|
||||
.map(|model| model.provider.icon())
|
||||
.unwrap_or_else(|| IconName::Ai);
|
||||
.unwrap_or_else(|| SharedString::from("Select a Model"));
|
||||
|
||||
let provider_icon = model.as_ref().map(|model| model.provider.icon());
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.child(
|
||||
Icon::new(provider_icon)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
.when_some(provider_icon, |this, icon| {
|
||||
this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
|
||||
})
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.color(Color::Muted)
|
||||
|
||||
@@ -25,6 +25,7 @@ mod thread_history;
|
||||
mod tool_compatibility;
|
||||
mod ui;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent::{Thread, ThreadId};
|
||||
@@ -40,7 +41,7 @@ use language_model::{
|
||||
};
|
||||
use prompt_store::PromptBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings as _, SettingsStore};
|
||||
|
||||
pub use crate::active_thread::ActiveThread;
|
||||
@@ -57,8 +58,6 @@ actions!(
|
||||
[
|
||||
/// Creates a new text-based conversation thread.
|
||||
NewTextThread,
|
||||
/// Creates a new external agent conversation thread.
|
||||
NewAcpThread,
|
||||
/// Toggles the context picker interface for adding files, symbols, or other context.
|
||||
ToggleContextPicker,
|
||||
/// Toggles the navigation menu for switching between threads and views.
|
||||
@@ -133,6 +132,32 @@ pub struct NewThread {
|
||||
from_thread_id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
/// Creates a new external agent conversation thread.
|
||||
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewExternalAgentThread {
|
||||
/// Which agent to use for the conversation.
|
||||
agent: Option<ExternalAgent>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ExternalAgent {
|
||||
#[default]
|
||||
Gemini,
|
||||
ClaudeCode,
|
||||
}
|
||||
|
||||
impl ExternalAgent {
|
||||
pub fn server(&self) -> Rc<dyn agent_servers::AgentServer> {
|
||||
match self {
|
||||
ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
|
||||
ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the profile management interface for configuring agent tools and settings.
|
||||
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
@@ -172,6 +197,11 @@ impl ModelUsageContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_settings(cx: &mut App) {
|
||||
AgentSettings::register(cx);
|
||||
SlashCommandSettings::register(cx);
|
||||
}
|
||||
|
||||
/// Initializes the `agent` crate.
|
||||
pub fn init(
|
||||
fs: Arc<dyn Fs>,
|
||||
@@ -181,8 +211,7 @@ pub fn init(
|
||||
is_eval: bool,
|
||||
cx: &mut App,
|
||||
) {
|
||||
AgentSettings::register(cx);
|
||||
SlashCommandSettings::register(cx);
|
||||
init_settings(cx);
|
||||
|
||||
assistant_context::init(client.clone(), cx);
|
||||
rules_library::init(cx);
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::language_model_selector::ToggleModelSelector;
|
||||
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
@@ -38,6 +37,7 @@ use ui::{
|
||||
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
|
||||
};
|
||||
use workspace::Workspace;
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
pub struct PromptEditor<T> {
|
||||
pub editor: Entity<Editor>,
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::{cmp::Reverse, sync::Arc};
|
||||
use collections::{HashSet, IndexMap};
|
||||
use feature_flags::ZedProFeatureFlag;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
|
||||
};
|
||||
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
|
||||
use language_model::{
|
||||
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
|
||||
LanguageModelRegistry,
|
||||
@@ -15,15 +13,6 @@ use picker::{Picker, PickerDelegate};
|
||||
use proto::Plan;
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
actions!(
|
||||
agent,
|
||||
[
|
||||
/// Toggles the language model selector dropdown.
|
||||
#[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
|
||||
ToggleModelSelector
|
||||
]
|
||||
);
|
||||
|
||||
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agent_diff::AgentDiffThread;
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::language_model_selector::ToggleModelSelector;
|
||||
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
||||
use crate::ui::{
|
||||
MaxModeTooltip,
|
||||
@@ -15,6 +14,7 @@ use agent::{
|
||||
context_store::ContextStoreEvent,
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
use ai_onboarding::ApiKeysWithProviders;
|
||||
use buffer_diff::BufferDiff;
|
||||
use client::UserStore;
|
||||
use collections::{HashMap, HashSet};
|
||||
@@ -29,12 +29,13 @@ use fs::Fs;
|
||||
use futures::future::Shared;
|
||||
use futures::{FutureExt as _, future};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle,
|
||||
WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, KeyContext, Subscription, Task,
|
||||
TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language, Point};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID,
|
||||
ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent,
|
||||
ZED_CLOUD_PROVIDER_ID,
|
||||
};
|
||||
use multi_buffer;
|
||||
use project::Project;
|
||||
@@ -49,6 +50,7 @@ use ui::{
|
||||
use util::ResultExt as _;
|
||||
use workspace::{CollaboratorId, Workspace};
|
||||
use zed_actions::agent::Chat;
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
use zed_llm_client::CompletionIntent;
|
||||
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
|
||||
@@ -65,6 +67,9 @@ use agent::{
|
||||
thread_store::{TextThreadStore, ThreadStore},
|
||||
};
|
||||
|
||||
pub const MIN_EDITOR_LINES: usize = 4;
|
||||
pub const MAX_EDITOR_LINES: usize = 8;
|
||||
|
||||
#[derive(RegisterComponent)]
|
||||
pub struct MessageEditor {
|
||||
thread: Entity<Thread>,
|
||||
@@ -88,9 +93,6 @@ pub struct MessageEditor {
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
const MIN_EDITOR_LINES: usize = 4;
|
||||
const MAX_EDITOR_LINES: usize = 8;
|
||||
|
||||
pub(crate) fn create_editor(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
context_store: WeakEntity<ContextStore>,
|
||||
@@ -132,6 +134,7 @@ pub(crate) fn create_editor(
|
||||
placement: Some(ContextMenuPlacement::Above),
|
||||
});
|
||||
editor.register_addon(ContextCreasesAddon::new());
|
||||
editor.register_addon(MessageEditorAddon::new());
|
||||
editor
|
||||
});
|
||||
|
||||
@@ -609,7 +612,11 @@ impl MessageEditor {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render_follow_toggle(
|
||||
&self,
|
||||
is_model_selected: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let following = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| {
|
||||
@@ -618,6 +625,7 @@ impl MessageEditor {
|
||||
.unwrap_or(false);
|
||||
|
||||
IconButton::new("follow-agent", IconName::Crosshair)
|
||||
.disabled(is_model_selected)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.toggle_state(following)
|
||||
@@ -705,11 +713,11 @@ impl MessageEditor {
|
||||
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
|
||||
)
|
||||
.capture_action(cx.listener(Self::paste))
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.bg(editor_bg_color)
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(editor_bg_color)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
@@ -786,7 +794,7 @@ impl MessageEditor {
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.child(self.render_follow_toggle(cx))
|
||||
.child(self.render_follow_toggle(is_model_selected, cx))
|
||||
.children(self.render_burn_mode_toggle(cx)),
|
||||
)
|
||||
.child(
|
||||
@@ -1489,6 +1497,31 @@ pub struct ContextCreasesAddon {
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
pub struct MessageEditorAddon {}
|
||||
|
||||
impl MessageEditorAddon {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Addon for MessageEditorAddon {
|
||||
fn to_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
|
||||
let settings = agent_settings::AgentSettings::get_global(cx);
|
||||
if settings.use_modifier_to_send {
|
||||
key_context.add("use_modifier_to_send");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Addon for ContextCreasesAddon {
|
||||
fn to_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
@@ -1624,9 +1657,34 @@ impl Render for MessageEditor {
|
||||
|
||||
let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
|
||||
|
||||
let in_pro_trial = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedProTrial)
|
||||
);
|
||||
|
||||
let pro_user = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedPro)
|
||||
);
|
||||
|
||||
let configured_providers: Vec<(IconName, SharedString)> =
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.map(|provider| (provider.icon(), provider.name().0.clone()))
|
||||
.collect();
|
||||
let has_existing_providers = configured_providers.len() > 0;
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.when(
|
||||
has_existing_providers && !in_pro_trial && !pro_user,
|
||||
|this| this.child(cx.new(ApiKeysWithProviders::new)),
|
||||
)
|
||||
.when(changed_buffers.len() > 0, |parent| {
|
||||
parent.child(self.render_edits_bar(&changed_buffers, window, cx))
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::{
|
||||
burn_mode_tooltip::BurnModeTooltip,
|
||||
language_model_selector::{
|
||||
LanguageModelSelector, ToggleModelSelector, language_model_selector,
|
||||
},
|
||||
language_model_selector::{LanguageModelSelector, language_model_selector},
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
use anyhow::Result;
|
||||
@@ -38,8 +36,7 @@ use language::{
|
||||
language_settings::{SoftWrap, all_language_settings},
|
||||
};
|
||||
use language_model::{
|
||||
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView,
|
||||
LanguageModelRegistry, Role,
|
||||
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
|
||||
};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use picker::{Picker, popover_menu::PickerPopoverMenu};
|
||||
@@ -74,6 +71,7 @@ use workspace::{
|
||||
pane,
|
||||
searchable::{SearchEvent, SearchableItem},
|
||||
};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
|
||||
use assistant_context::{
|
||||
@@ -1895,108 +1893,6 @@ impl TextThreadEditor {
|
||||
.update(cx, |context, cx| context.summarize(true, cx));
|
||||
}
|
||||
|
||||
fn render_notice(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
// This was previously gated behind the `zed-pro` feature flag. Since we
|
||||
// aren't planning to ship that right now, we're just hard-coding this
|
||||
// value to not show the nudge.
|
||||
let nudge = Some(false);
|
||||
|
||||
let model_registry = LanguageModelRegistry::read_global(cx);
|
||||
|
||||
if nudge.map_or(false, |value| value) {
|
||||
Some(
|
||||
h_flex()
|
||||
.p_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(Icon::new(IconName::ZedAssistant).color(Color::Accent))
|
||||
.child(Label::new("Zed AI is here! Get started by signing in →")),
|
||||
)
|
||||
.child(
|
||||
Button::new("sign-in", "Sign in")
|
||||
.size(ButtonSize::Compact)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
let client = this
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| workspace.client().clone())
|
||||
.log_err();
|
||||
|
||||
if let Some(client) = client {
|
||||
cx.spawn(async move |context_editor, cx| {
|
||||
match client.authenticate_and_connect(true, cx).await {
|
||||
util::ConnectionResult::Timeout => {
|
||||
log::error!("Authentication timeout")
|
||||
}
|
||||
util::ConnectionResult::ConnectionReset => {
|
||||
log::error!("Connection reset")
|
||||
}
|
||||
util::ConnectionResult::Result(r) => {
|
||||
if r.log_err().is_some() {
|
||||
context_editor
|
||||
.update(cx, |_, cx| cx.notify())
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
} else if let Some(configuration_error) =
|
||||
model_registry.configuration_error(model_registry.default_model(), cx)
|
||||
{
|
||||
Some(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
.child(Label::new(configuration_error.to_string())),
|
||||
)
|
||||
.child(
|
||||
Button::new("open-configuration", "Configure Providers")
|
||||
.size(ButtonSize::Compact)
|
||||
.icon(Some(IconName::SlidersVertical))
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_position(IconPosition::Start)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click({
|
||||
let focus_handle = self.focus_handle(cx).clone();
|
||||
move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(
|
||||
&zed_actions::agent::OpenConfiguration,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx).clone();
|
||||
|
||||
@@ -2128,12 +2024,13 @@ impl TextThreadEditor {
|
||||
.map(|default| default.model);
|
||||
let model_name = match active_model {
|
||||
Some(model) => model.name().0,
|
||||
None => SharedString::from("No model selected"),
|
||||
None => SharedString::from("Select Model"),
|
||||
};
|
||||
|
||||
let active_provider = LanguageModelRegistry::read_global(cx)
|
||||
.default_model()
|
||||
.map(|default| default.provider);
|
||||
|
||||
let provider_icon = match active_provider {
|
||||
Some(provider) => provider.icon(),
|
||||
None => IconName::Ai,
|
||||
@@ -2581,20 +2478,7 @@ impl EventEmitter<SearchEvent> for TextThreadEditor {}
|
||||
|
||||
impl Render for TextThreadEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let provider = LanguageModelRegistry::read_global(cx)
|
||||
.default_model()
|
||||
.map(|default| default.provider);
|
||||
|
||||
let accept_terms = if self.show_accept_terms {
|
||||
provider.as_ref().and_then(|provider| {
|
||||
provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let language_model_selector = self.language_model_selector_menu_handle.clone();
|
||||
let burn_mode_toggle = self.render_burn_mode_toggle(cx);
|
||||
|
||||
v_flex()
|
||||
.key_context("ContextEditor")
|
||||
@@ -2611,28 +2495,12 @@ impl Render for TextThreadEditor {
|
||||
language_model_selector.toggle(window, cx);
|
||||
})
|
||||
.size_full()
|
||||
.children(self.render_notice(cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_grow()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(self.editor.clone()),
|
||||
)
|
||||
.when_some(accept_terms, |this, element| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.right_3()
|
||||
.bottom_12()
|
||||
.max_w_96()
|
||||
.py_2()
|
||||
.px_3()
|
||||
.elevation_2(cx)
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.occlude()
|
||||
.child(element),
|
||||
)
|
||||
})
|
||||
.children(self.render_last_error(cx))
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -2649,7 +2517,7 @@ impl Render for TextThreadEditor {
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(self.render_inject_context_menu(cx))
|
||||
.when_some(burn_mode_toggle, |this, element| this.child(element)),
|
||||
.children(self.render_burn_mode_toggle(cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod agent_notification;
|
||||
mod burn_mode_tooltip;
|
||||
mod context_pill;
|
||||
mod end_trial_upsell;
|
||||
mod new_thread_button;
|
||||
mod onboarding_modal;
|
||||
pub mod preview;
|
||||
mod upsell;
|
||||
@@ -8,4 +10,6 @@ mod upsell;
|
||||
pub use agent_notification::*;
|
||||
pub use burn_mode_tooltip::*;
|
||||
pub use context_pill::*;
|
||||
pub use end_trial_upsell::*;
|
||||
pub use new_thread_button::*;
|
||||
pub use onboarding_modal::*;
|
||||
|
||||
112
crates/agent_ui/src/ui/end_trial_upsell.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai_onboarding::{AgentPanelOnboardingCard, BulletItem};
|
||||
use client::zed_urls;
|
||||
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
|
||||
use ui::{Divider, List, prelude::*};
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct EndTrialUpsell {
|
||||
dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl EndTrialUpsell {
|
||||
pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
|
||||
Self { dismiss_upsell }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EndTrialUpsell {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let pro_section = v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Pro")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Accent)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("500 prompts per month with Claude models"))
|
||||
.child(BulletItem::new("Unlimited edit predictions")),
|
||||
)
|
||||
.child(
|
||||
Button::new("cta-button", "Upgrade to Zed Pro")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
|
||||
);
|
||||
|
||||
let free_section = v_flex()
|
||||
.mt_1p5()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Free")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new(
|
||||
"50 prompts per month with the Claude models",
|
||||
))
|
||||
.child(BulletItem::new(
|
||||
"2000 accepted edit predictions using our open-source Zeta model",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("dismiss-button", "Stay on Free")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click({
|
||||
let callback = self.dismiss_upsell.clone();
|
||||
move |_, window, cx| callback(window, cx)
|
||||
}),
|
||||
);
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(Headline::new("Your Zed Pro trial has expired."))
|
||||
.child(
|
||||
Label::new("You've been automatically reset to the Free plan.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.mb_1(),
|
||||
)
|
||||
.child(pro_section)
|
||||
.child(free_section)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for EndTrialUpsell {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Agent
|
||||
}
|
||||
|
||||
fn sort_name() -> &'static str {
|
||||
"AgentEndTrialUpsell"
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
|
||||
Some(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_4()
|
||||
.child(EndTrialUpsell {
|
||||
dismiss_upsell: Arc::new(|_, _| {}),
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
75
crates/agent_ui/src/ui/new_thread_button.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled};
|
||||
use ui::prelude::*;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct NewThreadButton {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
icon: IconName,
|
||||
keybinding: Option<ui::KeyBinding>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl NewThreadButton {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, icon: IconName) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
icon,
|
||||
keybinding: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keybinding(mut self, keybinding: Option<ui::KeyBinding>) -> Self {
|
||||
self.keybinding = keybinding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_click = Some(Box::new(
|
||||
move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx),
|
||||
));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NewThreadButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.w_full()
|
||||
.py_1p5()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border.opacity(0.4))
|
||||
.bg(cx.theme().colors().element_active.opacity(0.2))
|
||||
.hover(|style| {
|
||||
style
|
||||
.bg(cx.theme().colors().element_hover)
|
||||
.border_color(cx.theme().colors().border)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(self.icon)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(self.label).size(LabelSize::Small)),
|
||||
)
|
||||
.when_some(self.keybinding, |this, keybinding| {
|
||||
this.child(keybinding.size(rems_from_px(10.)))
|
||||
})
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
27
crates/ai_onboarding/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ai_onboarding"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/ai_onboarding.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
client.workspace = true
|
||||
component.workspace = true
|
||||
gpui.workspace = true
|
||||
language_model.workspace = true
|
||||
proto.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
ui.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
zed_actions.workspace = true
|
||||
1
crates/ai_onboarding/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
135
crates/ai_onboarding/src/agent_api_keys_onboarding.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
|
||||
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
|
||||
use ui::{Divider, List, prelude::*};
|
||||
|
||||
use crate::BulletItem;
|
||||
|
||||
pub struct ApiKeysWithProviders {
|
||||
configured_providers: Vec<(IconName, SharedString)>,
|
||||
}
|
||||
|
||||
impl ApiKeysWithProviders {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
cx.subscribe(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
this.configured_providers = Self::compute_configured_providers(cx)
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
configured_providers: Self::compute_configured_providers(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.map(|provider| (provider.icon(), provider.name().0.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn has_providers(&self) -> bool {
|
||||
!self.configured_providers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ApiKeysWithProviders {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let configured_providers_list =
|
||||
self.configured_providers
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(icon, name)| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
|
||||
.child(Label::new(name))
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.mx_2p5()
|
||||
.p_1()
|
||||
.pb_0()
|
||||
.gap_2()
|
||||
.rounded_t_lg()
|
||||
.border_t_1()
|
||||
.border_x_1()
|
||||
.border_color(cx.theme().colors().border.opacity(0.5))
|
||||
.bg(cx.theme().colors().background.alpha(0.5))
|
||||
.shadow(vec![gpui::BoxShadow {
|
||||
color: gpui::black().opacity(0.15),
|
||||
offset: point(px(1.), px(-1.)),
|
||||
blur_radius: px(3.),
|
||||
spread_radius: px(0.),
|
||||
}])
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2p5()
|
||||
.py_1p5()
|
||||
.gap_2()
|
||||
.flex_wrap()
|
||||
.rounded_t(px(5.))
|
||||
.overflow_hidden()
|
||||
.border_t_1()
|
||||
.border_x_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.child(Icon::new(IconName::Info).size(IconSize::XSmall).color(Color::Muted))
|
||||
.child(Label::new("Or start now using API keys from your environment for the following providers:").color(Color::Muted))
|
||||
.children(configured_providers_list)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ApiKeysWithoutProviders;
|
||||
|
||||
impl ApiKeysWithoutProviders {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ApiKeysWithoutProviders {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("API Keys")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(List::new().child(BulletItem::new(
|
||||
"You can also use AI in Zed by bringing your own API keys",
|
||||
)))
|
||||
.child(
|
||||
Button::new("configure-providers", "Configure Providers")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
zed_actions::agent::OpenConfiguration.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
83
crates/ai_onboarding/src/agent_panel_onboarding_card.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use gpui::{AnyElement, IntoElement, ParentElement, linear_color_stop, linear_gradient};
|
||||
use smallvec::SmallVec;
|
||||
use ui::{Vector, VectorName, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct AgentPanelOnboardingCard {
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl AgentPanelOnboardingCard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for AgentPanelOnboardingCard {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AgentPanelOnboardingCard {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.m_2p5()
|
||||
.p(px(3.))
|
||||
.elevation_2(cx)
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().colors().background.alpha(0.5))
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.rounded(px(5.))
|
||||
.border_color(cx.theme().colors().text.alpha(0.1))
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.child(
|
||||
div()
|
||||
.opacity(0.5)
|
||||
.absolute()
|
||||
.top(px(-8.0))
|
||||
.right_0()
|
||||
.w(px(400.))
|
||||
.h(px(92.))
|
||||
.rounded_md()
|
||||
.child(
|
||||
Vector::new(
|
||||
VectorName::AiGrid,
|
||||
rems_from_px(400.),
|
||||
rems_from_px(92.),
|
||||
)
|
||||
.color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0p5()
|
||||
.right_0p5()
|
||||
.w(px(660.))
|
||||
.h(px(401.))
|
||||
.overflow_hidden()
|
||||
.rounded_md()
|
||||
.bg(linear_gradient(
|
||||
75.,
|
||||
linear_color_stop(
|
||||
cx.theme().colors().panel_background.alpha(0.01),
|
||||
1.0,
|
||||
),
|
||||
linear_color_stop(cx.theme().colors().panel_background, 0.45),
|
||||
)),
|
||||
)
|
||||
.children(self.children),
|
||||
)
|
||||
}
|
||||
}
|
||||
85
crates/ai_onboarding/src/agent_panel_onboarding_content.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore};
|
||||
use gpui::{Entity, IntoElement, ParentElement};
|
||||
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
|
||||
use ui::prelude::*;
|
||||
|
||||
use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
|
||||
|
||||
pub struct AgentPanelOnboarding {
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
configured_providers: Vec<(IconName, SharedString)>,
|
||||
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl AgentPanelOnboarding {
|
||||
pub fn new(
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
cx.subscribe(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
this.configured_providers = Self::compute_available_providers(cx)
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
user_store,
|
||||
client,
|
||||
configured_providers: Self::compute_available_providers(cx),
|
||||
continue_with_zed_ai: Arc::new(continue_with_zed_ai),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.map(|provider| (provider.icon(), provider.name().0.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AgentPanelOnboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let enrolled_in_trial = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedProTrial)
|
||||
);
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(
|
||||
ZedAiOnboarding::new(
|
||||
self.client.clone(),
|
||||
&self.user_store,
|
||||
self.continue_with_zed_ai.clone(),
|
||||
cx,
|
||||
)
|
||||
.with_dismiss({
|
||||
let callback = self.continue_with_zed_ai.clone();
|
||||
move |window, cx| callback(window, cx)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if enrolled_in_trial || self.configured_providers.len() >= 1 {
|
||||
this
|
||||
} else {
|
||||
this.child(ApiKeysWithoutProviders::new())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
464
crates/ai_onboarding/src/ai_onboarding.rs
Normal file
@@ -0,0 +1,464 @@
|
||||
mod agent_api_keys_onboarding;
|
||||
mod agent_panel_onboarding_card;
|
||||
mod agent_panel_onboarding_content;
|
||||
mod edit_prediction_onboarding_content;
|
||||
mod young_account_banner;
|
||||
|
||||
pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
|
||||
pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
|
||||
pub use agent_panel_onboarding_content::AgentPanelOnboarding;
|
||||
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
|
||||
pub use young_account_banner::YoungAccountBanner;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString};
|
||||
use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*};
|
||||
|
||||
pub struct BulletItem {
|
||||
label: SharedString,
|
||||
}
|
||||
|
||||
impl BulletItem {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for BulletItem {
|
||||
type Element = AnyElement;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
ListItem::new("list-item")
|
||||
.selectable(false)
|
||||
.start_slot(
|
||||
Icon::new(IconName::Dash)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Hidden),
|
||||
)
|
||||
.child(div().w_full().child(Label::new(self.label)))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SignInStatus {
|
||||
SignedIn,
|
||||
SigningIn,
|
||||
SignedOut,
|
||||
}
|
||||
|
||||
impl From<client::Status> for SignInStatus {
|
||||
fn from(status: client::Status) -> Self {
|
||||
if status.is_signing_in() {
|
||||
Self::SigningIn
|
||||
} else if status.is_signed_out() {
|
||||
Self::SignedOut
|
||||
} else {
|
||||
Self::SignedIn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(RegisterComponent, IntoElement)]
|
||||
pub struct ZedAiOnboarding {
|
||||
pub sign_in_status: SignInStatus,
|
||||
pub has_accepted_terms_of_service: bool,
|
||||
pub plan: Option<proto::Plan>,
|
||||
pub account_too_young: bool,
|
||||
pub continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
pub accept_terms_of_service: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
pub dismiss_onboarding: Option<Arc<dyn Fn(&mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl ZedAiOnboarding {
|
||||
pub fn new(
|
||||
client: Arc<Client>,
|
||||
user_store: &Entity<UserStore>,
|
||||
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
let store = user_store.read(cx);
|
||||
let status = *client.status().borrow();
|
||||
|
||||
Self {
|
||||
sign_in_status: status.into(),
|
||||
has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false),
|
||||
plan: store.current_plan(),
|
||||
account_too_young: store.account_too_young(),
|
||||
continue_with_zed_ai,
|
||||
accept_terms_of_service: Arc::new({
|
||||
let store = user_store.clone();
|
||||
move |_window, cx| {
|
||||
let task = store.update(cx, |store, cx| store.accept_terms_of_service(cx));
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
}),
|
||||
sign_in: Arc::new(move |_window, cx| {
|
||||
cx.spawn({
|
||||
let client = client.clone();
|
||||
async move |cx| {
|
||||
client.authenticate_and_connect(true, cx).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}),
|
||||
dismiss_onboarding: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_dismiss(
|
||||
mut self,
|
||||
dismiss_callback: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.dismiss_onboarding = Some(Arc::new(dismiss_callback));
|
||||
self
|
||||
}
|
||||
|
||||
fn free_plan_definition(&self, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Free")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(
|
||||
Label::new("(Current Plan)")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new(
|
||||
"50 prompts per month with the Claude models",
|
||||
))
|
||||
.child(BulletItem::new(
|
||||
"2000 accepted edit predictions using our open-source Zeta model",
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pro_trial_definition(&self) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(BulletItem::new(
|
||||
"150 prompts per month with the Claude models",
|
||||
))
|
||||
.child(BulletItem::new(
|
||||
"Unlimited accepted edit predictions using our open-source Zeta model",
|
||||
))
|
||||
}
|
||||
|
||||
fn pro_plan_definition(&self, cx: &mut App) -> impl IntoElement {
|
||||
v_flex().mt_2().gap_1().map(|this| {
|
||||
if self.account_too_young {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Pro")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Accent)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("500 prompts per month with Claude models"))
|
||||
.child(BulletItem::new(
|
||||
"Unlimited accepted edit predictions using our open-source Zeta model",
|
||||
))
|
||||
.child(BulletItem::new("USD $20 per month")),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Start with Pro")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(move |_, _window, cx| {
|
||||
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Pro Trial")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Accent)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(self.pro_trial_definition())
|
||||
.child(BulletItem::new(
|
||||
"Try it out for 14 days with no charge and no credit card required",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Start Pro Trial")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(move |_, _window, cx| {
|
||||
cx.open_url(&zed_urls::start_trial_url(cx))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_accept_terms_of_service(&self) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Headline::new("Before starting…"))
|
||||
.child(
|
||||
Label::new("Make sure you have read and accepted Zed AI's terms of service.")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(
|
||||
Button::new("terms_of_service", "View and Read the Terms of Service")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.on_click(move |_, _window, cx| cx.open_url(&zed_urls::terms_of_service(cx))),
|
||||
)
|
||||
.child(
|
||||
Button::new("accept_terms", "I've read it and accept it")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.on_click({
|
||||
let callback = self.accept_terms_of_service.clone();
|
||||
move |_, window, cx| (callback)(window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
|
||||
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed AI"))
|
||||
.child(
|
||||
Label::new("Sign in to start using AI in Zed with a free trial of the Pro plan, which includes:")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(self.pro_trial_definition())
|
||||
.child(
|
||||
Button::new("sign_in", "Sign in to Start Trial")
|
||||
.disabled(signing_in)
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click({
|
||||
let callback = self.sign_in.clone();
|
||||
move |_, window, cx| callback(window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
|
||||
let young_account_banner = YoungAccountBanner;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed AI"))
|
||||
.child(
|
||||
Label::new("Choose how you want to start.")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.map(|this| {
|
||||
if self.account_too_young {
|
||||
this.child(young_account_banner)
|
||||
} else {
|
||||
this.child(self.free_plan_definition(cx)).when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
let callback = dismiss_callback.clone();
|
||||
|
||||
this.child(
|
||||
h_flex().absolute().top_0().right_0().child(
|
||||
IconButton::new("dismiss_onboarding", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Dismiss"))
|
||||
.on_click(move |_, window, cx| callback(window, cx)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(self.pro_plan_definition(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to the Zed Pro free trial"))
|
||||
.child(
|
||||
Label::new("Here's what you get for the next 14 days:")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("150 prompts with Claude models"))
|
||||
.child(BulletItem::new(
|
||||
"Unlimited edit predictions with Zeta, our open-source model",
|
||||
)),
|
||||
)
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
let callback = dismiss_callback.clone();
|
||||
this.child(
|
||||
h_flex().absolute().top_0().right_0().child(
|
||||
IconButton::new("dismiss_onboarding", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Dismiss"))
|
||||
.on_click(move |_, window, cx| callback(window, cx)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed Pro"))
|
||||
.child(
|
||||
Label::new("Here's what you get:")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("500 prompts with Claude models"))
|
||||
.child(BulletItem::new("Unlimited edit predictions")),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Continue with Zed Pro")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click({
|
||||
let callback = self.continue_with_zed_ai.clone();
|
||||
move |_, window, cx| callback(window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ZedAiOnboarding {
|
||||
fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
|
||||
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
|
||||
if self.has_accepted_terms_of_service {
|
||||
match self.plan {
|
||||
None | Some(proto::Plan::Free) => self.render_free_plan_state(cx),
|
||||
Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx),
|
||||
Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx),
|
||||
}
|
||||
} else {
|
||||
self.render_accept_terms_of_service()
|
||||
}
|
||||
} else {
|
||||
self.render_sign_in_disclaimer(cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ZedAiOnboarding {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Agent
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
|
||||
fn onboarding(
|
||||
sign_in_status: SignInStatus,
|
||||
has_accepted_terms_of_service: bool,
|
||||
plan: Option<proto::Plan>,
|
||||
account_too_young: bool,
|
||||
) -> AnyElement {
|
||||
ZedAiOnboarding {
|
||||
sign_in_status,
|
||||
has_accepted_terms_of_service,
|
||||
plan,
|
||||
account_too_young,
|
||||
continue_with_zed_ai: Arc::new(|_, _| {}),
|
||||
sign_in: Arc::new(|_, _| {}),
|
||||
accept_terms_of_service: Arc::new(|_, _| {}),
|
||||
dismiss_onboarding: None,
|
||||
}
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_4()
|
||||
.children(vec![
|
||||
single_example(
|
||||
"Not Signed-in",
|
||||
onboarding(SignInStatus::SignedOut, false, None, false),
|
||||
),
|
||||
single_example(
|
||||
"Not Accepted ToS",
|
||||
onboarding(SignInStatus::SignedIn, false, None, false),
|
||||
),
|
||||
single_example(
|
||||
"Account too young",
|
||||
onboarding(SignInStatus::SignedIn, false, None, true),
|
||||
),
|
||||
single_example(
|
||||
"Free Plan",
|
||||
onboarding(SignInStatus::SignedIn, true, Some(proto::Plan::Free), false),
|
||||
),
|
||||
single_example(
|
||||
"Pro Trial",
|
||||
onboarding(
|
||||
SignInStatus::SignedIn,
|
||||
true,
|
||||
Some(proto::Plan::ZedProTrial),
|
||||
false,
|
||||
),
|
||||
),
|
||||
single_example(
|
||||
"Pro Plan",
|
||||
onboarding(
|
||||
SignInStatus::SignedIn,
|
||||
true,
|
||||
Some(proto::Plan::ZedPro),
|
||||
false,
|
||||
),
|
||||
),
|
||||
])
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore};
|
||||
use gpui::{Entity, IntoElement, ParentElement};
|
||||
use ui::prelude::*;
|
||||
|
||||
use crate::ZedAiOnboarding;
|
||||
|
||||
pub struct EditPredictionOnboarding {
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
copilot_is_configured: bool,
|
||||
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl EditPredictionOnboarding {
|
||||
pub fn new(
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
copilot_is_configured: bool,
|
||||
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_store,
|
||||
copilot_is_configured,
|
||||
client,
|
||||
continue_with_zed_ai,
|
||||
continue_with_copilot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for EditPredictionOnboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let github_copilot = v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(if self.copilot_is_configured {
|
||||
"Alternatively, you can continue to use GitHub Copilot as that's already set up."
|
||||
} else {
|
||||
"Alternatively, you can use GitHub Copilot as your edit prediction provider."
|
||||
}))
|
||||
.child(
|
||||
Button::new(
|
||||
"configure-copilot",
|
||||
if self.copilot_is_configured {
|
||||
"Use Copilot"
|
||||
} else {
|
||||
"Configure Copilot"
|
||||
},
|
||||
)
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click({
|
||||
let callback = self.continue_with_copilot.clone();
|
||||
move |_, window, cx| callback(window, cx)
|
||||
}),
|
||||
);
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(ZedAiOnboarding::new(
|
||||
self.client.clone(),
|
||||
&self.user_store,
|
||||
self.continue_with_zed_ai.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(ui::Divider::horizontal())
|
||||
.child(github_copilot)
|
||||
}
|
||||
}
|
||||
21
crates/ai_onboarding/src/young_account_banner.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use gpui::{IntoElement, ParentElement};
|
||||
use ui::{Banner, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct YoungAccountBanner;
|
||||
|
||||
impl RenderOnce for YoungAccountBanner {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing@zed.dev.";
|
||||
|
||||
let label = div()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(YOUNG_ACCOUNT_DISCLAIMER);
|
||||
|
||||
div()
|
||||
.my_1()
|
||||
.child(Banner::new().severity(ui::Severity::Warning).child(label))
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ collections = { workspace = true, features = ["test-support"] }
|
||||
clock = { workspace = true, features = ["test-support"] }
|
||||
ctor.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
indoc.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
language_model = { workspace = true, features = ["test-support"] }
|
||||
log.workspace = true
|
||||
|
||||
@@ -8,7 +8,10 @@ use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
|
||||
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
|
||||
use std::{cmp, ops::Range, sync::Arc};
|
||||
use text::{Edit, Patch, Rope};
|
||||
use util::{RangeExt, ResultExt as _};
|
||||
use util::{
|
||||
RangeExt, ResultExt as _,
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
};
|
||||
|
||||
/// Tracks actions performed by tools in a thread
|
||||
pub struct ActionLog {
|
||||
@@ -18,8 +21,6 @@ pub struct ActionLog {
|
||||
edited_since_project_diagnostics_check: bool,
|
||||
/// The project this action log is associated with
|
||||
project: Entity<Project>,
|
||||
/// Tracks which buffer versions have already been notified as changed externally
|
||||
notified_versions: BTreeMap<Entity<Buffer>, clock::Global>,
|
||||
}
|
||||
|
||||
impl ActionLog {
|
||||
@@ -29,7 +30,6 @@ impl ActionLog {
|
||||
tracked_buffers: BTreeMap::default(),
|
||||
edited_since_project_diagnostics_check: false,
|
||||
project,
|
||||
notified_versions: BTreeMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,67 @@ impl ActionLog {
|
||||
Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
|
||||
}
|
||||
|
||||
pub fn has_unnotified_user_edits(&self) -> bool {
|
||||
self.tracked_buffers
|
||||
.values()
|
||||
.any(|tracked| tracked.has_unnotified_user_edits)
|
||||
}
|
||||
|
||||
/// Return a unified diff patch with user edits made since last read or notification
|
||||
pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> {
|
||||
if !self.has_unnotified_user_edits() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unified_diff = self
|
||||
.tracked_buffers
|
||||
.values()
|
||||
.filter_map(|tracked| {
|
||||
if !tracked.has_unnotified_user_edits {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text_with_latest_user_edits = tracked.diff_base.to_string();
|
||||
let text_with_last_seen_user_edits = tracked.last_seen_base.to_string();
|
||||
if text_with_latest_user_edits == text_with_last_seen_user_edits {
|
||||
return None;
|
||||
}
|
||||
let patch = language::unified_diff(
|
||||
&text_with_last_seen_user_edits,
|
||||
&text_with_latest_user_edits,
|
||||
);
|
||||
|
||||
let buffer = tracked.buffer.clone();
|
||||
let file_path = buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.map(|file| RemotePathBuf::new(file.full_path(cx), PathStyle::Posix).to_proto())
|
||||
.unwrap_or_else(|| format!("buffer_{}", buffer.entity_id()));
|
||||
|
||||
let mut result = String::new();
|
||||
result.push_str(&format!("--- a/{}\n", file_path));
|
||||
result.push_str(&format!("+++ b/{}\n", file_path));
|
||||
result.push_str(&patch);
|
||||
|
||||
Some(result)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
Some(unified_diff)
|
||||
}
|
||||
|
||||
/// Return a unified diff patch with user edits made since last read/notification
|
||||
/// and mark them as notified
|
||||
pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> {
|
||||
let patch = self.unnotified_user_edits(cx);
|
||||
self.tracked_buffers.values_mut().for_each(|tracked| {
|
||||
tracked.has_unnotified_user_edits = false;
|
||||
tracked.last_seen_base = tracked.diff_base.clone();
|
||||
});
|
||||
patch
|
||||
}
|
||||
|
||||
fn track_buffer_internal(
|
||||
&mut self,
|
||||
buffer: Entity<Buffer>,
|
||||
@@ -59,7 +120,6 @@ impl ActionLog {
|
||||
) -> &mut TrackedBuffer {
|
||||
let status = if is_created {
|
||||
if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
|
||||
self.notified_versions.remove(&buffer);
|
||||
match tracked.status {
|
||||
TrackedBufferStatus::Created {
|
||||
existing_file_content,
|
||||
@@ -101,26 +161,31 @@ impl ActionLog {
|
||||
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
|
||||
let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
|
||||
let diff_base;
|
||||
let last_seen_base;
|
||||
let unreviewed_edits;
|
||||
if is_created {
|
||||
diff_base = Rope::default();
|
||||
last_seen_base = Rope::default();
|
||||
unreviewed_edits = Patch::new(vec![Edit {
|
||||
old: 0..1,
|
||||
new: 0..text_snapshot.max_point().row + 1,
|
||||
}])
|
||||
} else {
|
||||
diff_base = buffer.read(cx).as_rope().clone();
|
||||
last_seen_base = diff_base.clone();
|
||||
unreviewed_edits = Patch::default();
|
||||
}
|
||||
TrackedBuffer {
|
||||
buffer: buffer.clone(),
|
||||
diff_base,
|
||||
last_seen_base,
|
||||
unreviewed_edits,
|
||||
snapshot: text_snapshot.clone(),
|
||||
status,
|
||||
version: buffer.read(cx).version(),
|
||||
diff,
|
||||
diff_update: diff_update_tx,
|
||||
has_unnotified_user_edits: false,
|
||||
_open_lsp_handle: open_lsp_handle,
|
||||
_maintain_diff: cx.spawn({
|
||||
let buffer = buffer.clone();
|
||||
@@ -174,7 +239,6 @@ impl ActionLog {
|
||||
// If the buffer had been edited by a tool, but it got
|
||||
// deleted externally, we want to stop tracking it.
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -188,7 +252,6 @@ impl ActionLog {
|
||||
// resurrected externally, we want to clear the edits we
|
||||
// were tracking and reset the buffer's state.
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
self.track_buffer_internal(buffer, false, cx);
|
||||
}
|
||||
cx.notify();
|
||||
@@ -262,10 +325,10 @@ impl ActionLog {
|
||||
buffer_snapshot: text::BufferSnapshot,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
let rebase = this.read_with(cx, |this, cx| {
|
||||
let rebase = this.update(cx, |this, cx| {
|
||||
let tracked_buffer = this
|
||||
.tracked_buffers
|
||||
.get(buffer)
|
||||
.get_mut(buffer)
|
||||
.context("buffer not tracked")?;
|
||||
|
||||
let rebase = cx.background_spawn({
|
||||
@@ -273,8 +336,13 @@ impl ActionLog {
|
||||
let old_snapshot = tracked_buffer.snapshot.clone();
|
||||
let new_snapshot = buffer_snapshot.clone();
|
||||
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||
if let ChangeAuthor::User = author
|
||||
&& !edits.is_empty()
|
||||
{
|
||||
tracked_buffer.has_unnotified_user_edits = true;
|
||||
}
|
||||
async move {
|
||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||
if let ChangeAuthor::User = author {
|
||||
apply_non_conflicting_edits(
|
||||
&unreviewed_edits,
|
||||
@@ -494,7 +562,6 @@ impl ActionLog {
|
||||
match tracked_buffer.status {
|
||||
TrackedBufferStatus::Created { .. } => {
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
cx.notify();
|
||||
}
|
||||
TrackedBufferStatus::Modified => {
|
||||
@@ -520,7 +587,6 @@ impl ActionLog {
|
||||
match tracked_buffer.status {
|
||||
TrackedBufferStatus::Deleted => {
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
cx.notify();
|
||||
}
|
||||
_ => {
|
||||
@@ -629,7 +695,6 @@ impl ActionLog {
|
||||
};
|
||||
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
cx.notify();
|
||||
task
|
||||
}
|
||||
@@ -643,7 +708,6 @@ impl ActionLog {
|
||||
|
||||
// Clear all tracked edits for this buffer and start over as if we just read it.
|
||||
self.tracked_buffers.remove(&buffer);
|
||||
self.notified_versions.remove(&buffer);
|
||||
self.buffer_read(buffer.clone(), cx);
|
||||
cx.notify();
|
||||
save
|
||||
@@ -744,33 +808,6 @@ impl ActionLog {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns stale buffers that haven't been notified yet
|
||||
pub fn unnotified_stale_buffers<'a>(
|
||||
&'a self,
|
||||
cx: &'a App,
|
||||
) -> impl Iterator<Item = &'a Entity<Buffer>> {
|
||||
self.stale_buffers(cx).filter(|buffer| {
|
||||
let buffer_entity = buffer.read(cx);
|
||||
self.notified_versions
|
||||
.get(buffer)
|
||||
.map_or(true, |notified_version| {
|
||||
*notified_version != buffer_entity.version
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Marks the given buffers as notified at their current versions
|
||||
pub fn mark_buffers_as_notified(
|
||||
&mut self,
|
||||
buffers: impl IntoIterator<Item = Entity<Buffer>>,
|
||||
cx: &App,
|
||||
) {
|
||||
for buffer in buffers {
|
||||
let version = buffer.read(cx).version.clone();
|
||||
self.notified_versions.insert(buffer, version);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over buffers changed since last read or edited by the model
|
||||
pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
|
||||
self.tracked_buffers
|
||||
@@ -914,12 +951,14 @@ enum TrackedBufferStatus {
|
||||
struct TrackedBuffer {
|
||||
buffer: Entity<Buffer>,
|
||||
diff_base: Rope,
|
||||
last_seen_base: Rope,
|
||||
unreviewed_edits: Patch<u32>,
|
||||
status: TrackedBufferStatus,
|
||||
version: clock::Global,
|
||||
diff: Entity<BufferDiff>,
|
||||
snapshot: text::BufferSnapshot,
|
||||
diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
|
||||
has_unnotified_user_edits: bool,
|
||||
_open_lsp_handle: OpenLspBufferHandle,
|
||||
_maintain_diff: Task<()>,
|
||||
_subscription: Subscription,
|
||||
@@ -950,6 +989,7 @@ mod tests {
|
||||
use super::*;
|
||||
use buffer_diff::DiffHunkStatusKind;
|
||||
use gpui::TestAppContext;
|
||||
use indoc::indoc;
|
||||
use language::Point;
|
||||
use project::{FakeFs, Fs, Project, RemoveOptions};
|
||||
use rand::prelude::*;
|
||||
@@ -1232,6 +1272,110 @@ mod tests {
|
||||
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_user_edits_notifications(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/dir"),
|
||||
json!({"file": indoc! {"
|
||||
abc
|
||||
def
|
||||
ghi
|
||||
jkl
|
||||
mno"}}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
let file_path = project
|
||||
.read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
|
||||
.unwrap();
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(file_path, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Agent edits
|
||||
cx.update(|cx| {
|
||||
action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer
|
||||
.edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
|
||||
.unwrap()
|
||||
});
|
||||
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
buffer.read_with(cx, |buffer, _| buffer.text()),
|
||||
indoc! {"
|
||||
abc
|
||||
deF
|
||||
GHI
|
||||
jkl
|
||||
mno"}
|
||||
);
|
||||
assert_eq!(
|
||||
unreviewed_hunks(&action_log, cx),
|
||||
vec![(
|
||||
buffer.clone(),
|
||||
vec![HunkStatus {
|
||||
range: Point::new(1, 0)..Point::new(3, 0),
|
||||
diff_status: DiffHunkStatusKind::Modified,
|
||||
old_text: "def\nghi\n".into(),
|
||||
}],
|
||||
)]
|
||||
);
|
||||
|
||||
// User edits
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
[
|
||||
(Point::new(0, 2)..Point::new(0, 2), "X"),
|
||||
(Point::new(3, 0)..Point::new(3, 0), "Y"),
|
||||
],
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
buffer.read_with(cx, |buffer, _| buffer.text()),
|
||||
indoc! {"
|
||||
abXc
|
||||
deF
|
||||
GHI
|
||||
Yjkl
|
||||
mno"}
|
||||
);
|
||||
|
||||
// User edits should be stored separately from agent's
|
||||
let user_edits = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
|
||||
assert_eq!(
|
||||
user_edits.expect("should have some user edits"),
|
||||
indoc! {"
|
||||
--- a/dir/file
|
||||
+++ b/dir/file
|
||||
@@ -1,5 +1,5 @@
|
||||
-abc
|
||||
+abXc
|
||||
def
|
||||
ghi
|
||||
-jkl
|
||||
+Yjkl
|
||||
mno
|
||||
"}
|
||||
);
|
||||
|
||||
action_log.update(cx, |log, cx| {
|
||||
log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_creating_files(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -2221,4 +2365,61 @@ mod tests {
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_format_patch(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/dir"),
|
||||
json!({"test.txt": "line 1\nline 2\nline 3\n"}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
|
||||
let file_path = project
|
||||
.read_with(cx, |project, cx| {
|
||||
project.find_project_path("dir/test.txt", cx)
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(file_path, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| {
|
||||
// Track the buffer and mark it as read first
|
||||
action_log.update(cx, |log, cx| {
|
||||
log.buffer_read(buffer.clone(), cx);
|
||||
});
|
||||
|
||||
// Make some edits to create a patch
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer
|
||||
.edit([(Point::new(1, 0)..Point::new(1, 6), "CHANGED")], None, cx)
|
||||
.unwrap(); // Replace "line2" with "CHANGED"
|
||||
});
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Get the patch
|
||||
let patch = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
|
||||
|
||||
// Verify the patch format contains expected unified diff elements
|
||||
assert_eq!(
|
||||
patch.unwrap(),
|
||||
indoc! {"
|
||||
--- a/dir/test.txt
|
||||
+++ b/dir/test.txt
|
||||
@@ -1,3 +1,3 @@
|
||||
line 1
|
||||
-line 2
|
||||
+CHANGED
|
||||
line 3
|
||||
"}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_llm_client.workspace = true
|
||||
diffy = "0.4.2"
|
||||
|
||||
[dev-dependencies]
|
||||
lsp = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -12,6 +12,7 @@ use collections::HashMap;
|
||||
use fs::FakeFs;
|
||||
use futures::{FutureExt, future::LocalBoxFuture};
|
||||
use gpui::{AppContext, TestAppContext, Timer};
|
||||
use http_client::StatusCode;
|
||||
use indoc::{formatdoc, indoc};
|
||||
use language_model::{
|
||||
LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult,
|
||||
@@ -1675,6 +1676,30 @@ async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) ->
|
||||
Timer::after(retry_after + jitter).await;
|
||||
continue;
|
||||
}
|
||||
LanguageModelCompletionError::UpstreamProviderError {
|
||||
status,
|
||||
retry_after,
|
||||
..
|
||||
} => {
|
||||
// Only retry for specific status codes
|
||||
let should_retry = matches!(
|
||||
*status,
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
|
||||
) || status.as_u16() == 529;
|
||||
|
||||
if !should_retry {
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
// Use server-provided retry_after if available, otherwise use default
|
||||
let retry_after = retry_after.unwrap_or(Duration::from_secs(5));
|
||||
let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
|
||||
eprintln!(
|
||||
"Attempt #{attempt}: {err}. Retry after {retry_after:?} + jitter of {jitter:?}"
|
||||
);
|
||||
Timer::after(retry_after + jitter).await;
|
||||
continue;
|
||||
}
|
||||
_ => return Err(err.into()),
|
||||
},
|
||||
Err(err) => return Err(err),
|
||||
|
||||
@@ -6,8 +6,7 @@ use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchem
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::{fmt::Write, sync::Arc};
|
||||
use ui::IconName;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -52,39 +51,113 @@ impl Tool for ProjectNotificationsTool {
|
||||
_window: Option<AnyWindowHandle>,
|
||||
cx: &mut App,
|
||||
) -> ToolResult {
|
||||
let mut stale_files = String::new();
|
||||
let mut notified_buffers = Vec::new();
|
||||
|
||||
for stale_file in action_log.read(cx).unnotified_stale_buffers(cx) {
|
||||
if let Some(file) = stale_file.read(cx).file() {
|
||||
writeln!(&mut stale_files, "- {}", file.path().display()).ok();
|
||||
notified_buffers.push(stale_file.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !notified_buffers.is_empty() {
|
||||
action_log.update(cx, |log, cx| {
|
||||
log.mark_buffers_as_notified(notified_buffers, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let response = if stale_files.is_empty() {
|
||||
"No new notifications".to_string()
|
||||
} else {
|
||||
// NOTE: Changes to this prompt require a symmetric update in the LLM Worker
|
||||
const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt");
|
||||
format!("{HEADER}{stale_files}").replace("\r\n", "\n")
|
||||
let Some(user_edits_diff) =
|
||||
action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx))
|
||||
else {
|
||||
return result("No new notifications");
|
||||
};
|
||||
|
||||
Task::ready(Ok(response.into())).into()
|
||||
// NOTE: Changes to this prompt require a symmetric update in the LLM Worker
|
||||
const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt");
|
||||
const MAX_BYTES: usize = 8000;
|
||||
let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES);
|
||||
result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn result(response: &str) -> ToolResult {
|
||||
Task::ready(Ok(response.to_string().into())).into()
|
||||
}
|
||||
|
||||
/// Make sure that the patch fits into the size limit (in bytes).
|
||||
/// Compress the patch by omitting some parts if needed.
|
||||
/// Unified diff format is assumed.
|
||||
fn fit_patch_to_size(patch: &str, max_size: usize) -> String {
|
||||
if patch.len() <= max_size {
|
||||
return patch.to_string();
|
||||
}
|
||||
|
||||
// Compression level 1: remove context lines in diff bodies, but
|
||||
// leave the counts and positions of inserted/deleted lines
|
||||
let mut current_size = patch.len();
|
||||
let mut file_patches = split_patch(&patch);
|
||||
file_patches.sort_by_key(|patch| patch.len());
|
||||
let compressed_patches = file_patches
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|patch| {
|
||||
if current_size > max_size {
|
||||
let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string());
|
||||
current_size -= patch.len() - compressed.len();
|
||||
compressed
|
||||
} else {
|
||||
patch.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if current_size <= max_size {
|
||||
return compressed_patches.join("\n\n");
|
||||
}
|
||||
|
||||
// Compression level 2: list paths of the changed files only
|
||||
let filenames = file_patches
|
||||
.iter()
|
||||
.map(|patch| {
|
||||
let patch = diffy::Patch::from_str(patch).unwrap();
|
||||
let path = patch
|
||||
.modified()
|
||||
.and_then(|path| path.strip_prefix("b/"))
|
||||
.unwrap_or_default();
|
||||
format!("- {path}\n")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
filenames.join("")
|
||||
}
|
||||
|
||||
/// Split a potentially multi-file patch into multiple single-file patches
|
||||
fn split_patch(patch: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut current_patch = String::new();
|
||||
|
||||
for line in patch.lines() {
|
||||
if line.starts_with("---") && !current_patch.is_empty() {
|
||||
result.push(current_patch.trim_end_matches('\n').into());
|
||||
current_patch = String::new();
|
||||
}
|
||||
current_patch.push_str(line);
|
||||
current_patch.push('\n');
|
||||
}
|
||||
|
||||
if !current_patch.is_empty() {
|
||||
result.push(current_patch.trim_end_matches('\n').into());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn compress_patch(patch: &str) -> anyhow::Result<String> {
|
||||
let patch = diffy::Patch::from_str(patch)?;
|
||||
let mut out = String::new();
|
||||
|
||||
writeln!(out, "--- {}", patch.original().unwrap_or("a"))?;
|
||||
writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?;
|
||||
|
||||
for hunk in patch.hunks() {
|
||||
writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?;
|
||||
writeln!(out, "[...skipped...]")?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use assistant_tool::ToolResultContent;
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
use indoc::indoc;
|
||||
use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider};
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
@@ -123,6 +196,7 @@ mod tests {
|
||||
action_log.update(cx, |log, cx| {
|
||||
log.buffer_read(buffer.clone(), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Run the tool before any changes
|
||||
let tool = Arc::new(ProjectNotificationsTool);
|
||||
@@ -142,6 +216,7 @@ mod tests {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let response = result.output.await.unwrap();
|
||||
let response_text = match &response.content {
|
||||
@@ -158,6 +233,7 @@ mod tests {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit([(1..1, "\nChange!\n")], None, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Run the tool again
|
||||
let result = cx.update(|cx| {
|
||||
@@ -171,6 +247,7 @@ mod tests {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// This time the buffer is stale, so the tool should return a notification
|
||||
let response = result.output.await.unwrap();
|
||||
@@ -179,10 +256,12 @@ mod tests {
|
||||
_ => panic!("Expected text response"),
|
||||
};
|
||||
|
||||
let expected_content = "[The following is an auto-generated notification; do not reply]\n\nThese files have changed since the last read:\n- code.rs\n";
|
||||
assert_eq!(
|
||||
response_text.as_str(),
|
||||
expected_content,
|
||||
assert!(
|
||||
response_text.contains("These files have changed"),
|
||||
"Tool should return the stale buffer notification"
|
||||
);
|
||||
assert!(
|
||||
response_text.contains("test/code.rs"),
|
||||
"Tool should return the stale buffer notification"
|
||||
);
|
||||
|
||||
@@ -198,6 +277,7 @@ mod tests {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let response = result.output.await.unwrap();
|
||||
let response_text = match &response.content {
|
||||
@@ -212,6 +292,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_patch_compression() {
|
||||
// Given a patch that doesn't fit into the size budget
|
||||
let patch = indoc! {"
|
||||
--- a/dir/test.txt
|
||||
+++ b/dir/test.txt
|
||||
@@ -1,3 +1,3 @@
|
||||
line 1
|
||||
-line 2
|
||||
+CHANGED
|
||||
line 3
|
||||
@@ -10,2 +10,2 @@
|
||||
line 10
|
||||
-line 11
|
||||
+line eleven
|
||||
|
||||
|
||||
--- a/dir/another.txt
|
||||
+++ b/dir/another.txt
|
||||
@@ -100,1 +1,1 @@
|
||||
-before
|
||||
+after
|
||||
"};
|
||||
|
||||
// When the size deficit can be compensated by dropping the body,
|
||||
// then the body should be trimmed for larger files first
|
||||
let limit = patch.len() - 10;
|
||||
let compressed = fit_patch_to_size(patch, limit);
|
||||
let expected = indoc! {"
|
||||
--- a/dir/test.txt
|
||||
+++ b/dir/test.txt
|
||||
@@ -1,3 +1,3 @@
|
||||
[...skipped...]
|
||||
@@ -10,2 +10,2 @@
|
||||
[...skipped...]
|
||||
|
||||
|
||||
--- a/dir/another.txt
|
||||
+++ b/dir/another.txt
|
||||
@@ -100,1 +1,1 @@
|
||||
-before
|
||||
+after"};
|
||||
assert_eq!(compressed, expected);
|
||||
|
||||
// When the size deficit is too large, then only file paths
|
||||
// should be returned
|
||||
let limit = 10;
|
||||
let compressed = fit_patch_to_size(patch, limit);
|
||||
let expected = indoc! {"
|
||||
- dir/another.txt
|
||||
- dir/test.txt
|
||||
"};
|
||||
assert_eq!(compressed, expected);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
|
||||
@@ -285,7 +285,10 @@ impl Tool for ReadFileTool {
|
||||
|
||||
Using the line numbers in this outline, you can call this tool again
|
||||
while specifying the start_line and end_line fields to see the
|
||||
implementations of symbols in the outline."
|
||||
implementations of symbols in the outline.
|
||||
|
||||
Alternatively, you can fall back to the `grep` tool (if available)
|
||||
to search the file for specific content."
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ use client::{
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use fs::Fs;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, ScreenCaptureSource,
|
||||
ScreenCaptureStream, Task, WeakEntity,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
use livekit::{LocalTrackPublication, ParticipantIdentity, RoomEvent};
|
||||
use livekit_client::{self as livekit, TrackSid};
|
||||
use livekit_client::{self as livekit, AudioStream, TrackSid};
|
||||
use postage::{sink::Sink, stream::Stream, watch};
|
||||
use project::Project;
|
||||
use settings::Settings as _;
|
||||
use std::{any::Any, future::Future, mem, rc::Rc, sync::Arc, time::Duration};
|
||||
use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration};
|
||||
use util::{ResultExt, TryFutureExt, post_inc};
|
||||
|
||||
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -1251,12 +1254,21 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_screen_sharing(&self) -> bool {
|
||||
pub fn is_sharing_screen(&self) -> bool {
|
||||
self.live_kit.as_ref().map_or(false, |live_kit| {
|
||||
!matches!(live_kit.screen_track, LocalTrack::None)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shared_screen_id(&self) -> Option<u64> {
|
||||
self.live_kit.as_ref().and_then(|lk| match lk.screen_track {
|
||||
LocalTrack::Published { ref _stream, .. } => {
|
||||
_stream.metadata().ok().map(|meta| meta.id)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_sharing_mic(&self) -> bool {
|
||||
self.live_kit.as_ref().map_or(false, |live_kit| {
|
||||
!matches!(live_kit.microphone_track, LocalTrack::None)
|
||||
@@ -1369,11 +1381,15 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn share_screen(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
pub fn share_screen(
|
||||
&mut self,
|
||||
source: Rc<dyn ScreenCaptureSource>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if self.status.is_offline() {
|
||||
return Task::ready(Err(anyhow!("room is offline")));
|
||||
}
|
||||
if self.is_screen_sharing() {
|
||||
if self.is_sharing_screen() {
|
||||
return Task::ready(Err(anyhow!("screen was already shared")));
|
||||
}
|
||||
|
||||
@@ -1386,20 +1402,8 @@ impl Room {
|
||||
return Task::ready(Err(anyhow!("live-kit was not initialized")));
|
||||
};
|
||||
|
||||
let sources = cx.screen_capture_sources();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let sources = sources
|
||||
.await
|
||||
.map_err(|error| error.into())
|
||||
.and_then(|sources| sources);
|
||||
let source =
|
||||
sources.and_then(|sources| sources.into_iter().next().context("no display found"));
|
||||
|
||||
let publication = match source {
|
||||
Ok(source) => participant.publish_screenshare_track(&*source, cx).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let publication = participant.publish_screenshare_track(&*source, cx).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let live_kit = this
|
||||
@@ -1426,7 +1430,7 @@ impl Room {
|
||||
} else {
|
||||
live_kit.screen_track = LocalTrack::Published {
|
||||
track_publication: publication,
|
||||
_stream: Box::new(stream),
|
||||
_stream: stream,
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
@@ -1492,7 +1496,7 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unshare_screen(&mut self, cx: &mut Context<Self>) -> Result<()> {
|
||||
pub fn unshare_screen(&mut self, play_sound: bool, cx: &mut Context<Self>) -> Result<()> {
|
||||
anyhow::ensure!(!self.status.is_offline(), "room is offline");
|
||||
|
||||
let live_kit = self
|
||||
@@ -1516,7 +1520,10 @@ impl Room {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
Audio::play_sound(Sound::StopScreenshare, cx);
|
||||
if play_sound {
|
||||
Audio::play_sound(Sound::StopScreenshare, cx);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1624,8 +1631,8 @@ fn spawn_room_connection(
|
||||
|
||||
struct LiveKitRoom {
|
||||
room: Rc<livekit::Room>,
|
||||
screen_track: LocalTrack,
|
||||
microphone_track: LocalTrack,
|
||||
screen_track: LocalTrack<dyn ScreenCaptureStream>,
|
||||
microphone_track: LocalTrack<AudioStream>,
|
||||
/// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
|
||||
muted_by_user: bool,
|
||||
deafened: bool,
|
||||
@@ -1663,18 +1670,18 @@ impl LiveKitRoom {
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalTrack {
|
||||
enum LocalTrack<Stream: ?Sized> {
|
||||
None,
|
||||
Pending {
|
||||
publish_id: usize,
|
||||
},
|
||||
Published {
|
||||
track_publication: LocalTrackPublication,
|
||||
_stream: Box<dyn Any>,
|
||||
_stream: Box<Stream>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for LocalTrack {
|
||||
impl<T: ?Sized> Default for LocalTrack<T> {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
|
||||
@@ -315,19 +315,19 @@ fn main() -> Result<()> {
|
||||
});
|
||||
|
||||
let stdin_pipe_handle: Option<JoinHandle<anyhow::Result<()>>> =
|
||||
stdin_tmp_file.map(|tmp_file| {
|
||||
stdin_tmp_file.map(|mut tmp_file| {
|
||||
thread::spawn(move || {
|
||||
let stdin = std::io::stdin().lock();
|
||||
if io::IsTerminal::is_terminal(&stdin) {
|
||||
return Ok(());
|
||||
let mut stdin = std::io::stdin().lock();
|
||||
if !io::IsTerminal::is_terminal(&stdin) {
|
||||
io::copy(&mut stdin, &mut tmp_file)?;
|
||||
}
|
||||
return pipe_to_tmp(stdin, tmp_file);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
|
||||
let anonymous_fd_pipe_handles: Vec<JoinHandle<anyhow::Result<()>>> = anonymous_fd_tmp_files
|
||||
let anonymous_fd_pipe_handles: Vec<_> = anonymous_fd_tmp_files
|
||||
.into_iter()
|
||||
.map(|(file, tmp_file)| thread::spawn(move || pipe_to_tmp(file, tmp_file)))
|
||||
.map(|(mut file, mut tmp_file)| thread::spawn(move || io::copy(&mut file, &mut tmp_file)))
|
||||
.collect();
|
||||
|
||||
if args.foreground {
|
||||
@@ -349,22 +349,6 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pipe_to_tmp(mut src: impl io::Read, mut dest: fs::File) -> Result<()> {
|
||||
let mut buffer = [0; 8 * 1024];
|
||||
loop {
|
||||
let bytes_read = match src.read(&mut buffer) {
|
||||
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
|
||||
res => res?,
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
io::Write::write_all(&mut dest, &buffer[..bytes_read])?;
|
||||
}
|
||||
io::Write::flush(&mut dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn anonymous_fd(path: &str) -> Option<fs::File> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -301,6 +301,13 @@ impl Status {
|
||||
matches!(self, Self::Connected { .. })
|
||||
}
|
||||
|
||||
pub fn is_signing_in(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Authenticating | Self::Reauthenticating | Self::Connecting | Self::Reconnecting
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_signed_out(&self) -> bool {
|
||||
matches!(self, Self::SignedOut | Self::UpgradeRequired)
|
||||
}
|
||||
|
||||
@@ -764,6 +764,16 @@ impl UserStore {
|
||||
}
|
||||
|
||||
pub fn current_plan(&self) -> Option<proto::Plan> {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() {
|
||||
return match plan.as_str() {
|
||||
"free" => Some(proto::Plan::Free),
|
||||
"trial" => Some(proto::Plan::ZedProTrial),
|
||||
"pro" => Some(proto::Plan::ZedPro),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
self.current_plan
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,21 @@ fn server_url(cx: &App) -> &str {
|
||||
pub fn account_url(cx: &App) -> String {
|
||||
format!("{server_url}/account", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to the start trial page on zed.dev.
|
||||
pub fn start_trial_url(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/account/start-trial",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to the upgrade page on zed.dev.
|
||||
pub fn upgrade_to_zed_pro_url(cx: &App) -> String {
|
||||
format!("{server_url}/account/upgrade", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's terms of service.
|
||||
pub fn terms_of_service(cx: &App) -> String {
|
||||
format!("{server_url}/terms-of-service", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ dashmap.workspace = true
|
||||
derive_more.workspace = true
|
||||
envy = "0.4.2"
|
||||
futures.workspace = true
|
||||
gpui = { workspace = true, features = ["screen-capture"] }
|
||||
gpui.workspace = true
|
||||
hex.workspace = true
|
||||
http_client.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
@@ -127,6 +127,7 @@ sea-orm = { version = "1.1.0-rc.1", features = ["sqlx-sqlite"] }
|
||||
serde_json.workspace = true
|
||||
session = { workspace = true, features = ["test-support"] }
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
smol.workspace = true
|
||||
sqlx = { version = "0.8", features = ["sqlite"] }
|
||||
task.workspace = true
|
||||
theme.workspace = true
|
||||
|
||||
@@ -100,6 +100,7 @@ pub fn routes(rpc_server: Arc<rpc::Server>) -> Router<(), Body> {
|
||||
.route("/user", get(update_or_create_authenticated_user))
|
||||
.route("/users/look_up", get(look_up_user))
|
||||
.route("/users/:id/access_tokens", post(create_access_token))
|
||||
.route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens))
|
||||
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
|
||||
.merge(billing::router())
|
||||
.merge(contributors::router())
|
||||
@@ -334,3 +335,15 @@ async fn create_access_token(
|
||||
encrypted_access_token,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshLlmTokensResponse {}
|
||||
|
||||
async fn refresh_llm_tokens(
|
||||
Path(user_id): Path<UserId>,
|
||||
Extension(rpc_server): Extension<Arc<rpc::Server>>,
|
||||
) -> Result<Json<RefreshLlmTokensResponse>> {
|
||||
rpc_server.refresh_llm_tokens_for_user(user_id).await;
|
||||
|
||||
Ok(Json(RefreshLlmTokensResponse {}))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use anyhow::{Context as _, bail};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{self, Query},
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use collections::HashSet;
|
||||
use axum::{Extension, Json, Router, extract, routing::post};
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::{HashMap, HashSet};
|
||||
use reqwest::StatusCode;
|
||||
use sea_orm::ActiveValue;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use stripe::{
|
||||
BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
|
||||
@@ -21,13 +16,12 @@ use stripe::{
|
||||
PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
|
||||
};
|
||||
use util::{ResultExt, maybe};
|
||||
use zed_llm_client::LanguageModelProvider;
|
||||
|
||||
use crate::api::events::SnowflakeRow;
|
||||
use crate::db::billing_subscription::{
|
||||
StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
|
||||
};
|
||||
use crate::llm::db::subscription_usage_meter::CompletionMode;
|
||||
use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND};
|
||||
use crate::llm::db::subscription_usage_meter::{self, CompletionMode};
|
||||
use crate::rpc::{ResultExt as _, Server};
|
||||
use crate::stripe_client::{
|
||||
StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription,
|
||||
@@ -39,21 +33,14 @@ use crate::{
|
||||
db::{
|
||||
BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
|
||||
CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
|
||||
UpdateBillingPreferencesParams, UpdateBillingSubscriptionParams, billing_customer,
|
||||
UpdateBillingSubscriptionParams, billing_customer,
|
||||
},
|
||||
stripe_billing::StripeBilling,
|
||||
};
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/billing/preferences",
|
||||
get(get_billing_preferences).put(update_billing_preferences),
|
||||
)
|
||||
.route(
|
||||
"/billing/subscriptions",
|
||||
get(list_billing_subscriptions).post(create_billing_subscription),
|
||||
)
|
||||
.route("/billing/subscriptions", post(create_billing_subscription))
|
||||
.route(
|
||||
"/billing/subscriptions/manage",
|
||||
post(manage_billing_subscription),
|
||||
@@ -62,235 +49,6 @@ pub fn router() -> Router {
|
||||
"/billing/subscriptions/sync",
|
||||
post(sync_billing_subscription),
|
||||
)
|
||||
.route("/billing/usage", get(get_current_usage))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetBillingPreferencesParams {
|
||||
github_user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BillingPreferencesResponse {
|
||||
trial_started_at: Option<String>,
|
||||
max_monthly_llm_usage_spending_in_cents: i32,
|
||||
model_request_overages_enabled: bool,
|
||||
model_request_overages_spend_limit_in_cents: i32,
|
||||
}
|
||||
|
||||
async fn get_billing_preferences(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<GetBillingPreferencesParams>,
|
||||
) -> Result<Json<BillingPreferencesResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(params.github_user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
|
||||
let preferences = app.db.get_billing_preferences(user.id).await?;
|
||||
|
||||
Ok(Json(BillingPreferencesResponse {
|
||||
trial_started_at: billing_customer
|
||||
.and_then(|billing_customer| billing_customer.trial_started_at)
|
||||
.map(|trial_started_at| {
|
||||
trial_started_at
|
||||
.and_utc()
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}),
|
||||
max_monthly_llm_usage_spending_in_cents: preferences
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_MAX_MONTHLY_SPEND.0 as i32, |preferences| {
|
||||
preferences.max_monthly_llm_usage_spending_in_cents
|
||||
}),
|
||||
model_request_overages_enabled: preferences.as_ref().map_or(false, |preferences| {
|
||||
preferences.model_request_overages_enabled
|
||||
}),
|
||||
model_request_overages_spend_limit_in_cents: preferences
|
||||
.as_ref()
|
||||
.map_or(0, |preferences| {
|
||||
preferences.model_request_overages_spend_limit_in_cents
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateBillingPreferencesBody {
|
||||
github_user_id: i32,
|
||||
#[serde(default)]
|
||||
max_monthly_llm_usage_spending_in_cents: i32,
|
||||
#[serde(default)]
|
||||
model_request_overages_enabled: bool,
|
||||
#[serde(default)]
|
||||
model_request_overages_spend_limit_in_cents: i32,
|
||||
}
|
||||
|
||||
async fn update_billing_preferences(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Extension(rpc_server): Extension<Arc<crate::rpc::Server>>,
|
||||
extract::Json(body): extract::Json<UpdateBillingPreferencesBody>,
|
||||
) -> Result<Json<BillingPreferencesResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(body.github_user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
|
||||
|
||||
let max_monthly_llm_usage_spending_in_cents =
|
||||
body.max_monthly_llm_usage_spending_in_cents.max(0);
|
||||
let model_request_overages_spend_limit_in_cents =
|
||||
body.model_request_overages_spend_limit_in_cents.max(0);
|
||||
|
||||
let billing_preferences =
|
||||
if let Some(_billing_preferences) = app.db.get_billing_preferences(user.id).await? {
|
||||
app.db
|
||||
.update_billing_preferences(
|
||||
user.id,
|
||||
&UpdateBillingPreferencesParams {
|
||||
max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
|
||||
max_monthly_llm_usage_spending_in_cents,
|
||||
),
|
||||
model_request_overages_enabled: ActiveValue::set(
|
||||
body.model_request_overages_enabled,
|
||||
),
|
||||
model_request_overages_spend_limit_in_cents: ActiveValue::set(
|
||||
model_request_overages_spend_limit_in_cents,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
app.db
|
||||
.create_billing_preferences(
|
||||
user.id,
|
||||
&crate::db::CreateBillingPreferencesParams {
|
||||
max_monthly_llm_usage_spending_in_cents,
|
||||
model_request_overages_enabled: body.model_request_overages_enabled,
|
||||
model_request_overages_spend_limit_in_cents,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
SnowflakeRow::new(
|
||||
"Billing Preferences Updated",
|
||||
Some(user.metrics_id),
|
||||
user.admin,
|
||||
None,
|
||||
json!({
|
||||
"user_id": user.id,
|
||||
"model_request_overages_enabled": billing_preferences.model_request_overages_enabled,
|
||||
"model_request_overages_spend_limit_in_cents": billing_preferences.model_request_overages_spend_limit_in_cents,
|
||||
"max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents,
|
||||
}),
|
||||
)
|
||||
.write(&app.kinesis_client, &app.config.kinesis_stream)
|
||||
.await
|
||||
.log_err();
|
||||
|
||||
rpc_server.refresh_llm_tokens_for_user(user.id).await;
|
||||
|
||||
Ok(Json(BillingPreferencesResponse {
|
||||
trial_started_at: billing_customer
|
||||
.and_then(|billing_customer| billing_customer.trial_started_at)
|
||||
.map(|trial_started_at| {
|
||||
trial_started_at
|
||||
.and_utc()
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}),
|
||||
max_monthly_llm_usage_spending_in_cents: billing_preferences
|
||||
.max_monthly_llm_usage_spending_in_cents,
|
||||
model_request_overages_enabled: billing_preferences.model_request_overages_enabled,
|
||||
model_request_overages_spend_limit_in_cents: billing_preferences
|
||||
.model_request_overages_spend_limit_in_cents,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListBillingSubscriptionsParams {
|
||||
github_user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BillingSubscriptionJson {
|
||||
id: BillingSubscriptionId,
|
||||
name: String,
|
||||
status: StripeSubscriptionStatus,
|
||||
period: Option<BillingSubscriptionPeriodJson>,
|
||||
trial_end_at: Option<String>,
|
||||
cancel_at: Option<String>,
|
||||
/// Whether this subscription can be canceled.
|
||||
is_cancelable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BillingSubscriptionPeriodJson {
|
||||
start_at: String,
|
||||
end_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListBillingSubscriptionsResponse {
|
||||
subscriptions: Vec<BillingSubscriptionJson>,
|
||||
}
|
||||
|
||||
async fn list_billing_subscriptions(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<ListBillingSubscriptionsParams>,
|
||||
) -> Result<Json<ListBillingSubscriptionsResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(params.github_user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let subscriptions = app.db.get_billing_subscriptions(user.id).await?;
|
||||
|
||||
Ok(Json(ListBillingSubscriptionsResponse {
|
||||
subscriptions: subscriptions
|
||||
.into_iter()
|
||||
.map(|subscription| BillingSubscriptionJson {
|
||||
id: subscription.id,
|
||||
name: match subscription.kind {
|
||||
Some(SubscriptionKind::ZedPro) => "Zed Pro".to_string(),
|
||||
Some(SubscriptionKind::ZedProTrial) => "Zed Pro (Trial)".to_string(),
|
||||
Some(SubscriptionKind::ZedFree) => "Zed Free".to_string(),
|
||||
None => "Zed LLM Usage".to_string(),
|
||||
},
|
||||
status: subscription.stripe_subscription_status,
|
||||
period: maybe!({
|
||||
let start_at = subscription.current_period_start_at()?;
|
||||
let end_at = subscription.current_period_end_at()?;
|
||||
|
||||
Some(BillingSubscriptionPeriodJson {
|
||||
start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
})
|
||||
}),
|
||||
trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) {
|
||||
maybe!({
|
||||
let end_at = subscription.stripe_current_period_end?;
|
||||
let end_at = DateTime::from_timestamp(end_at, 0)?;
|
||||
|
||||
Some(end_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
cancel_at: subscription.stripe_cancel_at.map(|cancel_at| {
|
||||
cancel_at
|
||||
.and_utc()
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}),
|
||||
is_cancelable: subscription.kind != Some(SubscriptionKind::ZedFree)
|
||||
&& subscription.stripe_subscription_status.is_cancelable()
|
||||
&& subscription.stripe_cancel_at.is_none(),
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
|
||||
@@ -1157,157 +915,6 @@ async fn handle_customer_subscription_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetCurrentUsageParams {
|
||||
github_user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UsageCounts {
|
||||
pub used: i32,
|
||||
pub limit: Option<i32>,
|
||||
pub remaining: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelRequestUsage {
|
||||
pub model: String,
|
||||
pub mode: CompletionMode,
|
||||
pub requests: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CurrentUsage {
|
||||
pub model_requests: UsageCounts,
|
||||
pub model_request_usage: Vec<ModelRequestUsage>,
|
||||
pub edit_predictions: UsageCounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct GetCurrentUsageResponse {
|
||||
pub plan: String,
|
||||
pub current_usage: Option<CurrentUsage>,
|
||||
}
|
||||
|
||||
async fn get_current_usage(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<GetCurrentUsageParams>,
|
||||
) -> Result<Json<GetCurrentUsageResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(params.github_user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let feature_flags = app.db.get_user_flags(user.id).await?;
|
||||
let has_extended_trial = feature_flags
|
||||
.iter()
|
||||
.any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG);
|
||||
|
||||
let Some(llm_db) = app.llm_db.clone() else {
|
||||
return Err(Error::http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"LLM database not available".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let Some(subscription) = app.db.get_active_billing_subscription(user.id).await? else {
|
||||
return Ok(Json(GetCurrentUsageResponse::default()));
|
||||
};
|
||||
|
||||
let subscription_period = maybe!({
|
||||
let period_start_at = subscription.current_period_start_at()?;
|
||||
let period_end_at = subscription.current_period_end_at()?;
|
||||
|
||||
Some((period_start_at, period_end_at))
|
||||
});
|
||||
|
||||
let Some((period_start_at, period_end_at)) = subscription_period else {
|
||||
return Ok(Json(GetCurrentUsageResponse::default()));
|
||||
};
|
||||
|
||||
let usage = llm_db
|
||||
.get_subscription_usage_for_period(user.id, period_start_at, period_end_at)
|
||||
.await?;
|
||||
|
||||
let plan = subscription
|
||||
.kind
|
||||
.map(Into::into)
|
||||
.unwrap_or(zed_llm_client::Plan::ZedFree);
|
||||
|
||||
let model_requests_limit = match plan.model_requests_limit() {
|
||||
zed_llm_client::UsageLimit::Limited(limit) => {
|
||||
let limit = if plan == zed_llm_client::Plan::ZedProTrial && has_extended_trial {
|
||||
1_000
|
||||
} else {
|
||||
limit
|
||||
};
|
||||
|
||||
Some(limit)
|
||||
}
|
||||
zed_llm_client::UsageLimit::Unlimited => None,
|
||||
};
|
||||
|
||||
let edit_predictions_limit = match plan.edit_predictions_limit() {
|
||||
zed_llm_client::UsageLimit::Limited(limit) => Some(limit),
|
||||
zed_llm_client::UsageLimit::Unlimited => None,
|
||||
};
|
||||
|
||||
let Some(usage) = usage else {
|
||||
return Ok(Json(GetCurrentUsageResponse {
|
||||
plan: plan.as_str().to_string(),
|
||||
current_usage: Some(CurrentUsage {
|
||||
model_requests: UsageCounts {
|
||||
used: 0,
|
||||
limit: model_requests_limit,
|
||||
remaining: model_requests_limit,
|
||||
},
|
||||
model_request_usage: Vec::new(),
|
||||
edit_predictions: UsageCounts {
|
||||
used: 0,
|
||||
limit: edit_predictions_limit,
|
||||
remaining: edit_predictions_limit,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
let subscription_usage_meters = llm_db
|
||||
.get_current_subscription_usage_meters_for_user(user.id, Utc::now())
|
||||
.await?;
|
||||
|
||||
let model_request_usage = subscription_usage_meters
|
||||
.into_iter()
|
||||
.filter_map(|(usage_meter, _usage)| {
|
||||
let model = llm_db.model_by_id(usage_meter.model_id).ok()?;
|
||||
|
||||
Some(ModelRequestUsage {
|
||||
model: model.name.clone(),
|
||||
mode: usage_meter.mode,
|
||||
requests: usage_meter.requests,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(GetCurrentUsageResponse {
|
||||
plan: plan.as_str().to_string(),
|
||||
current_usage: Some(CurrentUsage {
|
||||
model_requests: UsageCounts {
|
||||
used: usage.model_requests,
|
||||
limit: model_requests_limit,
|
||||
remaining: model_requests_limit.map(|limit| (limit - usage.model_requests).max(0)),
|
||||
},
|
||||
model_request_usage,
|
||||
edit_predictions: UsageCounts {
|
||||
used: usage.edit_predictions,
|
||||
limit: edit_predictions_limit,
|
||||
remaining: edit_predictions_limit
|
||||
.map(|limit| (limit - usage.edit_predictions).max(0)),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
impl From<SubscriptionStatus> for StripeSubscriptionStatus {
|
||||
fn from(value: SubscriptionStatus) -> Self {
|
||||
match value {
|
||||
@@ -1416,18 +1023,21 @@ async fn sync_model_request_usage_with_stripe(
|
||||
let usage_meters = llm_db
|
||||
.get_current_subscription_usage_meters(Utc::now())
|
||||
.await?;
|
||||
let usage_meters = usage_meters
|
||||
.into_iter()
|
||||
.filter(|(_, usage)| !staff_user_ids.contains(&usage.user_id))
|
||||
.collect::<Vec<_>>();
|
||||
let user_ids = usage_meters
|
||||
.iter()
|
||||
.map(|(_, usage)| usage.user_id)
|
||||
.collect::<HashSet<UserId>>();
|
||||
let billing_subscriptions = app
|
||||
.db
|
||||
.get_active_zed_pro_billing_subscriptions(user_ids)
|
||||
.await?;
|
||||
let mut usage_meters_by_user_id =
|
||||
HashMap::<UserId, Vec<subscription_usage_meter::Model>>::default();
|
||||
for (usage_meter, usage) in usage_meters {
|
||||
let meters = usage_meters_by_user_id.entry(usage.user_id).or_default();
|
||||
meters.push(usage_meter);
|
||||
}
|
||||
|
||||
log::info!("Stripe usage sync: Retrieving Zed Pro subscriptions");
|
||||
let get_zed_pro_subscriptions_started_at = Utc::now();
|
||||
let billing_subscriptions = app.db.get_active_zed_pro_billing_subscriptions().await?;
|
||||
log::info!(
|
||||
"Stripe usage sync: Retrieved {} Zed Pro subscriptions in {}",
|
||||
billing_subscriptions.len(),
|
||||
Utc::now() - get_zed_pro_subscriptions_started_at
|
||||
);
|
||||
|
||||
let claude_sonnet_4 = stripe_billing
|
||||
.find_price_by_lookup_key("claude-sonnet-4-requests")
|
||||
@@ -1451,59 +1061,90 @@ async fn sync_model_request_usage_with_stripe(
|
||||
.find_price_by_lookup_key("claude-3-7-sonnet-requests-max")
|
||||
.await?;
|
||||
|
||||
let usage_meter_count = usage_meters.len();
|
||||
let model_mode_combinations = [
|
||||
("claude-opus-4", CompletionMode::Max),
|
||||
("claude-opus-4", CompletionMode::Normal),
|
||||
("claude-sonnet-4", CompletionMode::Max),
|
||||
("claude-sonnet-4", CompletionMode::Normal),
|
||||
("claude-3-7-sonnet", CompletionMode::Max),
|
||||
("claude-3-7-sonnet", CompletionMode::Normal),
|
||||
("claude-3-5-sonnet", CompletionMode::Normal),
|
||||
];
|
||||
|
||||
log::info!("Stripe usage sync: Syncing {usage_meter_count} usage meters");
|
||||
let billing_subscription_count = billing_subscriptions.len();
|
||||
|
||||
for (usage_meter, usage) in usage_meters {
|
||||
log::info!("Stripe usage sync: Syncing {billing_subscription_count} Zed Pro subscriptions");
|
||||
|
||||
for (user_id, (billing_customer, billing_subscription)) in billing_subscriptions {
|
||||
maybe!(async {
|
||||
let Some((billing_customer, billing_subscription)) =
|
||||
billing_subscriptions.get(&usage.user_id)
|
||||
else {
|
||||
bail!(
|
||||
"Attempted to sync usage meter for user who is not a Stripe customer: {}",
|
||||
usage.user_id
|
||||
);
|
||||
};
|
||||
if staff_user_ids.contains(&user_id) {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
|
||||
let stripe_customer_id =
|
||||
StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
|
||||
let stripe_subscription_id =
|
||||
StripeSubscriptionId(billing_subscription.stripe_subscription_id.clone().into());
|
||||
|
||||
let model = llm_db.model_by_id(usage_meter.model_id)?;
|
||||
let usage_meters = usage_meters_by_user_id.get(&user_id);
|
||||
|
||||
let (price, meter_event_name) = match model.name.as_str() {
|
||||
"claude-opus-4" => match usage_meter.mode {
|
||||
CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"),
|
||||
CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"),
|
||||
},
|
||||
"claude-sonnet-4" => match usage_meter.mode {
|
||||
CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"),
|
||||
CompletionMode::Max => (&claude_sonnet_4_max, "claude_sonnet_4/requests/max"),
|
||||
},
|
||||
"claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"),
|
||||
"claude-3-7-sonnet" => match usage_meter.mode {
|
||||
CompletionMode::Normal => (&claude_3_7_sonnet, "claude_3_7_sonnet/requests"),
|
||||
CompletionMode::Max => {
|
||||
(&claude_3_7_sonnet_max, "claude_3_7_sonnet/requests/max")
|
||||
for (model, mode) in &model_mode_combinations {
|
||||
let Ok(model) =
|
||||
llm_db.model(LanguageModelProvider::Anthropic, model)
|
||||
else {
|
||||
log::warn!("Failed to load model for user {user_id}: {model}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let (price, meter_event_name) = match model.name.as_str() {
|
||||
"claude-opus-4" => match mode {
|
||||
CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"),
|
||||
CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"),
|
||||
},
|
||||
"claude-sonnet-4" => match mode {
|
||||
CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"),
|
||||
CompletionMode::Max => {
|
||||
(&claude_sonnet_4_max, "claude_sonnet_4/requests/max")
|
||||
}
|
||||
},
|
||||
"claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"),
|
||||
"claude-3-7-sonnet" => match mode {
|
||||
CompletionMode::Normal => {
|
||||
(&claude_3_7_sonnet, "claude_3_7_sonnet/requests")
|
||||
}
|
||||
CompletionMode::Max => {
|
||||
(&claude_3_7_sonnet_max, "claude_3_7_sonnet/requests/max")
|
||||
}
|
||||
},
|
||||
model_name => {
|
||||
bail!("Attempted to sync usage meter for unsupported model: {model_name:?}")
|
||||
}
|
||||
},
|
||||
model_name => {
|
||||
bail!("Attempted to sync usage meter for unsupported model: {model_name:?}")
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
stripe_billing
|
||||
.subscribe_to_price(&stripe_subscription_id, price)
|
||||
.await?;
|
||||
stripe_billing
|
||||
.bill_model_request_usage(
|
||||
&stripe_customer_id,
|
||||
meter_event_name,
|
||||
usage_meter.requests,
|
||||
)
|
||||
.await?;
|
||||
let model_requests = usage_meters
|
||||
.and_then(|usage_meters| {
|
||||
usage_meters
|
||||
.iter()
|
||||
.find(|meter| meter.model_id == model.id && meter.mode == *mode)
|
||||
})
|
||||
.map(|usage_meter| usage_meter.requests)
|
||||
.unwrap_or(0);
|
||||
|
||||
if model_requests > 0 {
|
||||
stripe_billing
|
||||
.subscribe_to_price(&stripe_subscription_id, price)
|
||||
.await?;
|
||||
}
|
||||
|
||||
stripe_billing
|
||||
.bill_model_request_usage(&stripe_customer_id, meter_event_name, model_requests)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to bill model request usage of {model_requests} for {stripe_customer_id}: {meter_event_name}",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -1512,7 +1153,7 @@ async fn sync_model_request_usage_with_stripe(
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Stripe usage sync: Synced {usage_meter_count} usage meters in {:?}",
|
||||
"Stripe usage sync: Synced {billing_subscription_count} Zed Pro subscriptions in {}",
|
||||
Utc::now() - started_at
|
||||
);
|
||||
|
||||
|
||||
@@ -389,53 +389,58 @@ pub async fn post_panic(
|
||||
}
|
||||
}
|
||||
|
||||
let backtrace = if panic.backtrace.len() > 25 {
|
||||
let total = panic.backtrace.len();
|
||||
format!(
|
||||
"{}\n and {} more",
|
||||
panic
|
||||
.backtrace
|
||||
.iter()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
total - 20
|
||||
)
|
||||
} else {
|
||||
panic.backtrace.join("\n")
|
||||
};
|
||||
|
||||
if !report_to_slack(&panic) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let backtrace_with_summary = panic.payload + "\n" + &backtrace;
|
||||
|
||||
if let Some(slack_panics_webhook) = app.config.slack_panics_webhook.clone() {
|
||||
let backtrace = if panic.backtrace.len() > 25 {
|
||||
let total = panic.backtrace.len();
|
||||
format!(
|
||||
"{}\n and {} more",
|
||||
panic
|
||||
.backtrace
|
||||
.iter()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
total - 20
|
||||
)
|
||||
} else {
|
||||
panic.backtrace.join("\n")
|
||||
};
|
||||
let backtrace_with_summary = panic.payload + "\n" + &backtrace;
|
||||
|
||||
let version = if panic.release_channel == "nightly"
|
||||
&& !panic.app_version.contains("remote-server")
|
||||
&& let Some(sha) = panic.app_commit_sha
|
||||
{
|
||||
format!("Zed Nightly {}", sha.chars().take(7).collect::<String>())
|
||||
} else {
|
||||
panic.app_version
|
||||
};
|
||||
|
||||
let payload = slack::WebhookBody::new(|w| {
|
||||
w.add_section(|s| s.text(slack::Text::markdown("Panic request".to_string())))
|
||||
.add_section(|s| {
|
||||
s.add_field(slack::Text::markdown(format!(
|
||||
"*Version:*\n {} ",
|
||||
panic.app_version
|
||||
)))
|
||||
.add_field({
|
||||
let hostname = app.config.blob_store_url.clone().unwrap_or_default();
|
||||
let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
|
||||
hostname.strip_prefix("http://").unwrap_or_default()
|
||||
});
|
||||
s.add_field(slack::Text::markdown(format!("*Version:*\n {version} ",)))
|
||||
.add_field({
|
||||
let hostname = app.config.blob_store_url.clone().unwrap_or_default();
|
||||
let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
|
||||
hostname.strip_prefix("http://").unwrap_or_default()
|
||||
});
|
||||
|
||||
slack::Text::markdown(format!(
|
||||
"*{} {}:*\n<https://{}.{}/{}.json|{}…>",
|
||||
panic.os_name,
|
||||
panic.os_version.unwrap_or_default(),
|
||||
CRASH_REPORTS_BUCKET,
|
||||
hostname,
|
||||
incident_id,
|
||||
incident_id.chars().take(8).collect::<String>(),
|
||||
))
|
||||
})
|
||||
slack::Text::markdown(format!(
|
||||
"*{} {}:*\n<https://{}.{}/{}.json|{}…>",
|
||||
panic.os_name,
|
||||
panic.os_version.unwrap_or_default(),
|
||||
CRASH_REPORTS_BUCKET,
|
||||
hostname,
|
||||
incident_id,
|
||||
incident_id.chars().take(8).collect::<String>(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.add_rich_text(|r| r.add_preformatted(|p| p.add_text(backtrace_with_summary)))
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// A number of cents.
|
||||
#[derive(
|
||||
Debug,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Clone,
|
||||
Copy,
|
||||
derive_more::Add,
|
||||
derive_more::AddAssign,
|
||||
derive_more::Sub,
|
||||
derive_more::SubAssign,
|
||||
Serialize,
|
||||
)]
|
||||
pub struct Cents(pub u32);
|
||||
|
||||
impl Cents {
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
pub const fn new(cents: u32) -> Self {
|
||||
Self(cents)
|
||||
}
|
||||
|
||||
pub const fn from_dollars(dollars: u32) -> Self {
|
||||
Self(dollars * 100)
|
||||
}
|
||||
|
||||
pub fn saturating_sub(self, other: Cents) -> Self {
|
||||
Self(self.0.saturating_sub(other.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cents_new() {
|
||||
assert_eq!(Cents::new(50), Cents(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_from_dollars() {
|
||||
assert_eq!(Cents::from_dollars(1), Cents(100));
|
||||
assert_eq!(Cents::from_dollars(5), Cents(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_zero() {
|
||||
assert_eq!(Cents::ZERO, Cents(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_add() {
|
||||
assert_eq!(Cents(50) + Cents(30), Cents(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_add_assign() {
|
||||
let mut cents = Cents(50);
|
||||
cents += Cents(30);
|
||||
assert_eq!(cents, Cents(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_saturating_sub() {
|
||||
assert_eq!(Cents(50).saturating_sub(Cents(30)), Cents(20));
|
||||
assert_eq!(Cents(30).saturating_sub(Cents(50)), Cents(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cents_ordering() {
|
||||
assert!(Cents(50) > Cents(30));
|
||||
assert!(Cents(30) < Cents(50));
|
||||
assert_eq!(Cents(50), Cents(50));
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,6 @@ pub use tests::TestDb;
|
||||
|
||||
pub use ids::*;
|
||||
pub use queries::billing_customers::{CreateBillingCustomerParams, UpdateBillingCustomerParams};
|
||||
pub use queries::billing_preferences::{
|
||||
CreateBillingPreferencesParams, UpdateBillingPreferencesParams,
|
||||
};
|
||||
pub use queries::billing_subscriptions::{
|
||||
CreateBillingSubscriptionParams, UpdateBillingSubscriptionParams,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
use anyhow::Context as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CreateBillingPreferencesParams {
|
||||
pub max_monthly_llm_usage_spending_in_cents: i32,
|
||||
pub model_request_overages_enabled: bool,
|
||||
pub model_request_overages_spend_limit_in_cents: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct UpdateBillingPreferencesParams {
|
||||
pub max_monthly_llm_usage_spending_in_cents: ActiveValue<i32>,
|
||||
pub model_request_overages_enabled: ActiveValue<bool>,
|
||||
pub model_request_overages_spend_limit_in_cents: ActiveValue<i32>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Returns the billing preferences for the given user, if they exist.
|
||||
pub async fn get_billing_preferences(
|
||||
@@ -30,62 +14,4 @@ impl Database {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Creates new billing preferences for the given user.
|
||||
pub async fn create_billing_preferences(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
params: &CreateBillingPreferencesParams,
|
||||
) -> Result<billing_preference::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
let preferences = billing_preference::Entity::insert(billing_preference::ActiveModel {
|
||||
user_id: ActiveValue::set(user_id),
|
||||
max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
|
||||
params.max_monthly_llm_usage_spending_in_cents,
|
||||
),
|
||||
model_request_overages_enabled: ActiveValue::set(
|
||||
params.model_request_overages_enabled,
|
||||
),
|
||||
model_request_overages_spend_limit_in_cents: ActiveValue::set(
|
||||
params.model_request_overages_spend_limit_in_cents,
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(preferences)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the billing preferences for the given user.
|
||||
pub async fn update_billing_preferences(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
params: &UpdateBillingPreferencesParams,
|
||||
) -> Result<billing_preference::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
let preferences = billing_preference::Entity::update_many()
|
||||
.set(billing_preference::ActiveModel {
|
||||
max_monthly_llm_usage_spending_in_cents: params
|
||||
.max_monthly_llm_usage_spending_in_cents
|
||||
.clone(),
|
||||
model_request_overages_enabled: params.model_request_overages_enabled.clone(),
|
||||
model_request_overages_spend_limit_in_cents: params
|
||||
.model_request_overages_spend_limit_in_cents
|
||||
.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(billing_preference::Column::UserId.eq(user_id))
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(preferences
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("billing preferences not found")?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,33 @@ impl Database {
|
||||
|
||||
pub async fn get_active_zed_pro_billing_subscriptions(
|
||||
&self,
|
||||
) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut rows = billing_subscription::Entity::find()
|
||||
.inner_join(billing_customer::Entity)
|
||||
.select_also(billing_customer::Entity)
|
||||
.filter(
|
||||
billing_subscription::Column::StripeSubscriptionStatus
|
||||
.eq(StripeSubscriptionStatus::Active),
|
||||
)
|
||||
.filter(billing_subscription::Column::Kind.eq(SubscriptionKind::ZedPro))
|
||||
.order_by_asc(billing_subscription::Column::Id)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut subscriptions = HashMap::default();
|
||||
while let Some(row) = rows.next().await {
|
||||
if let (subscription, Some(customer)) = row? {
|
||||
subscriptions.insert(customer.user_id, (customer, subscription));
|
||||
}
|
||||
}
|
||||
Ok(subscriptions)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_active_zed_pro_billing_subscriptions_for_users(
|
||||
&self,
|
||||
user_ids: HashSet<UserId>,
|
||||
) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
|
||||
self.transaction(|tx| {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
mod cents;
|
||||
pub mod db;
|
||||
pub mod env;
|
||||
pub mod executor;
|
||||
@@ -21,7 +20,6 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
pub use cents::*;
|
||||
use db::{ChannelId, Database};
|
||||
use executor::Executor;
|
||||
use llm::db::LlmDatabase;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
pub mod db;
|
||||
mod token;
|
||||
|
||||
use crate::Cents;
|
||||
|
||||
pub use token::*;
|
||||
|
||||
pub const AGENT_EXTENDED_TRIAL_FEATURE_FLAG: &str = "agent-extended-trial";
|
||||
@@ -12,9 +10,3 @@ pub const BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG: &str = "bypass-account-age-chec
|
||||
|
||||
/// The minimum account age an account must have in order to use the LLM service.
|
||||
pub const MIN_ACCOUNT_AGE_FOR_LLM_USE: chrono::Duration = chrono::Duration::days(30);
|
||||
|
||||
/// The default value to use for maximum spend per month if the user did not
|
||||
/// explicitly set a maximum spend.
|
||||
///
|
||||
/// Used to prevent surprise bills.
|
||||
pub const DEFAULT_MAX_MONTHLY_SPEND: Cents = Cents::from_dollars(10);
|
||||
|
||||
@@ -4167,6 +4167,13 @@ async fn accept_terms_of_service(
|
||||
response.send(proto::AcceptTermsOfServiceResponse {
|
||||
accepted_tos_at: accepted_tos_at.timestamp() as u64,
|
||||
})?;
|
||||
|
||||
// When the user accepts the terms of service, we want to refresh their LLM
|
||||
// token to grant access.
|
||||
session
|
||||
.peer
|
||||
.send(session.connection_id, proto::RefreshLlmToken {})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::Result;
|
||||
use crate::db::billing_subscription::SubscriptionKind;
|
||||
use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
|
||||
use crate::stripe_client::{
|
||||
RealStripeClient, StripeBillingAddressCollection, StripeCheckoutSessionMode,
|
||||
StripeCheckoutSessionPaymentMethodCollection, StripeClient,
|
||||
RealStripeClient, StripeAutomaticTax, StripeBillingAddressCollection,
|
||||
StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection, StripeClient,
|
||||
StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
|
||||
StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
|
||||
StripeCreateMeterEventPayload, StripeCreateSubscriptionItems, StripeCreateSubscriptionParams,
|
||||
@@ -344,6 +344,7 @@ impl StripeBilling {
|
||||
price: Some(zed_free_price_id),
|
||||
quantity: Some(1),
|
||||
}],
|
||||
automatic_tax: Some(StripeAutomaticTax { enabled: true }),
|
||||
};
|
||||
|
||||
let subscription = self.client.create_subscription(params).await?;
|
||||
|
||||
@@ -73,6 +73,7 @@ pub enum StripeCancellationDetailsReason {
|
||||
pub struct StripeCreateSubscriptionParams {
|
||||
pub customer: StripeCustomerId,
|
||||
pub items: Vec<StripeCreateSubscriptionItems>,
|
||||
pub automatic_tax: Option<StripeAutomaticTax>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -224,6 +225,11 @@ pub struct StripeTaxIdCollection {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StripeAutomaticTax {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StripeCheckoutSession {
|
||||
pub url: Option<String>,
|
||||
|
||||
@@ -10,16 +10,17 @@ use stripe::{
|
||||
CreateCheckoutSessionSubscriptionData, CreateCheckoutSessionSubscriptionDataTrialSettings,
|
||||
CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior,
|
||||
CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod,
|
||||
CreateCustomer, Customer, CustomerId, ListCustomers, Price, PriceId, Recurring, Subscription,
|
||||
SubscriptionId, SubscriptionItem, SubscriptionItemId, UpdateCustomer, UpdateSubscriptionItems,
|
||||
UpdateSubscriptionTrialSettings, UpdateSubscriptionTrialSettingsEndBehavior,
|
||||
CreateCustomer, CreateSubscriptionAutomaticTax, Customer, CustomerId, ListCustomers, Price,
|
||||
PriceId, Recurring, Subscription, SubscriptionId, SubscriptionItem, SubscriptionItemId,
|
||||
UpdateCustomer, UpdateSubscriptionItems, UpdateSubscriptionTrialSettings,
|
||||
UpdateSubscriptionTrialSettingsEndBehavior,
|
||||
UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
|
||||
};
|
||||
|
||||
use crate::stripe_client::{
|
||||
CreateCustomerParams, StripeBillingAddressCollection, StripeCancellationDetails,
|
||||
StripeCancellationDetailsReason, StripeCheckoutSession, StripeCheckoutSessionMode,
|
||||
StripeCheckoutSessionPaymentMethodCollection, StripeClient,
|
||||
CreateCustomerParams, StripeAutomaticTax, StripeBillingAddressCollection,
|
||||
StripeCancellationDetails, StripeCancellationDetailsReason, StripeCheckoutSession,
|
||||
StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection, StripeClient,
|
||||
StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
|
||||
StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
|
||||
StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate,
|
||||
@@ -151,6 +152,7 @@ impl StripeClient for RealStripeClient {
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
create_subscription.automatic_tax = params.automatic_tax.map(Into::into);
|
||||
|
||||
let subscription = Subscription::create(&self.client, create_subscription).await?;
|
||||
|
||||
@@ -366,6 +368,15 @@ impl From<SubscriptionItem> for StripeSubscriptionItem {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StripeAutomaticTax> for CreateSubscriptionAutomaticTax {
|
||||
fn from(value: StripeAutomaticTax) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled,
|
||||
liability: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StripeSubscriptionTrialSettings> for UpdateSubscriptionTrialSettings {
|
||||
fn from(value: StripeSubscriptionTrialSettings) -> Self {
|
||||
Self {
|
||||
|
||||