Merge branch 'main' into handle-new-profile-form-in-migrate-settings-and-migrate-language-setting

This commit is contained in:
Joseph T. Lyons
2026-04-17 14:25:09 -04:00
293 changed files with 34250 additions and 18710 deletions
+3 -3
View File
@@ -42,12 +42,12 @@ jobs:
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
LATEST_TAG: ${{ steps.determine-version.outputs.tag }}
continue-on-error: true
- name: '@actions/upload-artifact compliance-report-${GITHUB_REF_NAME}.md'
- name: '@actions/upload-artifact compliance-report-${{ github.ref_name }}.md'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: compliance-report-${GITHUB_REF_NAME}.md
path: compliance-report-${GITHUB_REF_NAME}.md
name: compliance-report-${{ github.ref_name }}.md
path: compliance-report-${{ github.ref_name }}.md
if-no-files-found: error
- name: send_compliance_slack_notification
if: always()
+6 -6
View File
@@ -314,12 +314,12 @@ jobs:
GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }}
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
continue-on-error: true
- name: '@actions/upload-artifact compliance-report-${GITHUB_REF_NAME}.md'
- name: '@actions/upload-artifact compliance-report-${{ github.ref_name }}.md'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: compliance-report-${GITHUB_REF_NAME}.md
path: compliance-report-${GITHUB_REF_NAME}.md
name: compliance-report-${{ github.ref_name }}.md
path: compliance-report-${{ github.ref_name }}.md
if-no-files-found: error
- name: send_compliance_slack_notification
if: always()
@@ -682,12 +682,12 @@ jobs:
env:
GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }}
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- name: '@actions/upload-artifact compliance-report-${GITHUB_REF_NAME}.md'
- name: '@actions/upload-artifact compliance-report-${{ github.ref_name }}.md'
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: compliance-report-${GITHUB_REF_NAME}.md
path: compliance-report-${GITHUB_REF_NAME}.md
name: compliance-report-${{ github.ref_name }}.md
path: compliance-report-${{ github.ref_name }}.md
if-no-files-found: error
overwrite: true
- name: send_compliance_slack_notification
+17 -15
View File
@@ -6,6 +6,7 @@ env:
RUST_BACKTRACE: '1'
CARGO_INCREMENTAL: '0'
on:
merge_group: {}
pull_request:
branches:
- '**'
@@ -175,7 +176,7 @@ jobs:
clippy_windows:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: self-32vcpu-windows-2022
steps:
- name: steps::checkout_repo
@@ -205,7 +206,7 @@ jobs:
clippy_linux:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-16x32-ubuntu-2204
env:
CC: clang
@@ -243,7 +244,7 @@ jobs:
clippy_mac:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-mac-large
steps:
- name: steps::checkout_repo
@@ -274,7 +275,7 @@ jobs:
clippy_mac_x86_64:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-mac-large
steps:
- name: steps::checkout_repo
@@ -307,7 +308,7 @@ jobs:
run_tests_windows:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: self-32vcpu-windows-2022
steps:
- name: steps::checkout_repo
@@ -349,7 +350,7 @@ jobs:
run_tests_linux:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-16x32-ubuntu-2204
env:
CC: clang
@@ -407,7 +408,7 @@ jobs:
run_tests_mac:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-mac-large
steps:
- name: steps::checkout_repo
@@ -450,7 +451,7 @@ jobs:
doctests:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-16x32-ubuntu-2204
env:
CC: clang
@@ -494,7 +495,7 @@ jobs:
check_workspace_binaries:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-8x16-ubuntu-2204
env:
CC: clang
@@ -538,7 +539,7 @@ jobs:
build_visual_tests_binary:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-mac-large
steps:
- name: steps::checkout_repo
@@ -563,7 +564,7 @@ jobs:
check_wasm:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-8x16-ubuntu-2204
steps:
- name: steps::checkout_repo
@@ -589,9 +590,10 @@ jobs:
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
SCCACHE_BUCKET: sccache-zed
- name: run_tests::check_wasm::cargo_check_wasm
run: cargo +nightly -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform
run: cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform
env:
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: -C target-feature=+atomics,+bulk-memory,+mutable-globals
RUSTC_BOOTSTRAP: '1'
- name: steps::show_sccache_stats
run: sccache --show-stats || true
- name: steps::cleanup_cargo_config
@@ -602,7 +604,7 @@ jobs:
check_dependencies:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_tests == 'true'
if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-2x4-ubuntu-2404
env:
CC: clang
@@ -634,7 +636,7 @@ jobs:
check_docs:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_docs == 'true'
if: needs.orchestrate.outputs.run_docs == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-8x16-ubuntu-2204
env:
CC: clang
@@ -683,7 +685,7 @@ jobs:
check_licenses:
needs:
- orchestrate
if: needs.orchestrate.outputs.run_licenses == 'true'
if: needs.orchestrate.outputs.run_licenses == 'true' && github.event_name != 'merge_group'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: steps::checkout_repo
+1
View File
@@ -1,4 +1,5 @@
**/*.db
**/*.proptest-regressions
**/cargo-target
**/target
**/venv
Generated
+260 -217
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -57,6 +57,7 @@ members = [
"crates/edit_prediction",
"crates/edit_prediction_cli",
"crates/edit_prediction_context",
"crates/edit_prediction_metrics",
"crates/edit_prediction_types",
"crates/edit_prediction_ui",
"crates/editor",
@@ -102,6 +103,7 @@ members = [
"crates/http_client_tls",
"crates/icons",
"crates/image_viewer",
"crates/input_latency_ui",
"crates/inspector_ui",
"crates/install_cli",
"crates/journal",
@@ -356,6 +358,7 @@ image_viewer = { path = "crates/image_viewer" }
edit_prediction_types = { path = "crates/edit_prediction_types" }
edit_prediction_ui = { path = "crates/edit_prediction_ui" }
edit_prediction_context = { path = "crates/edit_prediction_context" }
input_latency_ui = { path = "crates/input_latency_ui" }
inspector_ui = { path = "crates/inspector_ui" }
install_cli = { path = "crates/install_cli" }
journal = { path = "crates/journal" }
@@ -480,6 +483,7 @@ zed_actions = { path = "crates/zed_actions" }
zed_credentials_provider = { path = "crates/zed_credentials_provider" }
zed_env_vars = { path = "crates/zed_env_vars" }
edit_prediction = { path = "crates/edit_prediction" }
edit_prediction_metrics = { path = "crates/edit_prediction_metrics" }
zeta_prompt = { path = "crates/zeta_prompt" }
zlog = { path = "crates/zlog" }
zlog_settings = { path = "crates/zlog_settings" }
@@ -562,7 +566,7 @@ derive_more = { version = "2.1.1", features = [
"mul_assign",
"not",
] }
dirs = "4.0"
dirs = "6.0"
documented = "0.9.1"
dotenvy = "0.15.0"
dunce = "1.0"
@@ -582,6 +586,7 @@ globset = "0.4"
heapless = "0.9.2"
handlebars = "4.3"
heck = "0.5"
hdrhistogram = "7"
heed = { version = "0.21.0", features = ["read-txn-no-tls"] }
hex = "0.4.3"
human_bytes = "0.4.1"
@@ -606,7 +611,7 @@ linkify = "0.10.0"
libwebrtc = "0.3.26"
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "c7396459fefc7886b4adfa3b596832405ae1e880" }
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "9bceaf7d06bd9394dc6ed002e27d306348d5b83d" }
mach2 = "0.5"
markup5ever_rcdom = "0.3.0"
metal = "0.33"
@@ -709,7 +714,7 @@ serde_json_lenient = { version = "0.2", features = [
serde_path_to_error = "0.1.17"
serde_urlencoded = "0.7"
sha2 = "0.10"
shellexpand = "2.1.0"
shellexpand = "3.1"
shlex = "1.3.0"
simplelog = "0.12.2"
slotmap = "1.0.6"
+4
View 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="M11.0885 2.44067C11.4162 2.44067 11.7304 2.57083 11.9621 2.80251C12.1938 3.0342 12.3239 3.34843 12.3239 3.67607V12.9416C12.3239 13.0498 12.2954 13.156 12.2414 13.2497C12.1874 13.3435 12.1098 13.4214 12.0162 13.4757C11.9227 13.53 11.8165 13.5587 11.7083 13.5591C11.6001 13.5594 11.4938 13.5314 11.3998 13.4777L8.61278 11.8853C8.42615 11.7787 8.21495 11.7226 8.00002 11.7226C7.78509 11.7226 7.57389 11.7787 7.38726 11.8853L4.6002 13.4777C4.50627 13.5314 4.3999 13.5594 4.29173 13.5591C4.18356 13.5587 4.07738 13.53 3.98382 13.4757C3.89026 13.4214 3.81259 13.3435 3.75859 13.2497C3.70459 13.156 3.67615 13.0498 3.67612 12.9416V3.67607C3.67612 3.34843 3.80627 3.0342 4.03796 2.80251C4.26964 2.57083 4.58387 2.44067 4.91152 2.44067H11.0885Z" stroke="#C6CAD0" stroke-width="1.11186" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.12" d="M11.0885 2.44067C11.4162 2.44067 11.7304 2.57083 11.9621 2.80251C12.1938 3.0342 12.3239 3.34843 12.3239 3.67607V12.9416C12.3239 13.0498 12.2954 13.156 12.2414 13.2497C12.1874 13.3435 12.1098 13.4214 12.0162 13.4757C11.9227 13.53 11.8165 13.5587 11.7083 13.5591C11.6001 13.5594 11.4938 13.5314 11.3998 13.4777L8.61278 11.8853C8.42615 11.7787 8.21495 11.7226 8.00002 11.7226C7.78509 11.7226 7.57389 11.7787 7.38726 11.8853L4.6002 13.4777C4.50627 13.5314 4.3999 13.5594 4.29173 13.5591C4.18356 13.5587 4.07738 13.53 3.98382 13.4757C3.89026 13.4214 3.81259 13.3435 3.75859 13.2497C3.70459 13.156 3.67615 13.0498 3.67612 12.9416V3.67607C3.67612 3.34843 3.80627 3.0342 4.03796 2.80251C4.26964 2.57083 4.58387 2.44067 4.91152 2.44067H11.0885Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="1.11186" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+4
View 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 4.87936V8L10.229 9.33742" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.6863 11.3137 2 8 2C4.6863 2 2 4.6863 2 8C2 11.3137 4.6863 14 8 14Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

+6 -1
View File
@@ -1 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5 3v7M11.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM4.5 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM11 6a5 5 0 0 1-5 5"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 13C5.32843 13 6 12.3284 6 11.5C6 10.6716 5.32843 10 4.5 10C3.67157 10 3 10.6716 3 11.5C3 12.3284 3.67157 13 4.5 13Z" stroke="#C6CAD0" stroke-width="1.2"/>
<path d="M11.5 6C12.3284 6 13 5.3284 13 4.5C13 3.6716 12.3284 3 11.5 3C10.6716 3 10 3.6716 10 4.5C10 5.3284 10.6716 6 11.5 6Z" stroke="#C6CAD0" stroke-width="1.2"/>
<path d="M4.5 10L4.5 3" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round"/>
<path d="M10 4.44133C8.54131 4.44133 7.14236 5.02697 6.11091 6.06943C5.07946 7.11188 4.5 8.52575 4.5 10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 712 B

-7
View File
@@ -1,7 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 14C5.32843 14 6 13.3284 6 12.5C6 11.6716 5.32843 11 4.5 11C3.67157 11 3 11.6716 3 12.5C3 13.3284 3.67157 14 4.5 14Z" stroke="black" stroke-width="1.2"/>
<path d="M4.5 11V5.5" stroke="black" stroke-width="1.2"/>
<path d="M4.5 10C4.5 10 4.875 8 6.5 8C7.29195 8 9.00787 8 9.87553 8C10.773 8 11.5 7.32843 11.5 6.5V5.5" stroke="black" stroke-width="1.2"/>
<path d="M4.5 6C5.32843 6 6 5.32843 6 4.5C6 3.67157 5.32843 3 4.5 3C3.67157 3 3 3.67157 3 4.5C3 5.32843 3.67157 6 4.5 6Z" stroke="black" stroke-width="1.2"/>
<path d="M11.5 6C12.3284 6 13 5.32843 13 4.5C13 3.67157 12.3284 3 11.5 3C10.6716 3 10 3.67157 10 4.5C10 5.32843 10.6716 6 11.5 6Z" stroke="black" stroke-width="1.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 793 B

+2 -2
View File
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 2V10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6C12.5304 6 13.0391 5.78929 13.4142 5.41421C13.7893 5.03914 14 4.53043 14 4C14 3.46957 13.7893 2.96086 13.4142 2.58579C13.0391 2.21071 12.5304 2 12 2C11.4696 2 10.9609 2.21071 10.5858 2.58579C10.2107 2.96086 10 3.46957 10 4C10 4.53043 10.2107 5.03914 10.5858 5.41421C10.9609 5.78929 11.4696 6 12 6Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 14C4.53043 14 5.03914 13.7893 5.41421 13.4142C5.78929 13.0391 6 12.5304 6 12C6 11.4696 5.78929 10.9609 5.41421 10.5858C5.03914 10.2107 4.53043 10 4 10C3.46957 10 2.96086 10.2107 2.58579 10.5858C2.21071 10.9609 2 11.4696 2 12C2 12.5304 2.21071 13.0391 2.58579 13.4142C2.96086 13.7893 3.46957 14 4 14Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 4C8.4087 4 6.88258 4.63214 5.75736 5.75736C4.63214 6.88258 4 8.4087 4 10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 10V14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 12H10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 13C4.82843 13 5.5 12.3284 5.5 11.5C5.5 10.6716 4.82843 10 4 10C3.17157 10 2.5 10.6716 2.5 11.5C2.5 12.3284 3.17157 13 4 13Z" stroke="#C6CAD0" stroke-width="1.2"/>
<path d="M11.5 5.5C12.3284 5.5 13 4.8284 13 4C13 3.1716 12.3284 2.5 11.5 2.5C10.6716 2.5 10 3.1716 10 4C10 4.8284 10.6716 5.5 11.5 5.5Z" stroke="#C6CAD0" stroke-width="1.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 938 B

+5 -5
View File
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.99567 13.0812C8.93101 13.0812 9.68925 12.3229 9.68925 11.3876C9.68925 10.4522 8.93101 9.694 7.99567 9.694C7.06033 9.694 6.30209 10.4522 6.30209 11.3876C6.30209 12.3229 7.06033 13.0812 7.99567 13.0812Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.61023 6.30643C5.54557 6.30643 6.30381 5.54819 6.30381 4.61286C6.30381 3.67752 5.54557 2.91928 4.61023 2.91928C3.6749 2.91928 2.91666 3.67752 2.91666 4.61286C2.91666 5.54819 3.6749 6.30643 4.61023 6.30643Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3915 6.30643C12.3268 6.30643 13.0851 5.54819 13.0851 4.61286C13.0851 3.67752 12.3268 2.91928 11.3915 2.91928C10.4561 2.91928 9.69791 3.67752 9.69791 4.61286C9.69791 5.54819 10.4561 6.30643 11.3915 6.30643Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3889 6.306V7.43505C11.3889 7.77377 11.1631 7.99958 10.8244 7.99958H5.17912C4.8404 7.99958 4.61459 7.77377 4.61459 7.43505V6.306" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 8V9.69358" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.81506 7.834L11.3571 3.29195" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.53091 9.5509L11.3556 12.3756" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.28955 7.834H6.80326" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.7743 6.31279V2.87418H8.33571" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.33417 12.7932L11.7728 12.7932L11.7728 9.35463" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 746 B

+7 -9
View File
@@ -1,11 +1,9 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" id="svg1378540956_510">
<g clip-path="url(#svg1378540956_510_clip0_1_1506)" transform="translate(4, 4) scale(0.857)">
<path d="M17.0547 0.372066H8.52652L-0.00165176 8.90024V17.4284H8.52652V8.90024H17.0547V0.372066Z" fill="#1A1C20"></path>
<path d="M10.1992 27.6279H18.7274L27.2556 19.0998V10.5716H18.7274V19.0998H10.1992V27.6279Z" fill="#1A1C20"></path>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_4326_1680" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="1" y="1" width="14" height="14">
<path d="M14.6738 1.32619H1.32619V14.6738H14.6738V1.32619Z" fill="white"/>
</mask>
<g mask="url(#mask0_4326_1680)">
<path d="M9.6781 1.32619H5.50173L1.32536 5.50256V9.67892H5.50173V5.50256H9.6781V1.32619Z" fill="#C6CAD0"/>
<path d="M6.32088 14.6738H10.4973L14.6736 10.4974V6.32104H10.4973V10.4974H6.32088V14.6738Z" fill="#C6CAD0"/>
</g>
<defs>
<clipPath id="svg1378540956_510_clip0_1_1506">
<rect width="27.2559" height="27.2559" fill="white" transform="translate(0 0.37207)"></rect>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 558 B

+5 -1
View File
@@ -1 +1,5 @@
<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-list-filter-icon lucide-list-filter"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4H14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66666 8H11.3333" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66666 12H9.33332" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 435 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31947 5.03803L8.31947 9.28259" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.19576 7.67419L8.31948 9.79792L10.4432 7.67419" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.64894 12.8952C6.89401 13.5339 8.32626 13.7069 9.68759 13.383C11.0489 13.0592 12.2499 12.2598 13.0739 11.1288C13.8979 9.99787 14.291 8.60973 14.1821 7.21464C14.0733 5.81955 13.4698 4.5092 12.4803 3.51972C11.4908 2.53024 10.1805 1.92671 8.78535 1.81787C7.39026 1.70904 6.00218 2.10207 4.87122 2.92612C3.74026 3.75018 2.94082 4.95106 2.61695 6.3124C2.29307 7.67374 2.46606 9.10598 3.10475 10.3511L1.80005 14.1999L5.64894 12.8952Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 900 B

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 176 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="61" fill="none"><g clip-path="url(#a)"><path fill="#000" d="M130.75.385c5.428 0 10.297 2.81 13.011 7.511l14.214 24.618-.013-.005c2.599 4.504 2.707 9.932.28 14.513-2.618 4.944-7.862 8.015-13.679 8.015h-31.811c-.452 0-.873-.242-1.103-.637a1.268 1.268 0 0 1 0-1.274l3.919-6.78c.223-.394.65-.636 1.102-.636h28.288a5.622 5.622 0 0 0 4.925-2.849 5.615 5.615 0 0 0 0-5.69l-14.214-24.617a5.621 5.621 0 0 0-4.925-2.848 5.621 5.621 0 0 0-4.925 2.848l-14.214 24.618a6.267 6.267 0 0 0-.319.643.998.998 0 0 1-.069.14L101.724 54.4l-.823 1.313-2.529 4.39a1.27 1.27 0 0 1-1.103.636h-7.83c-.452 0-.873-.242-1.102-.637-.23-.394-.23-.879 0-1.274l2.188-3.791H66.803c-3.32 0-6.454-1.122-8.818-3.167a17.141 17.141 0 0 1-3.394-3.96 1.261 1.261 0 0 1-.091-.137L34.2 12.573a5.622 5.622 0 0 0-4.925-2.849 5.621 5.621 0 0 0-4.924 2.85L10.137 37.19a5.615 5.615 0 0 0 0 5.69 5.63 5.63 0 0 0 4.925 2.841h29.862a1.276 1.276 0 0 1 1.102 1.912l-3.912 6.778a1.27 1.27 0 0 1-1.102.638H14.495c-3.32 0-6.454-1.128-8.817-3.173-5.906-5.104-7.36-12.883-3.62-19.363L16.267 7.89C18.872 3.385 23.517.583 28.697.39c.184-.006.356-.006.534-.006 5.378 0 10.45 3.007 13.246 7.85l12.986 22.372L68.58 7.891C71.186 3.385 75.83.582 81.01.39c.185-.006.358-.006.536-.006 4.453 0 8.71 2.039 11.672 5.588.337.407.388.98.127 1.446l-3.765 6.6a1.268 1.268 0 0 1-2.205.006l-.847-1.465a5.623 5.623 0 0 0-4.926-2.848 5.622 5.622 0 0 0-4.924 2.848L62.464 37.18a5.614 5.614 0 0 0 0 5.689 5.628 5.628 0 0 0 4.925 2.842H95.91L117.76 7.87c2.714-4.683 7.575-7.486 12.99-7.486Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .385h160v60.36H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-46
View File
@@ -1,46 +0,0 @@
<svg width="257" height="47" viewBox="0 0 257 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_100_26)">
<path d="M119.922 24.4481L109.212 5.8996C107.081 2.20815 103.26 0 98.9973 0C94.7394 0 90.9279 2.19855 88.7918 5.8804L66.6239 44.2734C66.3646 44.7198 66.3646 45.2671 66.6239 45.7135C66.8831 46.1599 67.3583 46.4336 67.8719 46.4336H73.7715C74.2852 46.4336 74.7604 46.1599 75.0196 45.7135L76.9302 42.4013L77.5158 41.4652C77.5158 41.4652 77.535 41.4364 77.5398 41.422L84.6923 29.0324C84.7211 28.9844 84.7499 28.9316 84.7691 28.874C84.8363 28.7203 84.9035 28.5763 84.9851 28.4419L95.6946 9.89347C96.3811 8.70299 97.6148 7.98774 98.9925 7.98774C100.37 7.98774 101.604 8.69819 102.29 9.89347L113 28.4419C113.686 29.6324 113.691 31.0581 113 32.2486C112.313 33.4391 111.08 34.1543 109.702 34.1543H102.232C101.718 34.1543 101.243 34.4279 100.984 34.8744L98.0318 39.9819C97.7726 40.4283 97.7726 40.9756 98.0318 41.422C98.291 41.8684 98.7662 42.1421 99.2799 42.1421H109.404C113.965 42.1421 118.079 39.7275 120.133 35.844C122.039 32.2438 121.957 27.9859 119.912 24.4433L119.922 24.4481Z" fill="white"/>
<path d="M82.8564 34.1538H104.17L103.872 42.1416H79.9042C79.3905 42.1416 78.9153 41.8679 78.6561 41.4215C78.3969 40.9751 78.3969 40.4278 78.6561 39.9814L81.6083 34.8739C81.8675 34.4274 82.3427 34.1538 82.8564 34.1538Z" fill="url(#paint0_linear_100_26)"/>
<path d="M43.7123 25.1535C43.7123 24.9039 43.6451 24.659 43.5155 24.443L32.8972 6.14897C30.7131 2.36152 26.7288 -0.000244141 22.5093 -0.000244141C22.3701 -0.000244141 22.2309 -0.000244141 22.0869 0.00935651C18.0162 0.158167 14.368 2.36152 12.323 5.89936L1.61829 24.4478C-1.31951 29.5314 -0.181833 35.6374 4.44568 39.6361C6.31301 41.2538 8.78518 42.1418 11.4062 42.1418H31.3851C31.8987 42.1418 32.374 41.8682 32.6332 41.4218L35.5806 36.3142C35.8398 35.8678 35.8398 35.3206 35.5806 34.8741C35.3214 34.4277 34.8461 34.1541 34.3325 34.1541H11.8334C10.4557 34.1541 9.22201 33.4436 8.53556 32.2532C7.84431 31.0627 7.84431 29.6418 8.53556 28.4465L19.2451 9.89803C19.9315 8.70755 21.1652 7.9923 22.5429 7.9923C23.9206 7.9923 25.1495 8.70275 25.8407 9.89803L41.1346 36.4198C41.461 36.9863 42.1282 37.2599 42.7619 37.0919C43.3955 36.9191 43.8276 36.343 43.8228 35.6902L43.7171 25.1583L43.7123 25.1535Z" fill="url(#paint1_linear_100_26)"/>
<path d="M4.44544 39.6362C-0.182077 35.6376 -1.31975 29.5315 1.61805 24.448L8.53532 28.4467C7.84407 29.6419 7.84407 31.0628 8.53532 32.2533C9.22176 33.4438 10.4554 34.1543 11.8331 34.1543H34.3323C34.8459 34.1543 35.3211 34.4279 35.5804 34.8743C35.8396 35.3207 35.8396 35.868 35.5804 36.3144L32.633 41.422C32.3737 41.8684 31.8985 42.142 31.3849 42.142H11.4059C8.78493 42.142 6.31276 41.2539 4.44544 39.6362Z" fill="white"/>
<path d="M74.6308 34.8696C74.3716 34.4231 73.8964 34.1495 73.3827 34.1495H51.2532C49.8755 34.1495 48.6418 33.4391 47.9554 32.2486C47.2642 31.0581 47.2642 29.6372 47.9554 28.4419L58.6649 9.89347C59.3514 8.70299 60.5851 7.98774 61.9628 7.98774C63.3404 7.98774 64.5693 8.69819 65.2606 9.89347L65.899 10.9975C66.1582 11.444 66.6335 11.7176 67.1471 11.7176C67.6607 11.7176 68.1408 11.4392 68.3952 10.9927L71.2322 6.01961C71.5298 5.49637 71.4722 4.84353 71.0882 4.3827C68.7648 1.59851 65.4238 0 61.9291 0C61.7899 0 61.6507 0 61.5067 0.00960065C57.436 0.158411 53.7878 2.36176 51.7429 5.8996L41.0333 24.4481C38.0955 29.5316 39.2332 35.6376 43.8607 39.6363C45.728 41.254 48.2002 42.1421 50.8212 42.1421H70.4257C70.9394 42.1421 71.4146 41.8684 71.6738 41.422L74.626 36.3145C74.8852 35.868 74.8852 35.3208 74.626 34.8744L74.6308 34.8696Z" fill="url(#paint2_linear_100_26)"/>
</g>
<path d="M132.976 15.5649V14.6449C133.383 14.6449 133.695 14.5359 133.911 14.3178C134.128 14.0998 134.311 13.8067 134.461 13.4388C134.61 13.0571 134.773 12.6346 134.949 12.1712L139.057 0.865676H139.871L144.325 12.3348C144.434 12.5937 144.556 12.9481 144.692 13.3979C144.827 13.834 144.888 14.2088 144.875 14.5223C145.105 14.495 145.329 14.4814 145.546 14.4814C145.776 14.4678 145.993 14.4541 146.197 14.4405V15.5649H141.254V14.6654C141.729 14.6518 142.041 14.5495 142.19 14.3587C142.352 14.1543 142.413 13.909 142.373 13.6227C142.346 13.3365 142.278 13.0503 142.169 12.7641L141.559 11.1081L136.576 11.2308L135.986 13.0094C135.905 13.282 135.817 13.541 135.722 13.7863C135.627 14.0316 135.532 14.2701 135.437 14.5018C135.681 14.4746 135.939 14.461 136.21 14.461C136.495 14.4473 136.745 14.4337 136.962 14.4201V15.5649H132.976ZM136.942 10.1268H141.213L139.932 6.67178C139.769 6.22201 139.613 5.77906 139.464 5.34292C139.315 4.89315 139.179 4.46382 139.057 4.05494H139.037C138.929 4.39568 138.807 4.77048 138.671 5.17937C138.535 5.58825 138.379 6.0312 138.203 6.50823L136.942 10.1268Z" fill="white"/>
<path d="M151.397 21.1053C150.204 21.1053 149.241 20.969 148.509 20.6964C147.791 20.4374 147.268 20.0967 146.943 19.6742C146.631 19.2653 146.475 18.8292 146.475 18.3658C146.475 17.9978 146.557 17.657 146.719 17.3436C146.896 17.0301 147.126 16.7507 147.411 16.5054C147.696 16.2737 148.014 16.0897 148.367 15.9534C147.892 15.8034 147.533 15.5922 147.289 15.3196C147.045 15.0334 146.923 14.6858 146.923 14.277C146.923 13.8408 147.092 13.4115 147.431 12.989C147.784 12.5665 148.285 12.2803 148.936 12.1303C148.326 11.8577 147.838 11.4625 147.472 10.9446C147.106 10.4267 146.916 9.81334 146.902 9.10462C146.889 8.28686 147.092 7.57132 147.513 6.95799C147.946 6.34467 148.496 5.86765 149.16 5.52691C149.838 5.18618 150.543 5.01581 151.275 5.01581C151.696 5.01581 152.13 5.07714 152.577 5.19981C153.025 5.30884 153.431 5.48603 153.798 5.73135C153.947 5.3361 154.143 4.97492 154.387 4.64782C154.631 4.32072 154.916 4.06176 155.242 3.87095C155.581 3.68014 155.933 3.58473 156.299 3.58473C156.693 3.58473 156.991 3.68695 157.194 3.89139C157.398 4.09583 157.499 4.36842 157.499 4.70915C157.499 4.81819 157.465 4.95448 157.398 5.11803C157.343 5.26796 157.242 5.39743 157.093 5.50647C156.943 5.6155 156.74 5.67002 156.482 5.67002C156.265 5.67002 156.069 5.60187 155.893 5.46558C155.73 5.31566 155.628 5.13848 155.587 4.93404C155.303 4.98855 155.065 5.15892 154.876 5.44514C154.699 5.71772 154.584 5.99713 154.53 6.28334C154.882 6.59682 155.154 6.97162 155.343 7.40776C155.547 7.83027 155.648 8.28686 155.648 8.77751C155.648 9.55439 155.438 10.2427 155.018 10.8424C154.611 11.4421 154.076 11.9123 153.411 12.253C152.747 12.5937 152.028 12.7641 151.255 12.7641C151.025 12.7641 150.808 12.7505 150.604 12.7232C150.401 12.6823 150.177 12.6619 149.933 12.6619C149.499 12.6619 149.14 12.7641 148.855 12.9685C148.584 13.173 148.482 13.4183 148.55 13.7045C148.618 14.0044 148.902 14.202 149.404 14.2974C149.906 14.3928 150.631 14.461 151.581 14.5018C152.625 14.5427 153.52 14.6722 154.265 14.8903C155.025 15.0947 155.608 15.415 156.015 15.8511C156.435 16.2873 156.645 16.8802 156.645 17.6298C156.645 18.1886 156.489 18.6861 156.177 19.1222C155.879 19.5583 155.472 19.9195 154.957 20.2057C154.455 20.5056 153.892 20.7305 153.269 20.8804C152.645 21.0303 152.021 21.1053 151.397 21.1053ZM151.581 20.1035C152.679 20.1035 153.526 19.8991 154.123 19.4902C154.733 19.0813 155.038 18.6179 155.038 18.1C155.038 17.6638 154.889 17.3231 154.591 17.0778C154.292 16.8461 153.872 16.6757 153.33 16.5667C152.801 16.4713 152.177 16.4168 151.458 16.4031C151.106 16.3759 150.747 16.3554 150.38 16.3418C150.014 16.3282 149.675 16.2941 149.363 16.2396C149.052 16.4577 148.808 16.7234 148.631 17.0369C148.469 17.3504 148.38 17.6775 148.367 18.0182C148.367 18.6452 148.658 19.1494 149.241 19.5311C149.825 19.9127 150.604 20.1035 151.581 20.1035ZM151.316 11.8032C151.777 11.8032 152.157 11.6874 152.455 11.4557C152.767 11.2104 152.998 10.8901 153.147 10.4948C153.309 10.0859 153.391 9.64298 153.391 9.16595C153.391 8.60715 153.309 8.08242 153.147 7.59176C152.984 7.1011 152.747 6.70585 152.435 6.40601C152.123 6.10616 151.723 5.95624 151.235 5.95624C150.57 5.95624 150.055 6.22201 149.689 6.75356C149.323 7.2851 149.14 7.92568 149.14 8.67529C149.14 9.27499 149.221 9.81334 149.384 10.2904C149.56 10.7538 149.811 11.1218 150.136 11.3943C150.462 11.6669 150.855 11.8032 151.316 11.8032Z" fill="white"/>
<path d="M162.5 15.892C161.551 15.892 160.696 15.674 159.937 15.2378C159.191 14.7881 158.601 14.1611 158.168 13.357C157.734 12.5392 157.517 11.5783 157.517 10.4744C157.517 9.52031 157.727 8.62759 158.147 7.7962C158.581 6.96481 159.185 6.29697 159.957 5.79269C160.73 5.27477 161.625 5.01581 162.642 5.01581C163.212 5.01581 163.747 5.1044 164.249 5.28159C164.764 5.45877 165.219 5.73817 165.612 6.11979C166.019 6.48778 166.337 6.97162 166.568 7.57132C166.798 8.15738 166.914 8.87292 166.914 9.71794L159.917 9.8406C159.917 10.7674 160.018 11.592 160.222 12.3143C160.439 13.0367 160.785 13.5955 161.259 13.9907C161.734 14.386 162.351 14.5836 163.11 14.5836C163.476 14.5836 163.863 14.5223 164.27 14.3996C164.69 14.2633 165.083 14.0725 165.449 13.8272C165.829 13.5819 166.148 13.2888 166.405 12.9481L167.036 13.5001C166.629 14.1134 166.161 14.5972 165.632 14.9516C165.103 15.2923 164.561 15.5309 164.005 15.6672C163.463 15.8171 162.961 15.892 162.5 15.892ZM159.998 8.77751H164.697C164.697 8.2596 164.622 7.7962 164.473 7.38732C164.337 6.96481 164.12 6.63089 163.822 6.38556C163.524 6.14023 163.144 6.01757 162.683 6.01757C161.964 6.01757 161.374 6.24927 160.913 6.71267C160.452 7.16244 160.147 7.85072 159.998 8.77751Z" fill="white"/>
<path d="M168.419 15.5649V14.7063C168.826 14.7063 169.111 14.6109 169.273 14.4201C169.436 14.2293 169.531 13.9635 169.558 13.6227C169.599 13.282 169.619 12.8868 169.619 12.437L169.64 8.18464C169.64 7.96657 169.64 7.73487 169.64 7.48954C169.653 7.23058 169.68 6.97844 169.721 6.73311C169.477 6.74674 169.226 6.76037 168.968 6.774C168.724 6.774 168.494 6.78081 168.277 6.79444V5.71091C168.887 5.71091 169.362 5.68365 169.701 5.62913C170.053 5.56099 170.318 5.48603 170.494 5.40425C170.684 5.32247 170.826 5.23388 170.921 5.13848H171.653C171.667 5.26114 171.673 5.37699 171.673 5.48603C171.687 5.59506 171.694 5.71091 171.694 5.83357C171.707 5.95624 171.721 6.11298 171.735 6.30379C172.033 6.05846 172.358 5.84039 172.711 5.64958C173.063 5.45877 173.429 5.30884 173.809 5.19981C174.202 5.07714 174.575 5.01581 174.928 5.01581C176.135 5.01581 177.003 5.38381 177.531 6.11979C178.074 6.85578 178.345 8.00745 178.345 9.57483V13.0299C178.345 13.2752 178.338 13.5273 178.325 13.7863C178.311 14.0316 178.291 14.2838 178.264 14.5427C178.481 14.5291 178.697 14.5223 178.914 14.5223C179.145 14.5087 179.355 14.495 179.545 14.4814V15.5649H175.009V14.7063C175.416 14.7063 175.701 14.6109 175.863 14.4201C176.026 14.2293 176.121 13.9635 176.148 13.6227C176.189 13.282 176.209 12.8868 176.209 12.437V9.57483C176.196 8.49811 176.019 7.68717 175.68 7.14199C175.355 6.59682 174.826 6.33105 174.094 6.34467C173.66 6.34467 173.233 6.45371 172.813 6.67178C172.392 6.88985 172.04 7.15562 171.755 7.4691C171.755 7.60539 171.755 7.75531 171.755 7.91886C171.755 8.06879 171.755 8.22552 171.755 8.38908V13.0299C171.755 13.2752 171.748 13.5273 171.735 13.7863C171.721 14.0316 171.701 14.2838 171.673 14.5427C171.89 14.5291 172.107 14.5223 172.324 14.5223C172.555 14.5087 172.765 14.495 172.955 14.4814V15.5649H168.419Z" fill="white"/>
<path d="M184.495 15.892C184.156 15.892 183.81 15.8443 183.458 15.7489C183.119 15.6671 182.807 15.4968 182.522 15.2378C182.237 14.9652 182.014 14.5768 181.851 14.0725C181.688 13.5682 181.607 12.9004 181.607 12.069L181.648 6.48778H180.142V5.34292C180.509 5.32929 180.881 5.19299 181.261 4.93404C181.641 4.67508 181.973 4.32753 182.258 3.89139C182.543 3.45525 182.739 2.98504 182.848 2.48075H183.804V5.34292H186.936V6.40601L183.804 6.4469L183.763 11.9054C183.763 12.4234 183.804 12.8799 183.885 13.2752C183.98 13.6568 184.129 13.9567 184.332 14.1747C184.549 14.3792 184.841 14.4814 185.207 14.4814C185.519 14.4814 185.838 14.386 186.163 14.1952C186.502 14.0044 186.821 13.6909 187.119 13.2548L187.811 13.8681C187.485 14.3451 187.16 14.7199 186.834 14.9925C186.509 15.2651 186.197 15.4627 185.899 15.5854C185.6 15.7217 185.329 15.8034 185.085 15.8307C184.841 15.8716 184.644 15.892 184.495 15.892Z" fill="white"/>
<path d="M188.83 21.1053L187.508 20.2671L199.224 0.0683594L200.708 1.00878L188.83 21.1053Z" fill="white"/>
<path d="M212.664 5.81313C212.515 4.58649 212.095 3.64606 211.403 2.99185C210.712 2.32402 209.81 1.98328 208.698 1.96965C207.993 1.96965 207.342 2.12639 206.745 2.43987C206.149 2.75334 205.633 3.19629 205.199 3.76873C204.766 4.32753 204.427 4.99537 204.182 5.77224C203.952 6.53549 203.837 7.37369 203.837 8.28686C203.837 9.52713 204.054 10.6311 204.488 11.5988C204.921 12.5528 205.518 13.2956 206.277 13.8272C207.05 14.3587 207.925 14.6245 208.901 14.6245C209.769 14.6245 210.623 14.4201 211.464 14.0112C212.318 13.5887 213.023 12.9958 213.579 12.2326L214.251 12.805C213.64 13.65 212.99 14.2974 212.298 14.7472C211.606 15.1969 210.915 15.4968 210.223 15.6467C209.545 15.8103 208.928 15.892 208.372 15.892C207.355 15.892 206.42 15.708 205.566 15.34C204.711 14.9721 203.972 14.461 203.349 13.8067C202.738 13.1389 202.264 12.3688 201.925 11.4966C201.599 10.6107 201.437 9.65661 201.437 8.63441C201.437 7.68035 201.586 6.74674 201.884 5.83357C202.182 4.92041 202.623 4.09583 203.206 3.35985C203.789 2.61023 204.521 2.01736 205.403 1.58122C206.298 1.14508 207.335 0.927008 208.515 0.927008C209.206 0.927008 209.878 1.02241 210.528 1.21322C211.193 1.40404 211.83 1.69707 212.44 2.09232L212.339 1.07012H213.6V5.81313H212.664Z" fill="white"/>
<path d="M215.152 14.7063C215.559 14.7063 215.844 14.6109 216.006 14.4201C216.169 14.2293 216.264 13.9635 216.291 13.6227C216.332 13.282 216.352 12.8868 216.352 12.437V3.19629C216.352 2.9646 216.352 2.7329 216.352 2.5012C216.366 2.25587 216.386 1.99691 216.413 1.72432C216.183 1.73795 215.938 1.75158 215.681 1.76521C215.423 1.76521 215.193 1.77203 214.989 1.78566V0.702123C215.599 0.702123 216.081 0.674865 216.433 0.620348C216.799 0.552201 217.077 0.477239 217.267 0.395463C217.471 0.313687 217.62 0.225096 217.715 0.129691H218.488V13.0299C218.488 13.2752 218.481 13.5273 218.467 13.7863C218.454 14.0316 218.433 14.2838 218.406 14.5427C218.623 14.5291 218.84 14.5223 219.057 14.5223C219.288 14.5087 219.498 14.495 219.688 14.4814V15.5649H215.152V14.7063Z" fill="white"/>
<path d="M220.993 14.7063C221.399 14.7063 221.684 14.6109 221.847 14.4201C222.01 14.2293 222.105 13.9635 222.132 13.6227C222.172 13.282 222.193 12.8868 222.193 12.437V8.20508C222.193 7.97338 222.199 7.74168 222.213 7.50998C222.227 7.26466 222.247 7.0057 222.274 6.73311C222.044 6.74674 221.799 6.76037 221.542 6.774C221.284 6.774 221.047 6.78081 220.83 6.79444V5.71091C221.44 5.71091 221.921 5.68365 222.274 5.62913C222.64 5.56099 222.918 5.48603 223.108 5.40425C223.311 5.32247 223.461 5.23388 223.555 5.13848H224.328V13.0299C224.328 13.2752 224.322 13.5273 224.308 13.7863C224.294 14.0316 224.274 14.2838 224.247 14.5427C224.464 14.5291 224.681 14.5223 224.898 14.5223C225.128 14.5087 225.339 14.495 225.528 14.4814V15.5649H220.993V14.7063ZM223.149 3.25763C222.796 3.25763 222.498 3.12815 222.254 2.86919C222.01 2.5966 221.888 2.2695 221.888 1.88788C221.888 1.50626 222.016 1.18597 222.274 0.927008C222.532 0.654421 222.823 0.518127 223.149 0.518127C223.501 0.518127 223.793 0.654421 224.023 0.927008C224.267 1.18597 224.389 1.50626 224.389 1.88788C224.389 2.2695 224.267 2.5966 224.023 2.86919C223.793 3.12815 223.501 3.25763 223.149 3.25763Z" fill="white"/>
<path d="M231.689 15.892C230.74 15.892 229.886 15.674 229.126 15.2378C228.381 14.7881 227.791 14.1611 227.357 13.357C226.923 12.5392 226.706 11.5783 226.706 10.4744C226.706 9.52031 226.916 8.62759 227.337 7.7962C227.771 6.96481 228.374 6.29697 229.147 5.79269C229.92 5.27477 230.815 5.01581 231.832 5.01581C232.401 5.01581 232.937 5.1044 233.439 5.28159C233.954 5.45877 234.408 5.73817 234.801 6.11979C235.208 6.48778 235.527 6.97162 235.757 7.57132C235.988 8.15738 236.103 8.87292 236.103 9.71794L229.106 9.8406C229.106 10.7674 229.208 11.592 229.411 12.3143C229.628 13.0367 229.974 13.5955 230.449 13.9907C230.923 14.386 231.54 14.5836 232.299 14.5836C232.666 14.5836 233.052 14.5223 233.459 14.3996C233.879 14.2633 234.272 14.0725 234.639 13.8272C235.018 13.5819 235.337 13.2888 235.595 12.9481L236.225 13.5001C235.818 14.1134 235.35 14.5972 234.822 14.9516C234.293 15.2923 233.75 15.5309 233.194 15.6672C232.652 15.8171 232.15 15.892 231.689 15.892ZM229.188 8.77751H233.886C233.886 8.2596 233.811 7.7962 233.662 7.38732C233.527 6.96481 233.31 6.63089 233.011 6.38556C232.713 6.14023 232.333 6.01757 231.872 6.01757C231.154 6.01757 230.564 6.24927 230.103 6.71267C229.642 7.16244 229.337 7.85072 229.188 8.77751Z" fill="white"/>
<path d="M237.608 15.5649V14.7063C238.015 14.7063 238.3 14.6109 238.463 14.4201C238.625 14.2293 238.72 13.9635 238.748 13.6227C238.788 13.282 238.809 12.8868 238.809 12.437L238.829 8.18464C238.829 7.96657 238.829 7.73487 238.829 7.48954C238.842 7.23058 238.87 6.97844 238.91 6.73311C238.666 6.74674 238.415 6.76037 238.158 6.774C237.914 6.774 237.683 6.78081 237.466 6.79444V5.71091C238.076 5.71091 238.551 5.68365 238.89 5.62913C239.242 5.56099 239.507 5.48603 239.683 5.40425C239.873 5.32247 240.015 5.23388 240.11 5.13848H240.842C240.856 5.26114 240.863 5.37699 240.863 5.48603C240.876 5.59506 240.883 5.71091 240.883 5.83357C240.897 5.95624 240.91 6.11298 240.924 6.30379C241.222 6.05846 241.548 5.84039 241.9 5.64958C242.253 5.45877 242.619 5.30884 242.999 5.19981C243.392 5.07714 243.765 5.01581 244.117 5.01581C245.324 5.01581 246.192 5.38381 246.721 6.11979C247.263 6.85578 247.534 8.00745 247.534 9.57483V13.0299C247.534 13.2752 247.528 13.5273 247.514 13.7863C247.5 14.0316 247.48 14.2838 247.453 14.5427C247.67 14.5291 247.887 14.5223 248.104 14.5223C248.334 14.5087 248.544 14.495 248.734 14.4814V15.5649H244.199V14.7063C244.605 14.7063 244.89 14.6109 245.053 14.4201C245.216 14.2293 245.31 13.9635 245.338 13.6227C245.378 13.282 245.399 12.8868 245.399 12.437V9.57483C245.385 8.49811 245.209 7.68717 244.87 7.14199C244.544 6.59682 244.016 6.33105 243.283 6.34467C242.849 6.34467 242.422 6.45371 242.002 6.67178C241.582 6.88985 241.229 7.15562 240.944 7.4691C240.944 7.60539 240.944 7.75531 240.944 7.91886C240.944 8.06879 240.944 8.22552 240.944 8.38908V13.0299C240.944 13.2752 240.937 13.5273 240.924 13.7863C240.91 14.0316 240.89 14.2838 240.863 14.5427C241.08 14.5291 241.297 14.5223 241.514 14.5223C241.744 14.5087 241.954 14.495 242.144 14.4814V15.5649H237.608Z" fill="white"/>
<path d="M253.685 15.892C253.346 15.892 253 15.8443 252.647 15.7489C252.308 15.6671 251.996 15.4968 251.712 15.2378C251.427 14.9652 251.203 14.5768 251.04 14.0725C250.878 13.5682 250.796 12.9004 250.796 12.069L250.837 6.48778H249.332V5.34292C249.698 5.32929 250.071 5.19299 250.451 4.93404C250.83 4.67508 251.162 4.32753 251.447 3.89139C251.732 3.45525 251.929 2.98504 252.037 2.48075H252.993V5.34292H256.125V6.40601L252.993 6.4469L252.952 11.9054C252.952 12.4234 252.993 12.8799 253.074 13.2752C253.169 13.6568 253.318 13.9567 253.522 14.1747C253.739 14.3792 254.03 14.4814 254.396 14.4814C254.708 14.4814 255.027 14.386 255.352 14.1952C255.691 14.0044 256.01 13.6909 256.308 13.2548L257 13.8681C256.674 14.3451 256.349 14.7199 256.024 14.9925C255.698 15.2651 255.386 15.4627 255.088 15.5854C254.79 15.7217 254.518 15.8034 254.274 15.8307C254.03 15.8716 253.834 15.892 253.685 15.892Z" fill="white"/>
<path d="M134.115 40.8445C134.562 40.8173 134.881 40.7219 135.071 40.5583C135.274 40.3948 135.403 40.1494 135.457 39.8223C135.512 39.4952 135.539 39.0795 135.539 38.5752V30.0705C135.539 29.7707 135.545 29.4845 135.559 29.2119C135.573 28.9257 135.586 28.6803 135.6 28.4759C135.369 28.4895 135.118 28.5032 134.847 28.5168C134.576 28.5304 134.332 28.544 134.115 28.5577V27.4333C134.915 27.4196 135.728 27.4128 136.556 27.4128C137.383 27.3992 138.223 27.3924 139.078 27.3924C140.352 27.3924 141.43 27.5491 142.312 27.8626C143.207 28.1624 143.892 28.6258 144.366 29.2528C144.854 29.8661 145.105 30.643 145.119 31.5834C145.132 32.1149 145.037 32.6669 144.834 33.2394C144.631 33.7982 144.285 34.3229 143.797 34.8135C143.308 35.2906 142.644 35.679 141.803 35.9789C140.976 36.2787 139.932 36.4286 138.671 36.4286C138.535 36.4423 138.379 36.4491 138.203 36.4491C138.04 36.4491 137.871 36.4423 137.695 36.4286V39.0046C137.695 39.3726 137.688 39.7065 137.674 40.0063C137.674 40.2925 137.661 40.5242 137.634 40.7014C137.823 40.6878 138.027 40.6742 138.244 40.6605C138.461 40.6469 138.671 40.6401 138.874 40.6401C139.091 40.6265 139.288 40.6128 139.464 40.5992V41.7441H134.115V40.8445ZM137.695 35.3451C137.925 35.3724 138.122 35.3928 138.285 35.4064C138.461 35.4064 138.664 35.4064 138.895 35.4064C139.654 35.4064 140.325 35.2633 140.908 34.9771C141.491 34.6909 141.946 34.2752 142.271 33.73C142.597 33.1848 142.759 32.5375 142.759 31.7878C142.759 31.1336 142.658 30.5885 142.454 30.1523C142.264 29.7025 141.993 29.355 141.641 29.1097C141.302 28.8507 140.908 28.6667 140.461 28.5577C140.013 28.4486 139.539 28.3941 139.037 28.3941C138.63 28.3941 138.332 28.4759 138.142 28.6395C137.952 28.803 137.83 29.0415 137.776 29.355C137.722 29.6685 137.695 30.0705 137.695 30.5612V35.3451Z" fill="white"/>
<path d="M146.262 40.8854C146.668 40.8854 146.953 40.79 147.116 40.5992C147.278 40.4084 147.373 40.1426 147.401 39.8019C147.441 39.4612 147.462 39.0659 147.462 38.6161V34.3638C147.462 34.1457 147.462 33.914 147.462 33.6687C147.475 33.4097 147.502 33.1576 147.543 32.9123C147.312 32.9259 147.068 32.9395 146.811 32.9531C146.553 32.9531 146.316 32.96 146.099 32.9736V31.8901H146.79C147.36 31.8901 147.794 31.8287 148.092 31.7061C148.39 31.5834 148.607 31.4539 148.743 31.3176H149.475C149.502 31.5084 149.523 31.7742 149.536 32.1149C149.55 32.4557 149.563 32.8373 149.577 33.2598C149.835 32.8782 150.133 32.5374 150.472 32.2376C150.811 31.9241 151.177 31.672 151.57 31.4812C151.977 31.2904 152.397 31.195 152.831 31.195C153.252 31.195 153.611 31.3108 153.909 31.5425C154.221 31.7606 154.377 32.1218 154.377 32.626C154.377 32.7623 154.336 32.9191 154.255 33.0963C154.187 33.2734 154.065 33.4302 153.889 33.5665C153.726 33.6891 153.502 33.7505 153.218 33.7505C152.933 33.7368 152.682 33.6278 152.465 33.4234C152.262 33.2189 152.167 32.9395 152.18 32.5852C151.855 32.5852 151.53 32.6874 151.204 32.8918C150.879 33.0826 150.574 33.3416 150.289 33.6687C150.018 33.9958 149.787 34.3502 149.597 34.7318V39.209C149.597 39.4543 149.59 39.7065 149.577 39.9654C149.577 40.2108 149.557 40.4629 149.516 40.7219C149.733 40.7082 149.957 40.7014 150.187 40.7014C150.418 40.6878 150.628 40.6742 150.818 40.6605V41.7441H146.262V40.8854Z" fill="white"/>
<path d="M160.08 42.0712C159.117 42.0712 158.249 41.8531 157.477 41.417C156.704 40.9808 156.087 40.3743 155.626 39.5975C155.178 38.8069 154.954 37.9006 154.954 36.8784C154.954 35.7744 155.178 34.7999 155.626 33.9549C156.087 33.0963 156.704 32.4216 157.477 31.9309C158.249 31.4403 159.11 31.195 160.06 31.195C161.022 31.195 161.89 31.4198 162.663 31.8696C163.436 32.3058 164.046 32.9191 164.494 33.7096C164.955 34.4864 165.185 35.386 165.185 36.4082C165.185 37.4849 164.962 38.4526 164.514 39.3112C164.067 40.1699 163.456 40.8445 162.683 41.3352C161.924 41.8259 161.056 42.0712 160.08 42.0712ZM160.222 41.049C160.873 41.0353 161.382 40.8309 161.748 40.4357C162.128 40.0404 162.399 39.5361 162.561 38.9228C162.724 38.2959 162.806 37.6485 162.806 36.9806C162.806 36.3946 162.751 35.8221 162.643 35.2633C162.548 34.6909 162.392 34.173 162.175 33.7096C161.958 33.2462 161.673 32.8782 161.321 32.6056C160.968 32.3194 160.541 32.1831 160.039 32.1967C159.402 32.1967 158.88 32.3943 158.473 32.7896C158.08 33.1848 157.788 33.6959 157.599 34.3229C157.422 34.9362 157.334 35.5904 157.334 36.2855C157.334 37.0897 157.436 37.8529 157.639 38.5752C157.843 39.2976 158.154 39.8905 158.575 40.3539C159.009 40.8173 159.558 41.049 160.222 41.049Z" fill="white"/>
<path d="M170.516 42.0712C170.177 42.0712 169.832 42.0235 169.479 41.9281C169.14 41.8463 168.828 41.6759 168.543 41.417C168.259 41.1444 168.035 40.7559 167.872 40.2517C167.709 39.7474 167.628 39.0795 167.628 38.2481L167.669 32.6669H166.164V31.5221C166.53 31.5084 166.903 31.3721 167.282 31.1132C167.662 30.8542 167.994 30.5067 168.279 30.0705C168.564 29.6344 168.76 29.1642 168.869 28.6599H169.825V31.5221H172.957V32.5852L169.825 32.626L169.784 38.0846C169.784 38.6025 169.825 39.0591 169.906 39.4543C170.001 39.836 170.15 40.1358 170.354 40.3539C170.571 40.5583 170.862 40.6605 171.228 40.6605C171.54 40.6605 171.859 40.5651 172.184 40.3743C172.523 40.1835 172.842 39.87 173.14 39.4339L173.832 40.0472C173.506 40.5243 173.181 40.8991 172.855 41.1716C172.53 41.4442 172.218 41.6419 171.92 41.7645C171.621 41.9008 171.35 41.9826 171.106 42.0098C170.862 42.0507 170.666 42.0712 170.516 42.0712Z" fill="white"/>
<path d="M179.516 42.0712C178.554 42.0712 177.686 41.8531 176.913 41.417C176.14 40.9808 175.523 40.3743 175.062 39.5975C174.615 38.8069 174.391 37.9006 174.391 36.8784C174.391 35.7744 174.615 34.7999 175.062 33.9549C175.523 33.0963 176.14 32.4216 176.913 31.9309C177.686 31.4403 178.547 31.195 179.496 31.195C180.459 31.195 181.327 31.4198 182.1 31.8696C182.872 32.3058 183.483 32.9191 183.93 33.7096C184.391 34.4864 184.622 35.386 184.622 36.4082C184.622 37.4849 184.398 38.4526 183.95 39.3112C183.503 40.1699 182.893 40.8445 182.12 41.3352C181.361 41.8259 180.493 42.0712 179.516 42.0712ZM179.659 41.049C180.31 41.0353 180.818 40.8309 181.184 40.4357C181.564 40.0404 181.835 39.5361 181.998 38.9228C182.161 38.2959 182.242 37.6485 182.242 36.9806C182.242 36.3946 182.188 35.8221 182.079 35.2633C181.984 34.6909 181.828 34.173 181.611 33.7096C181.394 33.2462 181.11 32.8782 180.757 32.6056C180.405 32.3194 179.977 32.1831 179.476 32.1967C178.838 32.1967 178.316 32.3943 177.91 32.7896C177.516 33.1848 177.225 33.6959 177.035 34.3229C176.859 34.9362 176.771 35.5904 176.771 36.2855C176.771 37.0897 176.872 37.8529 177.076 38.5752C177.279 39.2976 177.591 39.8905 178.011 40.3539C178.445 40.8173 178.994 41.049 179.659 41.049Z" fill="white"/>
<path d="M190.97 42.0712C190.061 42.0712 189.227 41.8667 188.468 41.4579C187.709 41.0354 187.098 40.4357 186.637 39.6588C186.19 38.8819 185.966 37.9415 185.966 36.8375C185.966 36.1015 186.095 35.3996 186.353 34.7318C186.61 34.0503 186.97 33.4438 187.431 32.9123C187.905 32.3807 188.461 31.965 189.098 31.6652C189.749 31.3517 190.468 31.195 191.254 31.195C192.055 31.195 192.739 31.3176 193.309 31.563C193.878 31.8083 194.312 32.1286 194.611 32.5238C194.922 32.9191 195.078 33.3484 195.078 33.8118C195.078 34.1389 194.983 34.4251 194.794 34.6704C194.604 34.9158 194.333 35.0384 193.98 35.0384C193.587 35.0521 193.309 34.9362 193.146 34.6909C192.983 34.4319 192.902 34.1934 192.902 33.9753C192.902 33.8527 192.922 33.7164 192.963 33.5665C193.004 33.4029 193.078 33.2598 193.187 33.1371C193.078 32.8509 192.902 32.6397 192.658 32.5034C192.414 32.3671 192.163 32.2785 191.905 32.2376C191.648 32.1967 191.431 32.1763 191.254 32.1763C190.427 32.1899 189.729 32.5511 189.159 33.2598C188.604 33.9549 188.326 34.9635 188.326 36.2855C188.326 37.1305 188.448 37.887 188.692 38.5548C188.949 39.2226 189.322 39.7542 189.81 40.1494C190.299 40.5447 190.882 40.7491 191.56 40.7628C192.17 40.7628 192.76 40.606 193.329 40.2926C193.899 39.9654 194.36 39.5566 194.712 39.0659L195.363 39.6383C194.97 40.2517 194.516 40.7355 194 41.0899C193.499 41.4442 192.983 41.6964 192.455 41.8463C191.939 41.9962 191.444 42.0712 190.97 42.0712Z" fill="white"/>
<path d="M201.555 42.0712C200.592 42.0712 199.724 41.8531 198.951 41.417C198.178 40.9808 197.561 40.3743 197.1 39.5975C196.653 38.8069 196.429 37.9006 196.429 36.8784C196.429 35.7744 196.653 34.7999 197.1 33.9549C197.561 33.0963 198.178 32.4216 198.951 31.9309C199.724 31.4403 200.585 31.195 201.534 31.195C202.497 31.195 203.365 31.4198 204.138 31.8696C204.911 32.3058 205.521 32.9191 205.969 33.7096C206.43 34.4864 206.66 35.386 206.66 36.4082C206.66 37.4849 206.436 38.4526 205.989 39.3112C205.541 40.1699 204.931 40.8445 204.158 41.3352C203.399 41.8259 202.531 42.0712 201.555 42.0712ZM201.697 41.049C202.348 41.0353 202.857 40.8309 203.223 40.4357C203.602 40.0404 203.874 39.5361 204.036 38.9228C204.199 38.2959 204.28 37.6485 204.28 36.9806C204.28 36.3946 204.226 35.8221 204.118 35.2633C204.023 34.6909 203.867 34.173 203.65 33.7096C203.433 33.2462 203.148 32.8782 202.796 32.6056C202.443 32.3194 202.016 32.1831 201.514 32.1967C200.877 32.1967 200.355 32.3943 199.948 32.7896C199.555 33.1848 199.263 33.6959 199.073 34.3229C198.897 34.9362 198.809 35.5904 198.809 36.2855C198.809 37.0897 198.911 37.8529 199.114 38.5752C199.317 39.2976 199.629 39.8905 200.05 40.3539C200.484 40.8173 201.033 41.049 201.697 41.049Z" fill="white"/>
<path d="M207.842 40.8854C208.249 40.8854 208.534 40.79 208.697 40.5992C208.859 40.4084 208.954 40.1426 208.981 39.8019C209.022 39.4612 209.042 39.0659 209.042 38.6161V29.3754C209.042 29.1437 209.042 28.912 209.042 28.6803C209.056 28.435 209.076 28.1761 209.103 27.9035C208.873 27.9171 208.629 27.9307 208.371 27.9444C208.114 27.9444 207.883 27.9512 207.68 27.9648V26.8813C208.29 26.8813 208.771 26.854 209.124 26.7995C209.49 26.7313 209.768 26.6564 209.958 26.5746C210.161 26.4928 210.31 26.4042 210.405 26.3088H211.178V39.209C211.178 39.4543 211.171 39.7065 211.158 39.9654C211.144 40.2108 211.124 40.4629 211.097 40.7219C211.314 40.7082 211.531 40.7014 211.748 40.7014C211.978 40.6878 212.188 40.6742 212.378 40.6605V41.7441H207.842V40.8854Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_100_26" x1="99.1169" y1="38.1477" x2="82.6837" y2="38.1477" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_100_26" x1="33.8049" y1="16.9269" x2="38.7165" y2="26.9811" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint2_linear_100_26" x1="55.0065" y1="30.3095" x2="68.2066" y2="40.7142" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_100_26">
<rect width="121.515" height="46.4384" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 29 KiB

+26 -2
View File
@@ -237,7 +237,7 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"ctrl-shift-t": "agent::CycleStartThreadIn",
"ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -338,6 +338,24 @@
"ctrl-alt-.": "agent::ToggleFastMode",
},
},
{
"context": "AcpThread > Editor && start_of_input",
"use_key_equivalents": true,
"bindings": {
"pageup": "agent::ScrollOutputPageUp",
"ctrl-pageup": "agent::ScrollOutputPageUp",
"ctrl-home": "agent::ScrollOutputToTop",
},
},
{
"context": "AcpThread > Editor && end_of_input",
"use_key_equivalents": true,
"bindings": {
"pagedown": "agent::ScrollOutputPageDown",
"ctrl-pagedown": "agent::ScrollOutputPageDown",
"ctrl-end": "agent::ScrollOutputToBottom",
},
},
{
"context": "AcpThread > Editor && mode == full",
"use_key_equivalents": true,
@@ -366,6 +384,12 @@
"backspace": "agent::RemoveSelectedThread",
},
},
{
"context": "ThreadsArchiveView",
"bindings": {
"shift-backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"bindings": {
@@ -702,7 +726,7 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
"ctrl-g": "agents_sidebar::ToggleArchive",
"ctrl-g": "agents_sidebar::ToggleThreadHistory",
"shift-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+26 -9
View File
@@ -275,7 +275,7 @@
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"cmd-shift-t": "agent::CycleStartThreadIn",
"cmd-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
"cmd-shift-e": "project_panel::ToggleFocus",
@@ -379,6 +379,24 @@
"cmd-alt-.": "agent::ToggleFastMode",
},
},
{
"context": "AcpThread > Editor && start_of_input",
"use_key_equivalents": true,
"bindings": {
"pageup": "agent::ScrollOutputPageUp",
"ctrl-pageup": "agent::ScrollOutputPageUp",
"ctrl-home": "agent::ScrollOutputToTop",
},
},
{
"context": "AcpThread > Editor && end_of_input",
"use_key_equivalents": true,
"bindings": {
"pagedown": "agent::ScrollOutputPageDown",
"ctrl-pagedown": "agent::ScrollOutputPageDown",
"ctrl-end": "agent::ScrollOutputToBottom",
},
},
{
"context": "AcpThread > Editor && mode == full",
"use_key_equivalents": true,
@@ -413,6 +431,12 @@
"shift-backspace": "agent::RemoveSelectedThread",
},
},
{
"context": "ThreadsArchiveView",
"bindings": {
"backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"use_key_equivalents": true,
@@ -461,13 +485,6 @@
"down": "search::NextHistoryQuery",
},
},
{
"context": "BufferSearchBar || ProjectSearchBar",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "editor::Newline",
},
},
{
"context": "ProjectSearchBar",
"use_key_equivalents": true,
@@ -765,7 +782,7 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"cmd-f": "agents_sidebar::FocusSidebarFilter",
"cmd-g": "agents_sidebar::ToggleArchive",
"cmd-g": "agents_sidebar::ToggleThreadHistory",
"shift-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+27 -2
View File
@@ -238,7 +238,7 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
"ctrl-shift-t": "agent::CycleStartThreadIn",
"ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-shift-.": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -339,6 +339,24 @@
"ctrl-alt-.": "agent::ToggleFastMode",
},
},
{
"context": "AcpThread > Editor && start_of_input",
"use_key_equivalents": true,
"bindings": {
"pageup": "agent::ScrollOutputPageUp",
"ctrl-pageup": "agent::ScrollOutputPageUp",
"ctrl-home": "agent::ScrollOutputToTop",
},
},
{
"context": "AcpThread > Editor && end_of_input",
"use_key_equivalents": true,
"bindings": {
"pagedown": "agent::ScrollOutputPageDown",
"ctrl-pagedown": "agent::ScrollOutputPageDown",
"ctrl-end": "agent::ScrollOutputToBottom",
},
},
{
"context": "AcpThread > Editor && mode == full",
"use_key_equivalents": true,
@@ -368,6 +386,13 @@
"backspace": "agent::RemoveSelectedThread",
},
},
{
"context": "ThreadsArchiveView",
"use_key_equivalents": true,
"bindings": {
"shift-backspace": "agent::ArchiveSelectedThread",
},
},
{
"context": "RulesLibrary",
"use_key_equivalents": true,
@@ -702,7 +727,7 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
"ctrl-g": "agents_sidebar::ToggleArchive",
"ctrl-g": "agents_sidebar::ToggleThreadHistory",
"shift-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+15 -5
View File
@@ -123,6 +123,13 @@
// Time to wait in milliseconds before showing the informational hover box.
// This delay also applies to auto signature help when `auto_signature_help` is enabled.
"hover_popover_delay": 300,
// Whether the hover popover sticks when the mouse moves toward it,
// allowing interaction with its contents before it disappears.
"hover_popover_sticky": true,
// Time to wait in milliseconds before hiding the hover popover
// after the mouse moves away from the hover target.
// Only applies when `hover_popover_sticky` is enabled.
"hover_popover_hiding_delay": 300,
// Whether to confirm before quitting Zed.
"confirm_quit": false,
// Whether to restore last closed project when fresh Zed instance is opened
@@ -138,9 +145,10 @@
// an explicit `-e` (existing window) or `-n` (new window) flag.
//
// May take 2 values:
// 1. Add to the existing Zed window
// 1. Open directories as a new workspace in the current Zed window's sidebar
// "cli_default_open_behavior": "existing_window"
// 2. Open a new Zed window
// 2. Open directories in a new window (reuse existing windows for files
// that are already part of an open project)
// "cli_default_open_behavior": "new_window"
"cli_default_open_behavior": "existing_window",
// Whether to attempt to restore previous file's state when opening it again.
@@ -606,6 +614,8 @@
"line_numbers": true,
// Whether to show runnables buttons in the gutter.
"runnables": true,
// Whether to show bookmarks in the gutter.
"bookmarks": true,
// Whether to show breakpoints in the gutter.
"breakpoints": true,
// Whether to show fold buttons in the gutter.
@@ -2074,10 +2084,10 @@
"ensure_final_newline_on_save": false,
},
"EEx": {
"language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."],
"language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "..."],
},
"Elixir": {
"language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "!emmet-language-server", "..."],
"language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "!emmet-language-server", "..."],
},
"Elm": {
"tab_size": 4,
@@ -2103,7 +2113,7 @@
},
},
"HEEx": {
"language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."],
"language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "..."],
},
"HTML": {
"prettier": {
+1 -1
View File
@@ -261,7 +261,7 @@
},
"link_text": {
"color": "#73ade9ff",
"font_style": "normal",
"font_style": "italic",
"font_weight": null
},
"link_uri": {
+1
View File
@@ -0,0 +1 @@
words = ["breakpoint"]
+1
View File
@@ -23,6 +23,7 @@ anyhow.workspace = true
buffer_diff.workspace = true
chrono.workspace = true
collections.workspace = true
feature_flags.workspace = true
multi_buffer.workspace = true
file_icons.workspace = true
futures.workspace = true
+268 -12
View File
@@ -8,6 +8,7 @@ use anyhow::{Context as _, Result, anyhow};
use collections::HashSet;
pub use connection::*;
pub use diff::*;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
use itertools::Itertools;
@@ -972,7 +973,7 @@ impl PlanEntry {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub max_tokens: u64,
pub used_tokens: u64,
@@ -981,6 +982,12 @@ pub struct TokenUsage {
pub max_output_tokens: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct SessionCost {
pub amount: f64,
pub currency: SharedString,
}
pub const TOKEN_USAGE_WARNING_THRESHOLD: f32 = 0.8;
impl TokenUsage {
@@ -1043,6 +1050,7 @@ pub struct AcpThread {
running_turn: Option<RunningTurn>,
connection: Rc<dyn AgentConnection>,
token_usage: Option<TokenUsage>,
cost: Option<SessionCost>,
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
@@ -1091,6 +1099,7 @@ impl From<&AcpThread> for ActionLogTelemetry {
#[derive(Debug)]
pub enum AcpThreadEvent {
PromptUpdated,
NewEntry,
TitleUpdated,
TokenUsageUpdated,
@@ -1232,6 +1241,7 @@ impl AcpThread {
connection,
session_id,
token_usage: None,
cost: None,
prompt_capabilities,
available_commands: Vec::new(),
_observe_prompt_capabilities: task,
@@ -1257,11 +1267,20 @@ impl AcpThread {
&self.available_commands
}
pub fn is_draft_thread(&self) -> bool {
self.entries().is_empty()
}
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
self.draft_prompt.as_deref()
}
pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
pub fn set_draft_prompt(
&mut self,
prompt: Option<Vec<acp::ContentBlock>>,
cx: &mut Context<Self>,
) {
cx.emit(AcpThreadEvent::PromptUpdated);
self.draft_prompt = prompt;
}
@@ -1303,6 +1322,10 @@ impl AcpThread {
&self.session_id
}
pub fn supports_truncate(&self, cx: &App) -> bool {
self.connection.truncate(&self.session_id, cx).is_some()
}
pub fn work_dirs(&self) -> Option<&PathList> {
self.work_dirs.as_ref()
}
@@ -1344,6 +1367,10 @@ impl AcpThread {
self.token_usage.as_ref()
}
pub fn cost(&self) -> Option<&SessionCost> {
self.cost.as_ref()
}
pub fn has_pending_edit_tool_calls(&self) -> bool {
for entry in self.entries.iter().rev() {
match entry {
@@ -1459,6 +1486,18 @@ impl AcpThread {
config_options,
..
}) => cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options)),
acp::SessionUpdate::UsageUpdate(update) if cx.has_flag::<AcpBetaFeatureFlag>() => {
let usage = self.token_usage.get_or_insert_with(Default::default);
usage.max_tokens = update.size;
usage.used_tokens = update.used;
if let Some(cost) = update.cost {
self.cost = Some(SessionCost {
amount: cost.amount,
currency: cost.currency.into(),
});
}
cx.emit(AcpThreadEvent::TokenUsageUpdated);
}
_ => {}
}
Ok(())
@@ -1755,6 +1794,9 @@ impl AcpThread {
}
pub fn update_token_usage(&mut self, usage: Option<TokenUsage>, cx: &mut Context<Self>) {
if usage.is_none() {
self.cost = None;
}
self.token_usage = usage;
cx.emit(AcpThreadEvent::TokenUsageUpdated);
}
@@ -2162,17 +2204,13 @@ impl AcpThread {
let request = acp::PromptRequest::new(self.session_id.clone(), message.clone());
let git_store = self.project.read(cx).git_store().clone();
let message_id = if self.connection.truncate(&self.session_id, cx).is_some() {
Some(UserMessageId::new())
} else {
None
};
let message_id = UserMessageId::new();
self.run_turn(cx, async move |this, cx| {
this.update(cx, |this, cx| {
this.push_entry(
AgentThreadEntry::UserMessage(UserMessage {
id: message_id.clone(),
id: Some(message_id.clone()),
content: block,
chunks: message,
checkpoint: None,
@@ -2340,6 +2378,15 @@ impl AcpThread {
}
}
if cx.has_flag::<AcpBetaFeatureFlag>()
&& let Some(response_usage) = &r.usage
{
let usage = this.token_usage.get_or_insert_with(Default::default);
usage.input_tokens = response_usage.input_tokens;
usage.output_tokens = response_usage.output_tokens;
cx.emit(AcpThreadEvent::TokenUsageUpdated);
}
cx.emit(AcpThreadEvent::Stopped(r.stop_reason));
Ok(Some(r))
}
@@ -4366,6 +4413,7 @@ mod tests {
#[derive(Clone, Default)]
struct FakeAgentConnection {
auth_methods: Vec<acp::AuthMethod>,
supports_truncate: bool,
sessions: Arc<parking_lot::Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
set_title_calls: Rc<RefCell<Vec<SharedString>>>,
on_user_message: Option<
@@ -4384,12 +4432,18 @@ mod tests {
fn new() -> Self {
Self {
auth_methods: Vec::new(),
supports_truncate: true,
on_user_message: None,
sessions: Arc::default(),
set_title_calls: Default::default(),
}
}
fn without_truncate_support(mut self) -> Self {
self.supports_truncate = false;
self
}
#[expect(unused)]
fn with_auth_methods(mut self, auth_methods: Vec<acp::AuthMethod>) -> Self {
self.auth_methods = auth_methods;
@@ -4469,7 +4523,7 @@ mod tests {
fn prompt(
&self,
_id: Option<UserMessageId>,
_id: UserMessageId,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<gpui::Result<acp::PromptResponse>> {
@@ -4491,9 +4545,11 @@ mod tests {
session_id: &acp::SessionId,
_cx: &App,
) -> Option<Rc<dyn AgentSessionTruncate>> {
Some(Rc::new(FakeAgentSessionEditor {
_session_id: session_id.clone(),
}))
self.supports_truncate.then(|| {
Rc::new(FakeAgentSessionEditor {
_session_id: session_id.clone(),
}) as Rc<dyn AgentSessionTruncate>
})
}
fn set_title(
@@ -5048,6 +5104,37 @@ mod tests {
);
}
#[gpui::test]
async fn test_send_assigns_message_id_without_truncate_support(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new().without_truncate_support());
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
let response = thread
.update(cx, |thread, cx| thread.send_raw("test message", cx))
.await;
assert!(response.is_ok(), "send should not fail: {response:?}");
thread.read_with(cx, |thread, _| {
let AgentThreadEntry::UserMessage(message) = &thread.entries[0] else {
panic!("expected first entry to be a user message")
};
assert!(
message.id.is_some(),
"user message should always have an id"
);
});
}
#[gpui::test]
async fn test_send_returns_cancelled_response_and_marks_tools_as_cancelled(
cx: &mut TestAppContext,
@@ -5257,4 +5344,173 @@ mod tests {
"session info title update should not propagate back to the connection"
);
}
#[gpui::test]
async fn test_usage_update_populates_token_usage_and_cost(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
thread.update(cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::UsageUpdate(
acp::UsageUpdate::new(5000, 10000).cost(acp::Cost::new(0.42, "USD")),
),
cx,
)
.unwrap();
});
thread.read_with(cx, |thread, _| {
let usage = thread.token_usage().expect("token_usage should be set");
assert_eq!(usage.max_tokens, 10000);
assert_eq!(usage.used_tokens, 5000);
let cost = thread.cost().expect("cost should be set");
assert!((cost.amount - 0.42).abs() < f64::EPSILON);
assert_eq!(cost.currency.as_ref(), "USD");
});
}
#[gpui::test]
async fn test_usage_update_without_cost_preserves_existing_cost(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
thread.update(cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::UsageUpdate(
acp::UsageUpdate::new(1000, 10000).cost(acp::Cost::new(0.10, "USD")),
),
cx,
)
.unwrap();
thread
.handle_session_update(
acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(2000, 10000)),
cx,
)
.unwrap();
});
thread.read_with(cx, |thread, _| {
let usage = thread.token_usage().expect("token_usage should be set");
assert_eq!(usage.used_tokens, 2000);
let cost = thread.cost().expect("cost should be preserved");
assert!((cost.amount - 0.10).abs() < f64::EPSILON);
});
}
#[gpui::test]
async fn test_response_usage_does_not_clobber_session_usage(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
move |_, thread, mut cx| {
async move {
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::UsageUpdate(
acp::UsageUpdate::new(3000, 10000)
.cost(acp::Cost::new(0.05, "EUR")),
),
cx,
)
.unwrap();
})?;
Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)
.usage(acp::Usage::new(500, 200, 300)))
}
.boxed_local()
},
));
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
thread
.update(cx, |thread, cx| thread.send_raw("hello", cx))
.await
.unwrap();
thread.read_with(cx, |thread, _| {
let usage = thread.token_usage().expect("token_usage should be set");
assert_eq!(usage.max_tokens, 10000, "max_tokens from UsageUpdate");
assert_eq!(usage.used_tokens, 3000, "used_tokens from UsageUpdate");
assert_eq!(usage.input_tokens, 200, "input_tokens from response usage");
assert_eq!(
usage.output_tokens, 300,
"output_tokens from response usage"
);
let cost = thread.cost().expect("cost should be set");
assert!((cost.amount - 0.05).abs() < f64::EPSILON);
assert_eq!(cost.currency.as_ref(), "EUR");
});
}
#[gpui::test]
async fn test_clearing_token_usage_also_clears_cost(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
thread.update(cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::UsageUpdate(
acp::UsageUpdate::new(1000, 10000).cost(acp::Cost::new(0.25, "USD")),
),
cx,
)
.unwrap();
assert!(thread.token_usage().is_some());
assert!(thread.cost().is_some());
thread.update_token_usage(None, cx);
assert!(thread.token_usage().is_none());
assert!(
thread.cost().is_none(),
"cost should be cleared when token usage is cleared"
);
});
}
}
+20 -5
View File
@@ -125,7 +125,7 @@ pub trait AgentConnection {
fn prompt(
&self,
user_message_id: Option<UserMessageId>,
user_message_id: UserMessageId,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<Result<acp::PromptResponse>>;
@@ -665,6 +665,23 @@ mod test_support {
)
}
/// Test-scoped counter for generating unique session IDs across all
/// `StubAgentConnection` instances within a test case. Set as a GPUI
/// global in test init so each case starts fresh.
pub struct StubSessionCounter(pub AtomicUsize);
impl gpui::Global for StubSessionCounter {}
impl StubSessionCounter {
pub fn next(cx: &App) -> usize {
cx.try_global::<Self>()
.map(|g| g.0.fetch_add(1, Ordering::SeqCst))
.unwrap_or_else(|| {
static FALLBACK: AtomicUsize = AtomicUsize::new(0);
FALLBACK.fetch_add(1, Ordering::SeqCst)
})
}
}
#[derive(Clone)]
pub struct StubAgentConnection {
sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
@@ -823,9 +840,7 @@ mod test_support {
work_dirs: PathList,
cx: &mut gpui::App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0);
let session_id =
acp::SessionId::new(NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst).to_string());
let session_id = acp::SessionId::new(StubSessionCounter::next(cx).to_string());
let thread = self.create_session(session_id, project, work_dirs, None, cx);
Task::ready(Ok(thread))
}
@@ -860,7 +875,7 @@ mod test_support {
fn prompt(
&self,
_id: Option<UserMessageId>,
_id: UserMessageId,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<gpui::Result<acp::PromptResponse>> {
+1
View File
@@ -33,6 +33,7 @@ watch.workspace = true
[dev-dependencies]
buffer_diff = { workspace = true, features = ["test-support"] }
git.workspace = true
collections = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
ctor.workspace = true
+113 -22
View File
@@ -274,7 +274,6 @@ impl ActionLog {
mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
cx: &mut AsyncApp,
) -> Result<()> {
let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?;
let git_diff = this
.update(cx, |this, cx| {
this.project.update(cx, |project, cx| {
@@ -283,28 +282,18 @@ impl ActionLog {
})?
.await
.ok();
let buffer_repo = git_store.read_with(cx, |git_store, cx| {
git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
});
let (mut git_diff_updates_tx, mut git_diff_updates_rx) = watch::channel(());
let _repo_subscription =
if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
cx.update(|cx| {
let mut old_head = buffer_repo.read(cx).head_commit.clone();
Some(cx.subscribe(git_diff, move |_, event, cx| {
if let buffer_diff::BufferDiffEvent::DiffChanged { .. } = event {
let new_head = buffer_repo.read(cx).head_commit.clone();
if new_head != old_head {
old_head = new_head;
git_diff_updates_tx.send(()).ok();
}
}
}))
})
} else {
None
};
let _diff_subscription = if let Some(git_diff) = git_diff.as_ref() {
cx.update(|cx| {
Some(cx.subscribe(git_diff, move |_, event, _cx| {
if matches!(event, buffer_diff::BufferDiffEvent::BaseTextChanged) {
git_diff_updates_tx.send(()).ok();
}
}))
})
} else {
None
};
loop {
futures::select_biased! {
@@ -2714,6 +2703,108 @@ mod tests {
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
/// Regression test: when head_commit updates before the BufferDiff's base
/// text does, an intermediate DiffChanged (e.g. from a buffer-edit diff
/// recalculation) must NOT consume the commit signal. The subscription
/// should only fire once the base text itself has changed.
#[gpui::test]
async fn test_keep_edits_on_commit_with_stale_diff_changed(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"file.txt": "aaa\nbbb\nccc\nddd\neee",
}),
)
.await;
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("file.txt", "aaa\nbbb\nccc\nddd\neee".into())],
"0000000",
);
cx.run_until_parked();
let project = Project::test(fs.clone(), [path!("/project").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(path!("/project/file.txt"), cx)
})
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
.unwrap();
// Agent makes an edit: bbb -> BBB
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, 0)..Point::new(1, 3), "BBB")], None, cx);
});
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
});
cx.run_until_parked();
// Verify the edit is tracked
let hunks = unreviewed_hunks(&action_log, cx);
assert_eq!(hunks.len(), 1);
let hunk = &hunks[0].1;
assert_eq!(hunk.len(), 1);
assert_eq!(hunk[0].old_text, "bbb\n");
// Simulate the race condition: update only the HEAD SHA first,
// without changing the committed file contents. This is analogous
// to compute_snapshot updating head_commit before
// reload_buffer_diff_bases has loaded the new base text.
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.refs.insert("HEAD".into(), "0000001".into());
})
.unwrap();
cx.run_until_parked();
// Make a user edit (on a different line) to trigger a buffer diff
// recalculation. This fires DiffChanged while the BufferDiff base
// text is still the OLD text. With the old head_commit-based
// subscription this would "consume" the commit detection.
cx.update(|cx| {
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(3, 0)..Point::new(3, 3), "DDD")], None, cx);
});
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
});
cx.run_until_parked();
// Now update the committed file contents to match the buffer
// (the agent edit was committed). Keep the same SHA so head_commit
// does NOT change again — this is the second half of the race.
{
use git::repository::repo_path;
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state
.head_contents
.insert(repo_path("file.txt"), "aaa\nBBB\nccc\nDDD\neee".into());
})
.unwrap();
}
cx.run_until_parked();
// The agent's edit (bbb -> BBB) should be accepted because the
// committed content now matches. Only the user edit (ddd -> DDD)
// should remain, but since the user edit is tracked as coming from
// the user (ChangeAuthor::User) it would have been rebased into
// the diff base already. So no unreviewed hunks should remain.
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![],
"agent edits should have been accepted after the base text update"
);
}
#[gpui::test]
async fn test_undo_last_reject(cx: &mut TestAppContext) {
init_test(cx);
+363 -51
View File
@@ -84,6 +84,12 @@ struct Session {
project_id: EntityId,
pending_save: Task<Result<()>>,
_subscriptions: Vec<Subscription>,
ref_count: usize,
}
struct PendingSession {
task: Shared<Task<Result<Entity<AcpThread>, Arc<anyhow::Error>>>>,
ref_count: usize,
}
pub struct LanguageModels {
@@ -195,7 +201,7 @@ impl LanguageModels {
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
cx.background_spawn(async move {
cx.spawn(async move |cx| {
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
if let Err(err) = authenticate_task.await {
match err {
@@ -238,6 +244,8 @@ impl LanguageModels {
}
}
}
cx.update(language_models::update_environment_fallback_model);
})
}
}
@@ -245,6 +253,7 @@ impl LanguageModels {
pub struct NativeAgent {
/// Session ID -> Session mapping
sessions: HashMap<acp::SessionId, Session>,
pending_sessions: HashMap<acp::SessionId, PendingSession>,
thread_store: Entity<ThreadStore>,
/// Project-specific state keyed by project EntityId
projects: HashMap<EntityId, ProjectState>,
@@ -278,6 +287,7 @@ impl NativeAgent {
Self {
sessions: HashMap::default(),
pending_sessions: HashMap::default(),
thread_store,
projects: HashMap::default(),
templates,
@@ -316,13 +326,14 @@ impl NativeAgent {
)
});
self.register_session(thread, project_id, cx)
self.register_session(thread, project_id, 1, cx)
}
fn register_session(
&mut self,
thread_handle: Entity<Thread>,
project_id: EntityId,
ref_count: usize,
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
@@ -349,14 +360,14 @@ impl NativeAgent {
prompt_capabilities_rx,
cx,
);
acp_thread.set_draft_prompt(draft_prompt);
acp_thread.set_draft_prompt(draft_prompt, cx);
acp_thread.set_ui_scroll_position(scroll_position);
acp_thread.update_token_usage(token_usage, cx);
acp_thread
});
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
let summarization_model = registry.thread_summary_model(cx).map(|c| c.model);
let weak = cx.weak_entity();
let weak_thread = thread_handle.downgrade();
@@ -388,6 +399,7 @@ impl NativeAgent {
project_id,
_subscriptions: subscriptions,
pending_save: Task::ready(Ok(())),
ref_count,
},
);
@@ -739,13 +751,14 @@ impl NativeAgent {
let registry = LanguageModelRegistry::read_global(cx);
let default_model = registry.default_model().map(|m| m.model);
let summarization_model = registry.thread_summary_model().map(|m| m.model);
let summarization_model = registry.thread_summary_model(cx).map(|m| m.model);
for session in self.sessions.values_mut() {
session.thread.update(cx, |thread, cx| {
if thread.model().is_none()
&& let Some(model) = default_model.clone()
{
let should_update_model = thread.model().is_none()
|| (thread.is_empty()
&& matches!(event, language_model::Event::DefaultModelChanged));
if should_update_model && let Some(model) = default_model.clone() {
thread.set_model(model, cx);
cx.notify();
}
@@ -900,7 +913,7 @@ impl NativeAgent {
.get(&project_id)
.context("project state not found")?;
let summarization_model = LanguageModelRegistry::read_global(cx)
.thread_summary_model()
.thread_summary_model(cx)
.map(|c| c.model);
Ok(cx.new(|cx| {
@@ -926,27 +939,68 @@ impl NativeAgent {
project: Entity<Project>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<AcpThread>>> {
if let Some(session) = self.sessions.get(&id) {
if let Some(session) = self.sessions.get_mut(&id) {
session.ref_count += 1;
return Task::ready(Ok(session.acp_thread.clone()));
}
let task = self.load_thread(id, project.clone(), cx);
cx.spawn(async move |this, cx| {
let thread = task.await?;
let acp_thread = this.update(cx, |this, cx| {
let project_id = this.get_or_create_project_state(&project, cx);
this.register_session(thread.clone(), project_id, cx)
})?;
let events = thread.update(cx, |thread, cx| thread.replay(cx));
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
if let Some(pending) = self.pending_sessions.get_mut(&id) {
pending.ref_count += 1;
let task = pending.task.clone();
return cx.background_spawn(async move { task.await.map_err(|err| anyhow!(err)) });
}
let task = self.load_thread(id.clone(), project.clone(), cx);
let shared_task = cx
.spawn({
let id = id.clone();
async move |this, cx| {
let thread = match task.await {
Ok(thread) => thread,
Err(err) => {
this.update(cx, |this, _cx| {
this.pending_sessions.remove(&id);
})
.ok();
return Err(Arc::new(err));
}
};
let acp_thread = this
.update(cx, |this, cx| {
let project_id = this.get_or_create_project_state(&project, cx);
let ref_count = this
.pending_sessions
.remove(&id)
.map_or(1, |pending| pending.ref_count);
this.register_session(thread.clone(), project_id, ref_count, cx)
})
.map_err(Arc::new)?;
let events = thread.update(cx, |thread, cx| thread.replay(cx));
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(
events,
acp_thread.downgrade(),
cx,
)
})
.await
.map_err(Arc::new)?;
acp_thread.update(cx, |thread, cx| {
thread.snapshot_completed_plan(cx);
});
Ok(acp_thread)
}
})
.await?;
acp_thread.update(cx, |thread, cx| {
thread.snapshot_completed_plan(cx);
});
Ok(acp_thread)
})
.shared();
self.pending_sessions.insert(
id,
PendingSession {
task: shared_task.clone(),
ref_count: 1,
},
);
cx.background_spawn(async move { shared_task.await.map_err(|err| anyhow!(err)) })
}
pub fn thread_summary(
@@ -968,11 +1022,43 @@ impl NativeAgent {
})?
.await
.context("Failed to generate summary")?;
this.update(cx, |this, cx| this.close_session(&id, cx))?
.await?;
drop(acp_thread);
Ok(result)
})
}
fn close_session(
&mut self,
session_id: &acp::SessionId,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let Some(session) = self.sessions.get_mut(session_id) else {
return Task::ready(Ok(()));
};
session.ref_count -= 1;
if session.ref_count > 0 {
return Task::ready(Ok(()));
}
let thread = session.thread.clone();
self.save_thread(thread, cx);
let Some(session) = self.sessions.remove(session_id) else {
return Task::ready(Ok(()));
};
let project_id = session.project_id;
let has_remaining = self.sessions.values().any(|s| s.project_id == project_id);
if !has_remaining {
self.projects.remove(&project_id);
}
session.pending_save
}
fn save_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) {
if thread.read(cx).is_empty() {
return;
@@ -1158,6 +1244,7 @@ impl NativeAgentConnection {
.get_mut(&session_id)
.map(|s| (s.thread.clone(), s.acp_thread.clone()))
}) else {
log::error!("Session not found in run_turn: {}", session_id);
return Task::ready(Err(anyhow!("Session not found")));
};
log::debug!("Found session for: {}", session_id);
@@ -1452,24 +1539,8 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
session_id: &acp::SessionId,
cx: &mut App,
) -> Task<Result<()>> {
self.0.update(cx, |agent, cx| {
let thread = agent.sessions.get(session_id).map(|s| s.thread.clone());
if let Some(thread) = thread {
agent.save_thread(thread, cx);
}
let Some(session) = agent.sessions.remove(session_id) else {
return Task::ready(Ok(()));
};
let project_id = session.project_id;
let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id);
if !has_remaining {
agent.projects.remove(&project_id);
}
session.pending_save
})
self.0
.update(cx, |agent, cx| agent.close_session(session_id, cx))
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
@@ -1489,16 +1560,22 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn prompt(
&self,
id: Option<acp_thread::UserMessageId>,
id: acp_thread::UserMessageId,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<Result<acp::PromptResponse>> {
let id = id.expect("UserMessageId is required");
let session_id = params.session_id.clone();
log::info!("Received prompt request for session: {}", session_id);
log::debug!("Prompt blocks count: {}", params.prompt.len());
let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else {
log::error!("Session not found in prompt: {}", session_id);
if self.0.read(cx).sessions.contains_key(&session_id) {
log::error!(
"Session found in sessions map, but not in project state: {}",
session_id
);
}
return Task::ready(Err(anyhow::anyhow!("Session not found")));
};
@@ -1813,7 +1890,7 @@ impl NativeThreadEnvironment {
.get(&parent_session_id)
.map(|s| s.project_id)
.context("parent session not found")?;
Ok(agent.register_session(subagent_thread.clone(), project_id, cx))
Ok(agent.register_session(subagent_thread.clone(), project_id, 1, cx))
})??;
let depth = current_depth + 1;
@@ -2852,8 +2929,8 @@ mod internal_tests {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())),
acp::ContentBlock::Text(acp::TextContent::new(" please")),
];
acp_thread.update(cx, |thread, _cx| {
thread.set_draft_prompt(Some(draft_blocks.clone()));
acp_thread.update(cx, |thread, cx| {
thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
});
thread.update(cx, |thread, _cx| {
thread.set_ui_scroll_position(Some(gpui::ListOffset {
@@ -2981,8 +3058,8 @@ mod internal_tests {
let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
"unsaved draft",
))];
acp_thread.update(cx, |thread, _cx| {
thread.set_draft_prompt(Some(draft_blocks.clone()));
acp_thread.update(cx, |thread, cx| {
thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
});
// Close the session immediately — no run_until_parked in between.
@@ -3007,6 +3084,241 @@ mod internal_tests {
});
}
#[gpui::test]
async fn test_thread_summary_releases_loaded_session(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/",
json!({
"a": {
"file.txt": "hello"
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let agent = cx.update(|cx| {
NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
});
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
.update(|cx| {
connection
.clone()
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
})
.await
.unwrap();
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
let thread = agent.read_with(cx, |agent, _| {
agent.sessions.get(&session_id).unwrap().thread.clone()
});
let model = Arc::new(FakeLanguageModel::default());
let summary_model = Arc::new(FakeLanguageModel::default());
thread.update(cx, |thread, cx| {
thread.set_model(model.clone(), cx);
thread.set_summarization_model(Some(summary_model.clone()), cx);
});
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
let send = cx.foreground_executor().spawn(send);
cx.run_until_parked();
model.send_last_completion_stream_text_chunk("world");
model.end_last_completion_stream();
send.await.unwrap();
cx.run_until_parked();
let summary = agent.update(cx, |agent, cx| {
agent.thread_summary(session_id.clone(), project.clone(), cx)
});
cx.run_until_parked();
summary_model.send_last_completion_stream_text_chunk("summary");
summary_model.end_last_completion_stream();
assert_eq!(summary.await.unwrap(), "summary");
cx.run_until_parked();
agent.read_with(cx, |agent, _| {
let session = agent
.sessions
.get(&session_id)
.expect("thread_summary should not close the active session");
assert_eq!(
session.ref_count, 1,
"thread_summary should release its temporary session reference"
);
});
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.unwrap();
cx.run_until_parked();
agent.read_with(cx, |agent, _| {
assert!(
agent.sessions.is_empty(),
"closing the active session after thread_summary should unload it"
);
});
}
#[gpui::test]
async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/",
json!({
"a": {
"file.txt": "hello"
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let agent = cx.update(|cx| {
NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
});
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
.update(|cx| {
connection
.clone()
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
})
.await
.unwrap();
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
let thread = agent.read_with(cx, |agent, _| {
agent.sessions.get(&session_id).unwrap().thread.clone()
});
let model = cx.update(|cx| {
LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default_model| default_model.model)
.expect("default test model should be available")
});
let fake_model = model.as_fake();
thread.update(cx, |thread, cx| {
thread.set_model(model.clone(), cx);
});
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
let send = cx.foreground_executor().spawn(send);
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("world");
fake_model.end_last_completion_stream();
send.await.unwrap();
cx.run_until_parked();
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.unwrap();
drop(thread);
drop(acp_thread);
agent.read_with(cx, |agent, _| {
assert!(agent.sessions.is_empty());
});
let first_loaded_thread = cx.update(|cx| {
connection.clone().load_session(
session_id.clone(),
project.clone(),
PathList::new(&[Path::new("")]),
None,
cx,
)
});
let second_loaded_thread = cx.update(|cx| {
connection.clone().load_session(
session_id.clone(),
project.clone(),
PathList::new(&[Path::new("")]),
None,
cx,
)
});
let first_loaded_thread = first_loaded_thread.await.unwrap();
let second_loaded_thread = second_loaded_thread.await.unwrap();
cx.run_until_parked();
assert_eq!(
first_loaded_thread.entity_id(),
second_loaded_thread.entity_id(),
"concurrent loads for the same session should share one AcpThread"
);
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.unwrap();
agent.read_with(cx, |agent, _| {
assert!(
agent.sessions.contains_key(&session_id),
"closing one loaded session should not drop shared session state"
);
});
let follow_up = second_loaded_thread.update(cx, |thread, cx| {
thread.send(vec!["still there?".into()], cx)
});
let follow_up = cx.foreground_executor().spawn(follow_up);
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("yes");
fake_model.end_last_completion_stream();
follow_up.await.unwrap();
cx.run_until_parked();
second_loaded_thread.read_with(cx, |thread, cx| {
assert_eq!(
thread.to_markdown(cx),
formatdoc! {"
## User
hello
## Assistant
world
## User
still there?
## Assistant
yes
"}
);
});
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.unwrap();
cx.run_until_parked();
drop(first_loaded_thread);
drop(second_loaded_thread);
agent.read_with(cx, |agent, _| {
assert!(agent.sessions.is_empty());
});
}
#[gpui::test]
async fn test_rapid_title_changes_do_not_loop(cx: &mut TestAppContext) {
// Regression test: rapid title changes must not cause a propagation loop
+61 -1
View File
@@ -3160,6 +3160,66 @@ async fn test_title_generation(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let summary_model = Arc::new(FakeLanguageModel::default());
let fake_summary_model = summary_model.as_fake();
thread.update(cx, |thread, cx| {
thread.set_summarization_model(Some(summary_model.clone()), cx)
});
let send = thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Hello"], cx)
})
.unwrap();
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Hey!");
fake_model.end_last_completion_stream();
cx.run_until_parked();
fake_summary_model.send_last_completion_stream_error(
LanguageModelCompletionError::UpstreamProviderError {
message: "Internal server error".to_string(),
status: gpui::http_client::StatusCode::INTERNAL_SERVER_ERROR,
retry_after: None,
},
);
fake_summary_model.end_last_completion_stream();
send.collect::<Vec<_>>().await;
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.title(), None);
assert!(thread.has_failed_title_generation());
assert!(!thread.is_generating_title());
});
thread.update(cx, |thread, cx| {
thread.generate_title(cx);
});
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert!(!thread.has_failed_title_generation());
assert!(thread.is_generating_title());
});
fake_summary_model.send_last_completion_stream_text_chunk("Retried title");
fake_summary_model.end_last_completion_stream();
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.title(), Some("Retried title".into()));
assert!(!thread.has_failed_title_generation());
assert!(!thread.is_generating_title());
});
}
#[gpui::test]
async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
@@ -3350,7 +3410,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
let result = cx
.update(|cx| {
connection.prompt(
Some(acp_thread::UserMessageId::new()),
acp_thread::UserMessageId::new(),
acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]),
cx,
)
+19 -12
View File
@@ -939,6 +939,7 @@ pub struct Thread {
updated_at: DateTime<Utc>,
title: Option<SharedString>,
pending_title_generation: Option<Task<()>>,
title_generation_failed: bool,
pending_summary_generation: Option<Shared<Task<Option<SharedString>>>>,
summary: Option<SharedString>,
messages: Vec<Message>,
@@ -1065,6 +1066,7 @@ impl Thread {
updated_at: Utc::now(),
title: None,
pending_title_generation: None,
title_generation_failed: false,
pending_summary_generation: None,
summary: None,
messages: Vec::new(),
@@ -1298,6 +1300,7 @@ impl Thread {
Some(db_thread.title.clone())
},
pending_title_generation: None,
title_generation_failed: false,
pending_summary_generation: None,
summary: db_thread.detailed_summary,
messages: db_thread.messages,
@@ -2556,6 +2559,10 @@ impl Thread {
self.pending_title_generation.is_some()
}
pub fn has_failed_title_generation(&self) -> bool {
self.title_generation_failed
}
pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
if let Some(summary) = self.summary.as_ref() {
return Task::ready(Some(summary.clone())).shared();
@@ -2617,6 +2624,7 @@ impl Thread {
}
pub fn generate_title(&mut self, cx: &mut Context<Self>) {
self.title_generation_failed = false;
let Some(model) = self.summarization_model.clone() else {
return;
};
@@ -2664,28 +2672,27 @@ impl Thread {
anyhow::Ok(())
};
if generate
let succeeded = generate
.await
.context("failed to generate thread title")
.log_err()
.is_some()
{
_ = this.update(cx, |this, cx| this.set_title(title.into(), cx));
} else {
// Emit TitleUpdated even on failure so that the propagation
// chain (agent::Thread → NativeAgent → AcpThread) fires and
// clears any provisional title that was set before the turn.
_ = this.update(cx, |_, cx| {
.is_some();
_ = this.update(cx, |this, cx| {
this.pending_title_generation = None;
if succeeded {
this.set_title(title.into(), cx);
} else {
this.title_generation_failed = true;
cx.emit(TitleUpdated);
cx.notify();
});
}
_ = this.update(cx, |this, _| this.pending_title_generation = None);
}
});
}));
}
pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
self.pending_title_generation = None;
self.title_generation_failed = false;
if Some(&title) != self.title.as_ref() {
self.title = Some(title);
cx.emit(TitleUpdated);
+5 -1
View File
@@ -6,7 +6,7 @@ publish.workspace = true
license = "GPL-3.0-or-later"
[features]
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:async-pipe", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
e2e = []
[lints]
@@ -22,6 +22,7 @@ acp_thread.workspace = true
action_log.workspace = true
agent-client-protocol.workspace = true
anyhow.workspace = true
async-pipe = { workspace = true, optional = true }
async-trait.workspace = true
chrono.workspace = true
client.workspace = true
@@ -66,6 +67,9 @@ fs.workspace = true
indoc.workspace = true
acp_thread = { workspace = true, features = ["test-support"] }
async-pipe.workspace = true
gpui = { workspace = true, features = ["test-support"] }
gpui_tokio.workspace = true
project = { workspace = true, features = ["test-support"] }
reqwest_client = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,10 @@ use gpui::{App, AppContext, Entity, Task};
use settings::SettingsStore;
use std::{any::Any, rc::Rc, sync::Arc};
#[cfg(any(test, feature = "test-support"))]
pub use acp::test_support::{
FakeAcpAgentServer, FakeAcpConnectionHarness, connect_fake_acp_connection,
};
pub use acp::{AcpConnection, GEMINI_TERMINAL_AUTH_METHOD_ID};
pub struct AgentServerDelegate {
+1
View File
@@ -17,6 +17,7 @@ anyhow.workspace = true
collections.workspace = true
convert_case.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
language_model.workspace = true
log.workspace = true
+17 -10
View File
@@ -6,6 +6,7 @@ use std::sync::{Arc, LazyLock};
use agent_client_protocol::ModelId;
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::channel::oneshot;
use gpui::{App, Pixels, px};
use language_model::LanguageModel;
use project::DisableAiSettings;
@@ -15,7 +16,7 @@ use settings::{
DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NewThreadLocation,
NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, RegisterSetting, Settings, SettingsContent,
SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
update_settings_file,
update_settings_file, update_settings_file_with_completion,
};
pub use crate::agent_profile::*;
@@ -242,26 +243,30 @@ impl AgentSettings {
});
}
pub fn set_layout(layout: WindowLayout, fs: Arc<dyn Fs>, cx: &App) {
pub fn set_layout(
layout: WindowLayout,
fs: Arc<dyn Fs>,
cx: &App,
) -> oneshot::Receiver<anyhow::Result<()>> {
let merged = PanelLayout::read_from(cx.global::<SettingsStore>().merged_settings());
match layout {
WindowLayout::Agent(None) => {
update_settings_file(fs, cx, move |settings, _cx| {
update_settings_file_with_completion(fs, cx, move |settings, _cx| {
PanelLayout::AGENT.write_diff_to(&merged, settings);
});
})
}
WindowLayout::Editor(None) => {
update_settings_file(fs, cx, move |settings, _cx| {
update_settings_file_with_completion(fs, cx, move |settings, _cx| {
PanelLayout::EDITOR.write_diff_to(&merged, settings);
});
})
}
WindowLayout::Agent(Some(saved))
| WindowLayout::Editor(Some(saved))
| WindowLayout::Custom(saved) => {
update_settings_file(fs, cx, move |settings, _cx| {
update_settings_file_with_completion(fs, cx, move |settings, _cx| {
saved.write_to(settings);
});
})
}
}
}
@@ -1356,8 +1361,10 @@ mod tests {
let layout = AgentSettings::get_layout(cx);
assert!(matches!(layout, WindowLayout::Custom(_)));
AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx);
});
AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx)
})
.await
.ok();
cx.run_until_parked();
+4 -1
View File
@@ -116,6 +116,7 @@ reqwest_client = { workspace = true, optional = true }
[dev-dependencies]
acp_thread = { workspace = true, features = ["test-support"] }
agent = { workspace = true, features = ["test-support"] }
agent_servers = { workspace = true, features = ["test-support"] }
buffer_diff = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
@@ -135,8 +136,10 @@ remote = { workspace = true, features = ["test-support"] }
remote_connection = { workspace = true, features = ["test-support"] }
remote_server = { workspace = true, features = ["test-support"] }
search = { workspace = true, features = ["test-support"] }
semver.workspace = true
reqwest_client.workspace = true
tempfile.workspace = true
vim.workspace = true
tree-sitter-md.workspace = true
unindent.workspace = true
+36 -32
View File
@@ -26,7 +26,7 @@ use language_model::{
ZED_CLOUD_PROVIDER_ID,
};
use language_models::AllLanguageModelSettings;
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use project::{
agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
@@ -1330,40 +1330,44 @@ fn show_unable_to_uninstall_extension_with_context_server(
move |this, _cx| {
let workspace_handle = workspace_handle.clone();
this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
.dismiss_button(true)
.action("Uninstall", move |_, _cx| {
if let Some((extension_id, _)) =
resolve_extension_for_context_server(&context_server_id, _cx)
{
ExtensionStore::global(_cx).update(_cx, |store, cx| {
store
.uninstall_extension(extension_id, cx)
.detach_and_log_err(cx);
});
this.icon(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.dismiss_button(true)
.action("Uninstall", move |_, _cx| {
if let Some((extension_id, _)) =
resolve_extension_for_context_server(&context_server_id, _cx)
{
ExtensionStore::global(_cx).update(_cx, |store, cx| {
store
.uninstall_extension(extension_id, cx)
.detach_and_log_err(cx);
});
workspace_handle
.update(_cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
cx.spawn({
let context_server_id = context_server_id.clone();
async move |_workspace_handle, cx| {
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.project
.context_servers
.remove(&context_server_id.0);
});
workspace_handle
.update(_cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
cx.spawn({
let context_server_id = context_server_id.clone();
async move |_workspace_handle, cx| {
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.project
.context_servers
.remove(&context_server_id.0);
});
anyhow::Ok(())
}
})
.detach_and_log_err(cx);
});
anyhow::Ok(())
}
})
.log_err();
}
})
.detach_and_log_err(cx);
})
.log_err();
}
})
},
);
@@ -9,7 +9,7 @@ use gpui::{
};
use language::{Language, LanguageRegistry};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use parking_lot::Mutex;
use project::{
context_server_store::{
@@ -631,8 +631,12 @@ impl ConfigureContextServerModal {
format!("{} configured successfully.", id.0),
cx,
|this, _cx| {
this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
.action("Dismiss", |_, _| {})
this.icon(
Icon::new(IconName::ToolHammer)
.size(IconSize::Small)
.color(Color::Muted),
)
.action("Dismiss", |_, _| {})
},
);
@@ -221,6 +221,8 @@ impl AgentConnectionStore {
self.entries.retain(|key, _| match key {
Agent::NativeAgent => true,
Agent::Custom { id } => store.external_agents.contains_key(id),
#[cfg(any(test, feature = "test-support"))]
Agent::Stub => true,
});
cx.notify();
}
+2 -1
View File
@@ -1418,7 +1418,8 @@ impl AgentDiff {
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
| AcpThreadEvent::WorkingDirectoriesUpdated => {}
| AcpThreadEvent::WorkingDirectoriesUpdated
| AcpThreadEvent::PromptUpdated => {}
}
}
File diff suppressed because it is too large Load Diff
+94 -27
View File
@@ -4,7 +4,6 @@ mod agent_diff;
mod agent_model_selector;
mod agent_panel;
mod agent_registry_ui;
mod branch_names;
mod buffer_codegen;
mod completion_provider;
mod config_options;
@@ -28,7 +27,6 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod thread_branch_picker;
mod thread_history;
mod thread_history_view;
mod thread_import;
@@ -37,6 +35,7 @@ pub mod thread_worktree_archive;
mod thread_worktree_picker;
pub mod threads_archive_view;
mod ui;
mod worktree_names;
use std::path::PathBuf;
use std::rc::Rc;
@@ -48,7 +47,7 @@ use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
use gpui::{Action, App, Context, Entity, SharedString, Window, actions};
use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions};
use language::{
LanguageRegistry,
language_settings::{AllLanguageSettings, EditPredictionProvider},
@@ -58,16 +57,20 @@ use language_model::{
};
use project::{AgentId, DisableAiSettings};
use prompt_store::PromptBuilder;
use release_channel::ReleaseChannel;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore};
use settings::{DockPosition, DockSide, LanguageModelSelection, Settings as _, SettingsStore};
use std::any::TypeId;
use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, DraftId, WorktreeCreationStatus};
pub use crate::agent_panel::{
AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads, WorktreeCreationStatus,
};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use crate::thread_metadata_store::ThreadId;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use conversation_view::ConversationView;
pub use external_source_prompt::ExternalSourcePrompt;
@@ -76,18 +79,21 @@ pub(crate) use model_selector::ModelSelector;
pub(crate) use model_selector_popover::ModelSelectorPopover;
pub(crate) use thread_history::ThreadHistory;
pub(crate) use thread_history_view::*;
pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
pub use thread_import::{
AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal,
channels_with_threads, import_threads_from_other_channels,
};
use zed_actions;
pub const DEFAULT_THREAD_TITLE: &str = "New Thread";
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
actions!(
agent,
[
/// Toggles the menu to create new agent threads.
ToggleNewThreadMenu,
/// Cycles through the options for where new threads start (current project or new worktree).
CycleStartThreadIn,
/// Toggles the worktree selector popover for choosing which worktree to use.
ToggleWorktreeSelector,
/// Toggles the navigation menu for switching between threads and views.
ToggleNavigationMenu,
/// Toggles the options menu for agent settings and preferences.
@@ -106,6 +112,8 @@ actions!(
OpenHistory,
/// Adds a context server to the configuration.
AddContextServer,
/// Archives the currently selected thread.
ArchiveSelectedThread,
/// Removes the currently selected thread.
RemoveSelectedThread,
/// Starts a chat conversation with follow-up enabled.
@@ -192,6 +200,8 @@ actions!(
ScrollOutputToPreviousMessage,
/// Scroll the output to the next user message.
ScrollOutputToNextMessage,
/// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
ImportThreadsFromOtherChannels,
]
);
@@ -256,6 +266,7 @@ pub struct NewNativeAgentThreadFromSummary {
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Agent {
#[default]
#[serde(alias = "NativeAgent", alias = "TextThread")]
@@ -265,6 +276,8 @@ pub enum Agent {
#[serde(rename = "name")]
id: AgentId,
},
#[cfg(any(test, feature = "test-support"))]
Stub,
}
impl From<AgentId> for Agent {
@@ -282,6 +295,8 @@ impl Agent {
match self {
Self::NativeAgent => agent::ZED_AGENT_ID.clone(),
Self::Custom { id } => id.clone(),
#[cfg(any(test, feature = "test-support"))]
Self::Stub => "stub".into(),
}
}
@@ -293,6 +308,8 @@ impl Agent {
match self {
Self::NativeAgent => "Zed Agent".into(),
Self::Custom { id, .. } => id.0.clone(),
#[cfg(any(test, feature = "test-support"))]
Self::Stub => "Stub Agent".into(),
}
}
@@ -300,6 +317,8 @@ impl Agent {
match self {
Self::NativeAgent => None,
Self::Custom { .. } => Some(IconName::Sparkle),
#[cfg(any(test, feature = "test-support"))]
Self::Stub => None,
}
}
@@ -313,6 +332,8 @@ impl Agent {
Self::Custom { id: name } => {
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
}
#[cfg(any(test, feature = "test-support"))]
Self::Stub => Rc::new(crate::test_support::StubAgentServer::default_response()),
}
}
}
@@ -336,23 +357,25 @@ pub enum NewWorktreeBranchTarget {
},
}
/// Sets where new threads will run.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
/// Creates a new git worktree and switches the workspace to it.
/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum StartThreadIn {
#[default]
LocalProject,
NewWorktree {
/// When this is None, Zed will randomly generate a worktree name
/// otherwise, the provided name will be used.
#[serde(default)]
worktree_name: Option<String>,
#[serde(default)]
branch_target: NewWorktreeBranchTarget,
},
/// A linked worktree that already exists on disk.
LinkedWorktree { path: PathBuf, display_name: String },
#[serde(deny_unknown_fields)]
pub struct CreateWorktree {
/// When this is None, Zed will randomly generate a worktree name.
pub worktree_name: Option<String>,
pub branch_target: NewWorktreeBranchTarget,
}
/// Switches the workspace to an existing linked worktree.
/// Dispatched by the unified worktree picker when the user selects an existing worktree.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(deny_unknown_fields)]
pub struct SwitchWorktree {
pub path: PathBuf,
pub display_name: String,
}
/// Content to initialize new external agent with.
@@ -495,6 +518,17 @@ pub fn init(
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
|workspace: &mut Workspace,
_: &ImportThreadsFromOtherChannels,
_window: &mut Window,
cx: &mut Context<Workspace>| {
import_threads_from_other_channels(workspace, cx);
},
);
})
.detach();
// Update command palette filter based on AI settings
update_command_palette_filter(cx);
@@ -511,7 +545,34 @@ pub fn init(
})
.detach();
maybe_backfill_editor_layout(fs, is_new_install, cx);
let agent_v2_enabled = agent_v2_enabled(cx);
if agent_v2_enabled {
maybe_backfill_editor_layout(fs, is_new_install, cx);
}
SettingsStore::update_global(cx, |store, cx| {
store.update_default_settings(cx, |defaults| {
if agent_v2_enabled {
defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left);
defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right);
defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right);
defaults.collaboration_panel.get_or_insert_default().dock =
Some(DockPosition::Right);
defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
} else {
defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right);
defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left);
defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Left);
defaults.collaboration_panel.get_or_insert_default().dock =
Some(DockPosition::Left);
defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left);
}
});
});
}
fn agent_v2_enabled(cx: &App) -> bool {
!matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable))
}
fn maybe_backfill_editor_layout(fs: Arc<dyn Fs>, is_new_install: bool, cx: &mut App) {
@@ -539,6 +600,7 @@ fn maybe_backfill_editor_layout(fs: Arc<dyn Fs>, is_new_install: bool, cx: &mut
fn update_command_palette_filter(cx: &mut App) {
let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
let agent_enabled = AgentSettings::get_global(cx).enabled;
let agent_v2_enabled = agent_v2_enabled(cx);
let edit_prediction_provider = AllLanguageSettings::get_global(cx)
.edit_predictions
@@ -608,7 +670,11 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
}
filter.show_namespace("multi_workspace");
if agent_v2_enabled {
filter.show_namespace("multi_workspace");
} else {
filter.hide_namespace("multi_workspace");
}
});
}
@@ -823,6 +889,7 @@ mod tests {
.unwrap();
cx.update(|cx| {
cx.set_global(db::AppDatabase::test_new());
let store = SettingsStore::test(cx);
cx.set_global(store);
AgentSettings::register(cx);
File diff suppressed because it is too large Load Diff
@@ -7,13 +7,14 @@ use std::cell::RefCell;
use acp_thread::{ContentBlock, PlanEntry};
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
use editor::actions::OpenExcerpts;
use feature_flags::AcpBetaFeatureFlag;
use crate::StartThreadIn;
use crate::message_editor::SharedSessionCapabilities;
use gpui::{Corner, List};
use heapless::Vec as ArrayVec;
use language_model::{LanguageModelEffortLevel, Speed};
use settings::update_settings_file;
use settings::{SidebarSide, update_settings_file};
use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab};
use workspace::SERIALIZATION_THROTTLE_TIME;
@@ -205,7 +206,6 @@ impl RenderOnce for GeneratingSpinnerElement {
}
pub enum AcpThreadViewEvent {
FirstSendRequested { content: Vec<acp::ContentBlock> },
MessageSentOrQueued,
}
@@ -262,8 +262,8 @@ impl PermissionSelection {
}
pub struct ThreadView {
pub id: acp::SessionId,
pub parent_id: Option<acp::SessionId>,
pub session_id: acp::SessionId,
pub parent_session_id: Option<acp::SessionId>,
pub thread: Entity<AcpThread>,
pub(crate) conversation: Entity<super::Conversation>,
pub server_view: WeakEntity<ConversationView>,
@@ -294,7 +294,7 @@ pub struct ThreadView {
pub expanded_thinking_blocks: HashSet<(usize, usize)>,
auto_expanded_thinking_block: Option<(usize, usize)>,
user_toggled_thinking_blocks: HashSet<(usize, usize)>,
pub subagent_scroll_handles: RefCell<HashMap<agent_client_protocol::SessionId, ScrollHandle>>,
pub subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
pub edits_expanded: bool,
pub plan_expanded: bool,
pub queue_expanded: bool,
@@ -337,7 +337,7 @@ pub struct ThreadView {
}
impl Focusable for ThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.parent_id.is_some() {
if self.parent_session_id.is_some() {
self.focus_handle.clone()
} else {
self.active_editor(cx).focus_handle(cx)
@@ -357,7 +357,6 @@ pub struct TurnFields {
impl ThreadView {
pub(crate) fn new(
parent_id: Option<acp::SessionId>,
thread: Entity<AcpThread>,
conversation: Entity<super::Conversation>,
server_view: WeakEntity<ConversationView>,
@@ -383,7 +382,8 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let id = thread.read(cx).session_id().clone();
let session_id = thread.read(cx).session_id().clone();
let parent_session_id = thread.read(cx).parent_session_id().cloned();
let has_commands = !session_capabilities.read().available_commands().is_empty();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
@@ -492,8 +492,8 @@ impl ThreadView {
None
};
this.update(cx, |this, cx| {
this.thread.update(cx, |thread, _cx| {
thread.set_draft_prompt(draft);
this.thread.update(cx, |thread, cx| {
thread.set_draft_prompt(draft, cx);
});
this.schedule_save(cx);
})
@@ -507,8 +507,8 @@ impl ThreadView {
.unwrap_or_default();
let mut this = Self {
id,
parent_id,
session_id,
parent_session_id,
focus_handle: cx.focus_handle(),
thread,
conversation,
@@ -644,6 +644,10 @@ impl ThreadView {
}
}
pub fn is_draft(&self, cx: &App) -> bool {
self.thread.read(cx).entries().is_empty()
}
pub(crate) fn as_native_connection(
&self,
cx: &App,
@@ -692,7 +696,7 @@ impl ThreadView {
}
fn is_subagent(&self) -> bool {
self.parent_id.is_some()
self.parent_session_id.is_some()
}
/// Returns the currently active editor, either for a message that is being
@@ -749,6 +753,7 @@ impl ThreadView {
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
if let Some(AgentThreadEntry::UserMessage(user_message)) =
self.thread.read(cx).entries().get(event.entry_index)
&& self.thread.read(cx).supports_truncate(cx)
&& user_message.id.is_some()
&& !self.is_subagent()
{
@@ -759,6 +764,7 @@ impl ThreadView {
ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
if let Some(AgentThreadEntry::UserMessage(user_message)) =
self.thread.read(cx).entries().get(event.entry_index)
&& self.thread.read(cx).supports_truncate(cx)
&& user_message.id.is_some()
&& !self.is_subagent()
{
@@ -879,10 +885,51 @@ impl ThreadView {
if let Some(usage) = self.thread.read(cx).token_usage() {
if let Some(tokens) = &mut self.turn_fields.turn_tokens {
*tokens += usage.output_tokens;
self.emit_token_limit_telemetry_if_needed(cx);
}
}
}
fn emit_token_limit_telemetry_if_needed(&mut self, cx: &App) {
let (ratio, agent_telemetry_id, session_id) = {
let thread_data = self.thread.read(cx);
let Some(token_usage) = thread_data.token_usage() else {
return;
};
(
token_usage.ratio(),
thread_data.connection().telemetry_id(),
thread_data.session_id().clone(),
)
};
let kind = match ratio {
acp_thread::TokenUsageRatio::Normal => {
self.last_token_limit_telemetry = None;
return;
}
acp_thread::TokenUsageRatio::Warning => "warning",
acp_thread::TokenUsageRatio::Exceeded => "exceeded",
};
let should_skip = self
.last_token_limit_telemetry
.as_ref()
.is_some_and(|last| *last >= ratio);
if should_skip {
return;
}
self.last_token_limit_telemetry = Some(ratio);
telemetry::event!(
"Agent Token Limit Warning",
agent = agent_telemetry_id,
session_id = session_id,
kind = kind,
);
}
// sending
fn clear_external_source_prompt_warning(&mut self, cx: &mut Context<Self>) {
@@ -901,49 +948,6 @@ impl ThreadView {
let message_editor = self.message_editor.clone();
// Intercept the first send so the agent panel can capture the full
// content blocks — needed for "Start thread in New Worktree",
// which must create a workspace before sending the message there.
let intercept_first_send = self.thread.read(cx).entries().is_empty()
&& !message_editor.read(cx).is_empty(cx)
&& self
.workspace
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
.is_some_and(|panel| {
!matches!(
panel.read(cx).start_thread_in(),
StartThreadIn::LocalProject
)
});
if intercept_first_send {
cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
let content_task = self.resolve_message_contents(&message_editor, cx);
cx.spawn(async move |this, cx| match content_task.await {
Ok((content, _tracked_buffers)) => {
if content.is_empty() {
return;
}
this.update(cx, |_, cx| {
cx.emit(AcpThreadViewEvent::FirstSendRequested { content });
})
.ok();
}
Err(error) => {
this.update(cx, |this, cx| {
this.handle_thread_error(error, cx);
})
.ok();
}
})
.detach();
return;
}
let is_editor_empty = message_editor.read(cx).is_empty(cx);
let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
@@ -1068,6 +1072,11 @@ impl ThreadView {
})
.detach();
let side = match AgentSettings::get_global(cx).sidebar_side() {
SidebarSide::Left => "left",
SidebarSide::Right => "right",
};
let task = cx.spawn_in(window, async move |this, cx| {
let Some((contents, tracked_buffers)) = contents_task.await? else {
return Ok(());
@@ -1136,7 +1145,8 @@ impl ThreadView {
session = session_id,
parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()),
model = model_id,
mode = mode_id
mode = mode_id,
side = side
);
thread.send(contents, cx)
@@ -1165,6 +1175,7 @@ impl ThreadView {
mode = mode_id,
status,
turn_time_ms,
side = side
);
res.map(|_| ())
});
@@ -1739,8 +1750,9 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let session_id = self.thread.read(cx).session_id().clone();
self.conversation.update(cx, |conversation, cx| {
conversation.authorize_pending_tool_call(&self.id, kind, cx)
conversation.authorize_pending_tool_call(&session_id, kind, cx)
})?;
if self.should_be_following {
self.workspace
@@ -1780,8 +1792,9 @@ impl ThreadView {
_ => acp::PermissionOptionKind::AllowOnce,
};
let session_id = self.thread.read(cx).session_id().clone();
self.authorize_tool_call(
self.id.clone(),
session_id,
tool_call_id,
SelectedPermissionOutcome::new(option_id, option_kind),
window,
@@ -1859,10 +1872,20 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let (session_id, tool_call_id, options) =
self.conversation.read(cx).pending_tool_call(&self.id, cx)?;
let session_id = self.thread.read(cx).session_id().clone();
let (returned_session_id, tool_call_id, options) = self
.conversation
.read(cx)
.pending_tool_call(&session_id, cx)?;
let options = options.clone();
self.authorize_with_granularity(session_id, tool_call_id, &options, is_allow, window, cx)
self.authorize_with_granularity(
returned_session_id,
tool_call_id,
&options,
is_allow,
window,
cx,
)
}
fn authorize_with_granularity(
@@ -2262,13 +2285,14 @@ impl ThreadView {
h_flex()
.w_full()
.px_2()
.justify_center()
.child(
v_flex()
.flex_basis(max_content_width)
.flex_shrink()
.flex_grow_0()
.mx_2()
.max_w_full()
.bg(self.activity_bar_bg(cx))
.border_1()
.border_b_0()
@@ -2548,6 +2572,35 @@ impl ThreadView {
)
}
fn collect_subagent_items_for_sessions(
entries: &[AgentThreadEntry],
awaiting_session_ids: &[acp::SessionId],
cx: &App,
) -> Vec<(SharedString, usize)> {
let tool_calls_by_session: HashMap<_, _> = entries
.iter()
.enumerate()
.filter_map(|(entry_ix, entry)| {
let AgentThreadEntry::ToolCall(tool_call) = entry else {
return None;
};
let info = tool_call.subagent_session_info.as_ref()?;
let summary_text = tool_call.label.read(cx).source().to_string();
let subagent_summary = if summary_text.is_empty() {
SharedString::from("Subagent")
} else {
SharedString::from(summary_text)
};
Some((info.session_id.clone(), (subagent_summary, entry_ix)))
})
.collect();
awaiting_session_ids
.iter()
.filter_map(|session_id| tool_calls_by_session.get(session_id).cloned())
.collect()
}
fn render_subagents_awaiting_permission(&self, cx: &Context<Self>) -> Option<AnyElement> {
let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx);
@@ -2555,30 +2608,15 @@ impl ThreadView {
return None;
}
let awaiting_session_ids: Vec<_> = awaiting
.iter()
.map(|(session_id, _)| session_id.clone())
.collect();
let thread = self.thread.read(cx);
let entries = thread.entries();
let mut subagent_items: Vec<(SharedString, usize)> = Vec::new();
for (session_id, _) in &awaiting {
for (entry_ix, entry) in entries.iter().enumerate() {
if let AgentThreadEntry::ToolCall(tool_call) = entry {
if let Some(info) = &tool_call.subagent_session_info {
if &info.session_id == session_id {
let subagent_summary: SharedString = {
let summary_text = tool_call.label.read(cx).source().to_string();
if !summary_text.is_empty() {
summary_text.into()
} else {
"Subagent".into()
}
};
subagent_items.push((subagent_summary, entry_ix));
break;
}
}
}
}
}
let subagent_items =
Self::collect_subagent_items_for_sessions(entries, &awaiting_session_ids, cx);
if subagent_items.is_empty() {
return None;
@@ -2807,7 +2845,7 @@ impl ThreadView {
IconButton::new("dismiss-plan", IconName::Close)
.icon_size(IconSize::XSmall)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::text("Clear plan"))
.tooltip(Tooltip::text("Clear Plan"))
.on_click(cx.listener(|this, _, _, cx| {
this.thread.update(cx, |thread, cx| thread.clear_plan(cx));
cx.stop_propagation();
@@ -2831,51 +2869,64 @@ impl ThreadView {
.max_h_40()
.overflow_y_scroll()
.children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
let element = h_flex()
.py_1()
.px_2()
.gap_2()
.justify_between()
.bg(cx.theme().colors().editor_background)
.when(index < plan.entries.len() - 1, |parent| {
parent.border_color(cx.theme().colors().border).border_b_1()
})
.child(
h_flex()
.id(("plan_entry", index))
.gap_1p5()
.max_w_full()
.overflow_x_scroll()
.text_xs()
.text_color(cx.theme().colors().text_muted)
.child(match entry.status {
acp::PlanEntryStatus::InProgress => {
Icon::new(IconName::TodoProgress)
.size(IconSize::Small)
.color(Color::Accent)
.with_rotate_animation(2)
.into_any_element()
}
acp::PlanEntryStatus::Completed => {
Icon::new(IconName::TodoComplete)
.size(IconSize::Small)
.color(Color::Success)
.into_any_element()
}
acp::PlanEntryStatus::Pending | _ => {
Icon::new(IconName::TodoPending)
.size(IconSize::Small)
.color(Color::Muted)
.into_any_element()
}
})
.child(MarkdownElement::new(
entry.content.clone(),
plan_label_markdown_style(&entry.status, window, cx),
)),
);
let entry_bg = cx.theme().colors().editor_background;
let tooltip_text: SharedString = entry.content.read(cx).source().to_string().into();
Some(element)
Some(
h_flex()
.id(("plan_entry_row", index))
.py_1()
.px_2()
.gap_2()
.justify_between()
.relative()
.bg(entry_bg)
.when(index < plan.entries.len() - 1, |parent| {
parent.border_color(cx.theme().colors().border).border_b_1()
})
.overflow_hidden()
.child(
h_flex()
.id(("plan_entry", index))
.gap_1p5()
.min_w_0()
.text_xs()
.text_color(cx.theme().colors().text_muted)
.child(match entry.status {
acp::PlanEntryStatus::InProgress => {
Icon::new(IconName::TodoProgress)
.size(IconSize::Small)
.color(Color::Accent)
.with_rotate_animation(2)
.into_any_element()
}
acp::PlanEntryStatus::Completed => {
Icon::new(IconName::TodoComplete)
.size(IconSize::Small)
.color(Color::Success)
.into_any_element()
}
acp::PlanEntryStatus::Pending | _ => {
Icon::new(IconName::TodoPending)
.size(IconSize::Small)
.color(Color::Muted)
.into_any_element()
}
})
.child(MarkdownElement::new(
entry.content.clone(),
plan_label_markdown_style(&entry.status, window, cx),
)),
)
.child(div().absolute().top_0().right_0().h_full().w_8().bg(
linear_gradient(
90.,
linear_color_stop(entry_bg, 1.),
linear_color_stop(entry_bg.opacity(0.), 0.),
),
))
.tooltip(Tooltip::text(tooltip_text)),
)
}))
.into_any_element()
}
@@ -3092,7 +3143,7 @@ impl ThreadView {
}
fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool {
let Some(parent_session_id) = self.parent_id.as_ref() else {
let Some(parent_session_id) = self.parent_session_id.as_ref() else {
return false;
};
@@ -3119,9 +3170,10 @@ impl ThreadView {
}
pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let Some(parent_session_id) = self.parent_id.clone() else {
if self.parent_session_id.is_none() {
return None;
};
}
let parent_session_id = self.thread.read(cx).parent_session_id()?.clone();
let server_view = self.server_view.clone();
let thread = self.thread.clone();
@@ -3189,7 +3241,7 @@ impl ThreadView {
.tooltip(Tooltip::text("Minimize Subagent"))
.on_click(move |_, window, cx| {
let _ = server_view.update(cx, |server_view, cx| {
server_view.navigate_to_session(
server_view.navigate_to_thread(
parent_session_id.clone(),
window,
cx,
@@ -3513,6 +3565,19 @@ impl ThreadView {
let usage = thread.token_usage()?;
let show_split = self.supports_split_token_display(cx);
let cost_label = if cx.has_flag::<AcpBetaFeatureFlag>() {
thread.cost().map(|cost| {
let precision = if cost.amount > 0.0 && cost.amount < 0.01 {
4
} else {
2
};
format!("{:.prec$} {}", cost.amount, cost.currency, prec = precision)
})
} else {
None
};
let progress_color = |ratio: f32| -> Hsla {
if ratio >= 0.85 {
cx.theme().status().warning
@@ -3583,6 +3648,7 @@ impl ThreadView {
let output_max_label = output_max_label.clone();
let project_entry_ids = project_entry_ids.clone();
let workspace = workspace.clone();
let cost_label = cost_label.clone();
cx.new(move |_cx| TokenUsageTooltip {
percentage,
used,
@@ -3592,6 +3658,7 @@ impl ThreadView {
input_max: input_max_label,
output_max: output_max_label,
show_split,
cost_label,
separator_color: tooltip_separator_color,
user_rules_count,
first_user_rules_id,
@@ -4239,6 +4306,7 @@ struct TokenUsageTooltip {
input_max: String,
output_max: String,
show_split: bool,
cost_label: Option<String>,
separator_color: Color,
user_rules_count: usize,
first_user_rules_id: Option<uuid::Uuid>,
@@ -4258,6 +4326,7 @@ impl Render for TokenUsageTooltip {
let input_max = self.input_max.clone();
let output_max = self.output_max.clone();
let show_split = self.show_split;
let cost_label = self.cost_label.clone();
let user_rules_count = self.user_rules_count;
let first_user_rules_id = self.first_user_rules_id;
let project_rules_count = self.project_rules_count;
@@ -4305,6 +4374,22 @@ impl Render for TokenUsageTooltip {
),
)
})
.when_some(cost_label, |this, cost_label| {
this.child(
v_flex()
.mt_1p5()
.pt_1p5()
.gap_0p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(
Label::new("Cost")
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(Label::new(cost_label)),
)
})
.when(
user_rules_count > 0 || project_rules_count > 0,
move |this| {
@@ -4463,7 +4548,8 @@ impl ThreadView {
.is_some_and(|checkpoint| checkpoint.show);
let is_subagent = self.is_subagent();
let is_editable = message.id.is_some() && !is_subagent;
let can_rewind = self.thread.read(cx).supports_truncate(cx);
let is_editable = can_rewind && message.id.is_some() && !is_subagent;
let agent_name = if is_subagent {
"subagents".into()
} else {
@@ -4688,17 +4774,28 @@ impl ThreadView {
.into_any()
}
}
AgentThreadEntry::ToolCall(tool_call) => self
.render_any_tool_call(
&self.id,
AgentThreadEntry::ToolCall(tool_call) => {
let tool_call = self.render_any_tool_call(
self.thread.read(cx).session_id(),
entry_ix,
tool_call,
&self.focus_handle(cx),
false,
window,
cx,
)
.into_any(),
);
if let Some(handle) = self
.entry_view_state
.read(cx)
.entry(entry_ix)
.and_then(|entry| entry.focus_handle(cx))
{
tool_call.track_focus(&handle).into_any()
} else {
tool_call.into_any()
}
}
AgentThreadEntry::CompletedPlan(entries) => {
self.render_completed_plan(entries, window, cx)
}
@@ -6124,7 +6221,7 @@ impl ThreadView {
.when_some(confirmation_options, |this, options| {
let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
this.child(self.render_permission_buttons(
self.id.clone(),
self.thread.read(cx).session_id().clone(),
is_first,
options,
entry_ix,
@@ -6146,7 +6243,8 @@ impl ThreadView {
.read(cx)
.pending_tool_call(active_session_id, cx)
.map_or(false, |(pending_session_id, pending_tool_call_id, _)| {
self.id == pending_session_id && tool_call_id == &pending_tool_call_id
self.thread.read(cx).session_id() == &pending_session_id
&& tool_call_id == &pending_tool_call_id
})
}
@@ -6358,7 +6456,7 @@ impl ThreadView {
)
})
.child(self.render_permission_buttons(
self.id.clone(),
self.thread.read(cx).session_id().clone(),
self.is_first_tool_call(active_session_id, &tool_call.id, cx),
options,
entry_ix,
@@ -7117,10 +7215,10 @@ impl ThreadView {
})
.label_size(LabelSize::Small)
.on_click(cx.listener({
let session_id = session_id.clone();
let tool_call_id = tool_call_id.clone();
let option_id = option.option_id.clone();
let option_kind = option.kind;
let session_id = session_id.clone();
move |this, _, window, cx| {
this.authorize_tool_call(
session_id.clone(),
@@ -7673,11 +7771,11 @@ impl ThreadView {
window: &Window,
cx: &Context<Self>,
) -> Div {
let subagent_thread_view = subagent_session_id.and_then(|id| {
let subagent_thread_view = subagent_session_id.and_then(|session_id| {
self.server_view
.upgrade()
.and_then(|server_view| server_view.read(cx).as_connected())
.and_then(|connected| connected.threads.get(&id))
.and_then(|connected| connected.threads.get(&session_id))
});
let content = self.render_subagent_card(
@@ -7714,12 +7812,11 @@ impl ThreadView {
.map(|log| log.read(cx).changed_buffers(cx))
.unwrap_or_default();
let is_pending_tool_call = thread
let is_pending_tool_call = thread_view
.as_ref()
.and_then(|thread| {
self.conversation
.read(cx)
.pending_tool_call(thread.read(cx).session_id(), cx)
.and_then(|tv| {
let sid = tv.read(cx).thread.read(cx).session_id();
self.conversation.read(cx).pending_tool_call(sid, cx)
})
.is_some();
@@ -7945,12 +8042,13 @@ impl ThreadView {
)
.when_some(thread_view, |this, thread_view| {
let thread = &thread_view.read(cx).thread;
let tv_session_id = thread.read(cx).session_id();
let pending_tool_call = self
.conversation
.read(cx)
.pending_tool_call(thread.read(cx).session_id(), cx);
.pending_tool_call(tv_session_id, cx);
let session_id = thread.read(cx).session_id().clone();
let nav_session_id = tv_session_id.clone();
let fullscreen_toggle = h_flex()
.id(entry_ix)
@@ -7972,7 +8070,7 @@ impl ThreadView {
telemetry::event!("Subagent Maximized");
this.server_view
.update(cx, |this, cx| {
this.navigate_to_session(session_id.clone(), window, cx);
this.navigate_to_thread(nav_session_id.clone(), window, cx);
})
.ok();
}));
@@ -8069,7 +8167,7 @@ impl ThreadView {
let scroll_handle = self
.subagent_scroll_handles
.borrow_mut()
.entry(session_id.clone())
.entry(subagent_view.session_id.clone())
.or_default()
.clone();
@@ -8687,7 +8785,7 @@ impl ThreadView {
}
fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
if self.token_limit_callout_dismissed {
if self.token_limit_callout_dismissed || self.as_native_thread(cx).is_none() {
return None;
}
@@ -8869,15 +8967,15 @@ impl Render for ThreadView {
.key_context("AcpThread")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
if this.parent_id.is_none() {
if this.parent_session_id.is_none() {
this.cancel_generation(cx);
}
}))
.on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
if let Some(parent_session_id) = this.parent_id.clone() {
if let Some(parent_session_id) = this.thread.read(cx).parent_session_id().cloned() {
this.server_view
.update(cx, |view, cx| {
view.navigate_to_session(parent_session_id, window, cx);
view.navigate_to_thread(parent_session_id, window, cx);
})
.ok();
}
+27 -5
View File
@@ -72,6 +72,7 @@ impl EntryViewState {
match thread_entry {
AgentThreadEntry::UserMessage(message) => {
let can_rewind = thread.read(cx).supports_truncate(cx);
let has_id = message.id.is_some();
let is_subagent = thread.read(cx).parent_session_id().is_some();
let chunks = message.chunks.clone();
@@ -101,7 +102,7 @@ impl EntryViewState {
window,
cx,
);
if !has_id || is_subagent {
if !can_rewind || !has_id || is_subagent {
editor.set_read_only(true, cx);
}
editor.set_message(chunks, window, cx);
@@ -129,6 +130,7 @@ impl EntryViewState {
index,
Entry::ToolCall(ToolCallEntry {
content: HashMap::default(),
focus_handle: cx.focus_handle(),
}),
);
let Some(Entry::ToolCall(tool_call)) = self.entries.get_mut(index) else {
@@ -261,7 +263,7 @@ impl EntryViewState {
Entry::UserMessage { .. }
| Entry::AssistantMessage { .. }
| Entry::CompletedPlan => {}
Entry::ToolCall(ToolCallEntry { content }) => {
Entry::ToolCall(ToolCallEntry { content, .. }) => {
for view in content.values() {
if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
diff_editor.update(cx, |diff_editor, cx| {
@@ -320,6 +322,7 @@ impl AssistantMessageEntry {
#[derive(Debug)]
pub struct ToolCallEntry {
content: HashMap<EntityId, AnyEntity>,
focus_handle: FocusHandle,
}
#[derive(Debug)]
@@ -335,7 +338,8 @@ impl Entry {
match self {
Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
Self::AssistantMessage(message) => Some(message.focus_handle.clone()),
Self::ToolCall(_) | Self::CompletedPlan => None,
Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()),
Self::CompletedPlan => None,
}
}
@@ -375,7 +379,7 @@ impl Entry {
fn content_map(&self) -> Option<&HashMap<EntityId, AnyEntity>> {
match self {
Self::ToolCall(ToolCallEntry { content }) => Some(content),
Self::ToolCall(ToolCallEntry { content, .. }) => Some(content),
_ => None,
}
}
@@ -383,12 +387,29 @@ impl Entry {
#[cfg(test)]
pub fn has_content(&self) -> bool {
match self {
Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(),
Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(),
Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false,
}
}
}
impl Focusable for ToolCallEntry {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Focusable for Entry {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self {
Self::UserMessage(editor) => editor.read(cx).focus_handle(cx),
Self::AssistantMessage(message) => message.focus_handle.clone(),
Self::ToolCall(tool_call) => tool_call.focus_handle.clone(),
Self::CompletedPlan => cx.focus_handle(),
}
}
}
fn create_terminal(
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
@@ -437,6 +458,7 @@ fn create_editor_diff(
editor.set_show_indent_guides(false, cx);
editor.set_read_only(true);
editor.set_delegate_open_excerpts(true);
editor.set_show_bookmarks(false, cx);
editor.set_show_breakpoints(false, cx);
editor.set_show_code_actions(false, cx);
editor.set_show_git_diff_gutter(false, cx);
+2 -54
View File
@@ -8,8 +8,8 @@ use gpui::{
Subscription, Task,
};
use language_model::{
AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider,
LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
@@ -124,7 +124,6 @@ pub struct LanguageModelPickerDelegate {
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
_authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
popover_styles: bool,
focus_handle: FocusHandle,
@@ -151,7 +150,6 @@ impl LanguageModelPickerDelegate {
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
on_toggle_favorite: Arc::new(on_toggle_favorite),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
@@ -197,56 +195,6 @@ impl LanguageModelPickerDelegate {
.unwrap_or(0)
}
/// Authenticates all providers in the [`LanguageModelRegistry`].
///
/// We do this so that we can populate the language selector with all of the
/// models from the configured providers.
fn authenticate_all_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.visible_providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
cx.spawn(async move |_cx| {
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
if let Err(err) = authenticate_task.await {
if matches!(err, AuthenticateError::CredentialsNotFound) {
// Since we're authenticating these providers in the
// background for the purposes of populating the
// language selector, we don't care about providers
// where the credentials are not found.
} else {
// Some providers have noisy failure states that we
// don't want to spam the logs with every time the
// language model selector is initialized.
//
// Ideally these should have more clear failure modes
// that we know are safe to ignore here, like what we do
// with `CredentialsNotFound` above.
match provider_id.0.as_ref() {
"lmstudio" | "ollama" => {
// LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
//
// These fail noisily, so we don't log them.
}
"copilot_chat" => {
// Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
}
_ => {
log::error!(
"Failed to authenticate provider: {}: {err:#}",
provider_name.0
);
}
}
}
}
}
})
}
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
+17 -2
View File
@@ -7,7 +7,7 @@ use agent_settings::{
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Entity,
Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Empty, Entity,
FocusHandle, Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window,
};
use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu};
@@ -31,6 +31,9 @@ pub trait ProfileProvider {
/// Check if profiles are supported in the current context (e.g. if the model that is selected has tool support)
fn profiles_supported(&self, cx: &App) -> bool;
/// Check if there is a model selected in the current context.
fn model_selected(&self, cx: &App) -> bool;
}
pub struct ProfileSelector {
@@ -152,6 +155,10 @@ impl Focusable for ProfileSelector {
impl Render for ProfileSelector {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.provider.model_selected(cx) {
return Empty.into_any_element();
}
if !self.provider.profiles_supported(cx) {
return Button::new("tools-not-supported-button", "Tools Unsupported")
.disabled(true)
@@ -792,11 +799,15 @@ mod tests {
struct TestProfileProvider {
profile_id: AgentProfileId,
has_model: bool,
}
impl TestProfileProvider {
fn new(profile_id: AgentProfileId) -> Self {
Self { profile_id }
Self {
profile_id,
has_model: true,
}
}
}
@@ -810,5 +821,9 @@ mod tests {
fn profiles_supported(&self, _cx: &App) -> bool {
true
}
fn model_selected(&self, _cx: &App) -> bool {
self.has_model
}
}
}
+35
View File
@@ -6,11 +6,36 @@ use project::AgentId;
use project::Project;
use settings::SettingsStore;
use std::any::Any;
use std::cell::RefCell;
use std::rc::Rc;
use crate::AgentPanel;
use crate::agent_panel;
thread_local! {
static STUB_AGENT_CONNECTION: RefCell<Option<StubAgentConnection>> = const { RefCell::new(None) };
}
/// Registers a `StubAgentConnection` that will be used by `Agent::Stub`.
///
/// Returns the same connection so callers can hold onto it and control
/// the stub's behavior (e.g. `connection.set_next_prompt_updates(...)`).
pub fn set_stub_agent_connection(connection: StubAgentConnection) -> StubAgentConnection {
STUB_AGENT_CONNECTION.with(|cell| {
*cell.borrow_mut() = Some(connection.clone());
});
connection
}
/// Returns the shared `StubAgentConnection` used by `Agent::Stub`,
/// creating a default one if none was registered.
pub fn stub_agent_connection() -> StubAgentConnection {
STUB_AGENT_CONNECTION.with(|cell| {
let mut borrow = cell.borrow_mut();
borrow.get_or_insert_with(StubAgentConnection::new).clone()
})
}
pub struct StubAgentServer<C> {
connection: C,
agent_id: AgentId,
@@ -73,6 +98,9 @@ pub fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
cx.set_global(acp_thread::StubSessionCounter(
std::sync::atomic::AtomicUsize::new(0),
));
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
release_channel::init("0.0.0".parse().unwrap(), cx);
@@ -128,3 +156,10 @@ pub fn active_session_id(panel: &Entity<AgentPanel>, cx: &VisualTestContext) ->
thread.read(cx).session_id().clone()
})
}
pub fn active_thread_id(
panel: &Entity<AgentPanel>,
cx: &VisualTestContext,
) -> crate::thread_metadata_store::ThreadId {
panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap())
}
-758
View File
@@ -1,758 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use collections::HashSet as CollectionsHashSet;
use std::path::PathBuf;
use std::sync::Arc;
use fuzzy::StringMatchCandidate;
use git::repository::Branch as GitBranch;
use gpui::{
AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::Project;
use ui::{
Divider, DocumentationAside, HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem,
ListItemSpacing, prelude::*,
};
use util::ResultExt as _;
use crate::{NewWorktreeBranchTarget, StartThreadIn};
pub(crate) struct ThreadBranchPicker {
picker: Entity<Picker<ThreadBranchPickerDelegate>>,
focus_handle: FocusHandle,
_subscription: gpui::Subscription,
}
impl ThreadBranchPicker {
pub fn new(
project: Entity<Project>,
current_target: &StartThreadIn,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let project_worktree_paths: HashSet<PathBuf> = project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect();
let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
let current_branch_name = project
.read(cx)
.active_repository(cx)
.and_then(|repo| {
repo.read(cx)
.branch
.as_ref()
.map(|branch| branch.name().to_string())
})
.unwrap_or_else(|| "HEAD".to_string());
let repository = if has_multiple_repositories {
None
} else {
project.read(cx).active_repository(cx)
};
let branches_request = repository
.clone()
.map(|repo| repo.update(cx, |repo, _| repo.branches()));
let default_branch_request = repository
.clone()
.map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
let worktrees_request = repository.map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
let (worktree_name, branch_target) = match current_target {
StartThreadIn::NewWorktree {
worktree_name,
branch_target,
} => (worktree_name.clone(), branch_target.clone()),
_ => (None, NewWorktreeBranchTarget::default()),
};
let delegate = ThreadBranchPickerDelegate {
matches: vec![ThreadBranchEntry::CurrentBranch],
all_branches: None,
occupied_branches: None,
selected_index: 0,
worktree_name,
branch_target,
project_worktree_paths,
current_branch_name,
default_branch_name: None,
has_multiple_repositories,
};
let picker = cx.new(|cx| {
Picker::list(delegate, window, cx)
.list_measure_all()
.modal(false)
.max_height(Some(rems(20.).into()))
});
let focus_handle = picker.focus_handle(cx);
if let (Some(branches_request), Some(default_branch_request), Some(worktrees_request)) =
(branches_request, default_branch_request, worktrees_request)
{
let picker_handle = picker.downgrade();
cx.spawn_in(window, async move |_this, cx| {
let branches = branches_request.await??;
let default_branch = default_branch_request.await.ok().and_then(Result::ok).flatten();
let worktrees = worktrees_request.await??;
let remote_upstreams: CollectionsHashSet<_> = branches
.iter()
.filter_map(|branch| {
branch
.upstream
.as_ref()
.filter(|upstream| upstream.is_remote())
.map(|upstream| upstream.ref_name.clone())
})
.collect();
let mut occupied_branches = HashMap::new();
for worktree in worktrees {
let Some(branch_name) = worktree.branch_name().map(ToOwned::to_owned) else {
continue;
};
let reason = if picker_handle
.read_with(cx, |picker, _| {
picker
.delegate
.project_worktree_paths
.contains(&worktree.path)
})
.unwrap_or(false)
{
format!(
"This branch is already checked out in the current project worktree at {}.",
worktree.path.display()
)
} else {
format!(
"This branch is already checked out in a linked worktree at {}.",
worktree.path.display()
)
};
occupied_branches.insert(branch_name, reason);
}
let mut all_branches: Vec<_> = branches
.into_iter()
.filter(|branch| !remote_upstreams.contains(&branch.ref_name))
.collect();
all_branches.sort_by_key(|branch| {
(
branch.is_remote(),
!branch.is_head,
branch
.most_recent_commit
.as_ref()
.map(|commit| 0 - commit.commit_timestamp),
)
});
picker_handle.update_in(cx, |picker, window, cx| {
picker.delegate.all_branches = Some(all_branches);
picker.delegate.occupied_branches = Some(occupied_branches);
picker.delegate.default_branch_name = default_branch.map(|branch| branch.to_string());
picker.refresh(window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
let subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
Self {
picker,
focus_handle,
_subscription: subscription,
}
}
}
impl Focusable for ThreadBranchPicker {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for ThreadBranchPicker {}
impl Render for ThreadBranchPicker {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(rems(22.))
.elevation_3(cx)
.child(self.picker.clone())
.on_mouse_down_out(cx.listener(|_, _, _, cx| {
cx.emit(DismissEvent);
}))
}
}
#[derive(Clone)]
enum ThreadBranchEntry {
CurrentBranch,
DefaultBranch,
Separator,
ExistingBranch {
branch: GitBranch,
positions: Vec<usize>,
},
CreateNamed {
name: String,
},
}
pub(crate) struct ThreadBranchPickerDelegate {
matches: Vec<ThreadBranchEntry>,
all_branches: Option<Vec<GitBranch>>,
occupied_branches: Option<HashMap<String, String>>,
selected_index: usize,
worktree_name: Option<String>,
branch_target: NewWorktreeBranchTarget,
project_worktree_paths: HashSet<PathBuf>,
current_branch_name: String,
default_branch_name: Option<String>,
has_multiple_repositories: bool,
}
impl ThreadBranchPickerDelegate {
fn new_worktree_action(&self, branch_target: NewWorktreeBranchTarget) -> StartThreadIn {
StartThreadIn::NewWorktree {
worktree_name: self.worktree_name.clone(),
branch_target,
}
}
fn selected_entry_name(&self) -> Option<&str> {
match &self.branch_target {
NewWorktreeBranchTarget::CurrentBranch => None,
NewWorktreeBranchTarget::ExistingBranch { name } => Some(name),
NewWorktreeBranchTarget::CreateBranch {
from_ref: Some(from_ref),
..
} => Some(from_ref),
NewWorktreeBranchTarget::CreateBranch { name, .. } => Some(name),
}
}
fn prefer_create_entry(&self) -> bool {
matches!(
&self.branch_target,
NewWorktreeBranchTarget::CreateBranch { from_ref: None, .. }
)
}
fn fixed_matches(&self) -> Vec<ThreadBranchEntry> {
let mut matches = vec![ThreadBranchEntry::CurrentBranch];
if !self.has_multiple_repositories
&& self
.default_branch_name
.as_ref()
.is_some_and(|default_branch_name| default_branch_name != &self.current_branch_name)
{
matches.push(ThreadBranchEntry::DefaultBranch);
}
matches
}
fn is_branch_occupied(&self, branch_name: &str) -> bool {
self.occupied_branches
.as_ref()
.is_some_and(|occupied| occupied.contains_key(branch_name))
}
fn branch_aside_text(&self, branch_name: &str, is_remote: bool) -> Option<SharedString> {
if self.is_branch_occupied(branch_name) {
Some(
format!(
"This branch is already checked out in another worktree. \
A new branch will be created from {branch_name}."
)
.into(),
)
} else if is_remote {
Some("A new local branch will be created from this remote branch.".into())
} else {
None
}
}
fn entry_aside_text(&self, entry: &ThreadBranchEntry) -> Option<SharedString> {
match entry {
ThreadBranchEntry::CurrentBranch => Some(SharedString::from(
"A new branch will be created from the current branch.",
)),
ThreadBranchEntry::DefaultBranch => {
let default_branch_name = self
.default_branch_name
.as_ref()
.filter(|name| *name != &self.current_branch_name)?;
self.branch_aside_text(default_branch_name, false)
}
ThreadBranchEntry::ExistingBranch { branch, .. } => {
self.branch_aside_text(branch.name(), branch.is_remote())
}
_ => None,
}
}
fn sync_selected_index(&mut self) {
let selected_entry_name = self.selected_entry_name().map(ToOwned::to_owned);
let prefer_create = self.prefer_create_entry();
if prefer_create {
if let Some(ref selected_entry_name) = selected_entry_name {
if let Some(index) = self.matches.iter().position(|entry| {
matches!(
entry,
ThreadBranchEntry::CreateNamed { name } if name == selected_entry_name
)
}) {
self.selected_index = index;
return;
}
}
} else if let Some(ref selected_entry_name) = selected_entry_name {
if selected_entry_name == &self.current_branch_name {
if let Some(index) = self
.matches
.iter()
.position(|entry| matches!(entry, ThreadBranchEntry::CurrentBranch))
{
self.selected_index = index;
return;
}
}
if self
.default_branch_name
.as_ref()
.is_some_and(|default_branch_name| default_branch_name == selected_entry_name)
{
if let Some(index) = self
.matches
.iter()
.position(|entry| matches!(entry, ThreadBranchEntry::DefaultBranch))
{
self.selected_index = index;
return;
}
}
if let Some(index) = self.matches.iter().position(|entry| {
matches!(
entry,
ThreadBranchEntry::ExistingBranch { branch, .. }
if branch.name() == selected_entry_name.as_str()
)
}) {
self.selected_index = index;
return;
}
}
if self.matches.len() > 1
&& self
.matches
.iter()
.skip(1)
.all(|entry| matches!(entry, ThreadBranchEntry::CreateNamed { .. }))
{
self.selected_index = 1;
return;
}
self.selected_index = 0;
}
}
impl PickerDelegate for ThreadBranchPickerDelegate {
type ListItem = AnyElement;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search branches…".into()
}
fn editor_position(&self) -> PickerEditorPosition {
PickerEditorPosition::Start
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
!matches!(self.matches.get(ix), Some(ThreadBranchEntry::Separator))
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
if self.has_multiple_repositories {
let mut matches = self.fixed_matches();
if query.is_empty() {
if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) {
if self.prefer_create_entry() {
matches.push(ThreadBranchEntry::Separator);
matches.push(ThreadBranchEntry::CreateNamed { name });
}
}
} else {
matches.push(ThreadBranchEntry::Separator);
matches.push(ThreadBranchEntry::CreateNamed {
name: query.replace(' ', "-"),
});
}
self.matches = matches;
self.sync_selected_index();
return Task::ready(());
}
let Some(all_branches) = self.all_branches.clone() else {
self.matches = self.fixed_matches();
self.selected_index = 0;
return Task::ready(());
};
if query.is_empty() {
let mut matches = self.fixed_matches();
let filtered_branches: Vec<_> = all_branches
.into_iter()
.filter(|branch| {
branch.name() != self.current_branch_name
&& self
.default_branch_name
.as_ref()
.is_none_or(|default_branch_name| branch.name() != default_branch_name)
})
.collect();
if !filtered_branches.is_empty() {
matches.push(ThreadBranchEntry::Separator);
}
for branch in filtered_branches {
matches.push(ThreadBranchEntry::ExistingBranch {
branch,
positions: Vec::new(),
});
}
if let Some(selected_entry_name) = self.selected_entry_name().map(ToOwned::to_owned) {
let has_existing = matches.iter().any(|entry| {
matches!(
entry,
ThreadBranchEntry::ExistingBranch { branch, .. }
if branch.name() == selected_entry_name
)
});
if self.prefer_create_entry() && !has_existing {
matches.push(ThreadBranchEntry::CreateNamed {
name: selected_entry_name,
});
}
}
self.matches = matches;
self.sync_selected_index();
return Task::ready(());
}
let candidates: Vec<_> = all_branches
.iter()
.enumerate()
.map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
.collect();
let executor = cx.background_executor().clone();
let query_clone = query.clone();
let normalized_query = query.replace(' ', "-");
let task = cx.background_executor().spawn(async move {
fuzzy::match_strings(
&candidates,
&query_clone,
true,
true,
10000,
&Default::default(),
executor,
)
.await
});
let all_branches_clone = all_branches;
cx.spawn_in(window, async move |picker, cx| {
let fuzzy_matches = task.await;
picker
.update_in(cx, |picker, _window, cx| {
let mut matches = picker.delegate.fixed_matches();
let mut has_dynamic_entries = false;
for candidate in &fuzzy_matches {
let branch = all_branches_clone[candidate.candidate_id].clone();
if branch.name() == picker.delegate.current_branch_name
|| picker.delegate.default_branch_name.as_ref().is_some_and(
|default_branch_name| branch.name() == default_branch_name,
)
{
continue;
}
if !has_dynamic_entries {
matches.push(ThreadBranchEntry::Separator);
has_dynamic_entries = true;
}
matches.push(ThreadBranchEntry::ExistingBranch {
branch,
positions: candidate.positions.clone(),
});
}
if fuzzy_matches.is_empty() {
if !has_dynamic_entries {
matches.push(ThreadBranchEntry::Separator);
}
matches.push(ThreadBranchEntry::CreateNamed {
name: normalized_query.clone(),
});
}
picker.delegate.matches = matches;
if let Some(index) =
picker.delegate.matches.iter().position(|entry| {
matches!(entry, ThreadBranchEntry::ExistingBranch { .. })
})
{
picker.delegate.selected_index = index;
} else if !fuzzy_matches.is_empty() {
picker.delegate.selected_index = 0;
} else if let Some(index) =
picker.delegate.matches.iter().position(|entry| {
matches!(entry, ThreadBranchEntry::CreateNamed { .. })
})
{
picker.delegate.selected_index = index;
} else {
picker.delegate.sync_selected_index();
}
cx.notify();
})
.log_err();
})
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(self.selected_index) else {
return;
};
match entry {
ThreadBranchEntry::Separator => return,
ThreadBranchEntry::CurrentBranch => {
window.dispatch_action(
Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
cx,
);
}
ThreadBranchEntry::DefaultBranch => {
let Some(default_branch_name) = self.default_branch_name.clone() else {
return;
};
window.dispatch_action(
Box::new(
self.new_worktree_action(NewWorktreeBranchTarget::ExistingBranch {
name: default_branch_name,
}),
),
cx,
);
}
ThreadBranchEntry::ExistingBranch { branch, .. } => {
let branch_target = if branch.is_remote() {
let branch_name = branch
.ref_name
.as_ref()
.strip_prefix("refs/remotes/")
.and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
.unwrap_or(branch.name())
.to_string();
NewWorktreeBranchTarget::CreateBranch {
name: branch_name,
from_ref: Some(branch.name().to_string()),
}
} else {
NewWorktreeBranchTarget::ExistingBranch {
name: branch.name().to_string(),
}
};
window.dispatch_action(Box::new(self.new_worktree_action(branch_target)), cx);
}
ThreadBranchEntry::CreateNamed { name } => {
window.dispatch_action(
Box::new(
self.new_worktree_action(NewWorktreeBranchTarget::CreateBranch {
name: name.clone(),
from_ref: None,
}),
),
cx,
);
}
}
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let entry = self.matches.get(ix)?;
match entry {
ThreadBranchEntry::Separator => Some(
div()
.py(DynamicSpacing::Base04.rems(cx))
.child(Divider::horizontal())
.into_any_element(),
),
ThreadBranchEntry::CurrentBranch => {
let branch_name = if self.has_multiple_repositories {
SharedString::from("current branches")
} else {
SharedString::from(self.current_branch_name.clone())
};
Some(
ListItem::new("current-branch")
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(branch_name))
.into_any_element(),
)
}
ThreadBranchEntry::DefaultBranch => {
let default_branch_name = self
.default_branch_name
.as_ref()
.filter(|name| *name != &self.current_branch_name)?;
let is_occupied = self.is_branch_occupied(default_branch_name);
let item = ListItem::new("default-branch")
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(default_branch_name.clone()));
Some(
if is_occupied {
item.start_slot(Icon::new(IconName::GitBranchPlus).color(Color::Muted))
} else {
item
}
.into_any_element(),
)
}
ThreadBranchEntry::ExistingBranch {
branch, positions, ..
} => {
let branch_name = branch.name().to_string();
let needs_new_branch = self.is_branch_occupied(&branch_name) || branch.is_remote();
Some(
ListItem::new(SharedString::from(format!("branch-{ix}")))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.min_w_0()
.gap_1()
.child(
HighlightedLabel::new(branch_name, positions.clone())
.truncate(),
)
.when(needs_new_branch, |item| {
item.child(
Icon::new(IconName::GitBranchPlus)
.size(IconSize::Small)
.color(Color::Muted),
)
}),
)
.into_any_element(),
)
}
ThreadBranchEntry::CreateNamed { name } => Some(
ListItem::new("create-named-branch")
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(format!("Create Branch: \"{name}\"")))
.into_any_element(),
),
}
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
None
}
fn documentation_aside(
&self,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<DocumentationAside> {
let entry = self.matches.get(self.selected_index)?;
let aside_text = self.entry_aside_text(entry)?;
let side = crate::ui::documentation_aside_side(cx);
Some(DocumentationAside::new(
side,
Rc::new(move |_| Label::new(aside_text.clone()).into_any_element()),
))
}
fn documentation_aside_index(&self) -> Option<usize> {
let entry = self.matches.get(self.selected_index)?;
self.entry_aside_text(entry).map(|_| self.selected_index)
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ pub(crate) fn thread_title(entry: &AgentSessionInfo) -> SharedString {
entry
.title
.clone()
.filter(|title| !title.is_empty())
.and_then(|title| if title.is_empty() { None } else { Some(title) })
.unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
}
@@ -74,7 +74,7 @@ impl ThreadHistoryView {
) -> Self {
let search_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Search threads...", window, cx);
editor.set_placeholder_text("Search all threads", window, cx);
editor
});
+391 -21
View File
@@ -4,14 +4,16 @@ use agent_client_protocol as acp;
use chrono::Utc;
use collections::HashSet;
use db::kvp::Dismissable;
use db::sqlez;
use fs::Fs;
use futures::FutureExt as _;
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
Render, SharedString, Task, WeakEntity, Window,
};
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use project::{AgentId, AgentRegistryStore, AgentServerStore};
use release_channel::ReleaseChannel;
use remote::RemoteConnectionOptions;
use ui::{
Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
@@ -23,10 +25,11 @@ use workspace::{ModalView, MultiWorkspace, Workspace};
use crate::{
Agent, AgentPanel,
agent_connection_store::AgentConnectionStore,
thread_metadata_store::{ThreadMetadata, ThreadMetadataStore, ThreadWorktreePaths},
thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},
};
pub struct AcpThreadImportOnboarding;
pub struct CrossChannelImportOnboarding;
impl AcpThreadImportOnboarding {
pub fn dismissed(cx: &App) -> bool {
@@ -42,6 +45,40 @@ impl Dismissable for AcpThreadImportOnboarding {
const KEY: &'static str = "dismissed-acp-thread-import";
}
impl CrossChannelImportOnboarding {
pub fn dismissed(cx: &App) -> bool {
<Self as Dismissable>::dismissed(cx)
}
pub fn dismiss(cx: &mut App) {
<Self as Dismissable>::set_dismissed(true, cx);
}
}
impl Dismissable for CrossChannelImportOnboarding {
const KEY: &'static str = "dismissed-cross-channel-thread-import";
}
/// Returns the list of non-Dev, non-current release channels that have
/// at least one thread in their database. The result is suitable for
/// building a user-facing message ("from Zed Preview and Nightly").
pub fn channels_with_threads(cx: &App) -> Vec<ReleaseChannel> {
let Some(current_channel) = ReleaseChannel::try_global(cx) else {
return Vec::new();
};
let database_dir = paths::database_dir();
ReleaseChannel::ALL
.iter()
.copied()
.filter(|channel| {
*channel != current_channel
&& *channel != ReleaseChannel::Dev
&& channel_has_threads(database_dir, *channel)
})
.collect()
}
#[derive(Clone)]
struct AgentEntry {
agent_id: AgentId,
@@ -206,10 +243,11 @@ impl ThreadImportModal {
.filter(|agent_id| !self.unchecked_agents.contains(agent_id))
.collect::<Vec<_>>();
let existing_sessions = ThreadMetadataStore::global(cx)
let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)
.read(cx)
.entry_ids()
.collect::<HashSet<_>>();
.entries()
.filter_map(|m| m.session_id.clone())
.collect();
let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx);
cx.spawn(async move |this, cx| {
@@ -237,8 +275,12 @@ impl ThreadImportModal {
fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
let status_toast = if imported_count == 0 {
StatusToast::new("No threads found to import.", cx, |this, _cx| {
this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
.dismiss_button(true)
this.icon(
Icon::new(IconName::Info)
.size(IconSize::Small)
.color(Color::Muted),
)
.dismiss_button(true)
})
} else {
let message = if imported_count == 1 {
@@ -247,8 +289,12 @@ impl ThreadImportModal {
format!("Imported {imported_count} threads.")
};
StatusToast::new(message, cx, |this, _cx| {
this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
.dismiss_button(true)
this.icon(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
.dismiss_button(true)
})
};
@@ -345,7 +391,7 @@ impl Render for ThreadImportModal {
.headline("Import External Agent Threads")
.description(
"Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
Choose which agents to include, and their threads will appear in your archive."
Choose which agents to include, and their threads will appear in your thread history."
)
.show_dismiss_button(true),
@@ -520,14 +566,14 @@ fn collect_importable_threads(
continue;
};
to_insert.push(ThreadMetadata {
session_id: session.session_id,
thread_id: ThreadId::new(),
session_id: Some(session.session_id),
agent_id: agent_id.clone(),
title: session
.title
.unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into()),
title: session.title,
updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
created_at: session.created_at,
worktree_paths: ThreadWorktreePaths::from_folder_paths(&folder_paths),
interacted_at: None,
worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
remote_connection: remote_connection.clone(),
archived: true,
});
@@ -536,11 +582,121 @@ fn collect_importable_threads(
to_insert
}
pub fn import_threads_from_other_channels(_workspace: &mut Workspace, cx: &mut Context<Workspace>) {
let database_dir = paths::database_dir().clone();
import_threads_from_other_channels_in(database_dir, cx);
}
fn import_threads_from_other_channels_in(
database_dir: std::path::PathBuf,
cx: &mut Context<Workspace>,
) {
let current_channel = ReleaseChannel::global(cx);
let existing_thread_ids: HashSet<ThreadId> = ThreadMetadataStore::global(cx)
.read(cx)
.entries()
.map(|metadata| metadata.thread_id)
.collect();
let workspace_handle = cx.weak_entity();
cx.spawn(async move |_this, cx| {
let mut imported_threads = Vec::new();
for channel in &ReleaseChannel::ALL {
if *channel == current_channel || *channel == ReleaseChannel::Dev {
continue;
}
match read_threads_from_channel(&database_dir, *channel) {
Ok(threads) => {
let new_threads = threads
.into_iter()
.filter(|thread| !existing_thread_ids.contains(&thread.thread_id));
imported_threads.extend(new_threads);
}
Err(error) => {
log::warn!(
"Failed to read threads from {} channel database: {}",
channel.dev_name(),
error
);
}
}
}
let imported_count = imported_threads.len();
cx.update(|cx| {
ThreadMetadataStore::global(cx)
.update(cx, |store, cx| store.save_all(imported_threads, cx));
show_cross_channel_import_toast(&workspace_handle, imported_count, cx);
})
})
.detach();
}
fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool {
let db_path = db::db_path(database_dir, channel);
if !db_path.exists() {
return false;
}
let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
connection
.select_row::<bool>("SELECT 1 FROM sidebar_threads LIMIT 1")
.ok()
.and_then(|mut query| query().ok().flatten())
.unwrap_or(false)
}
fn read_threads_from_channel(
database_dir: &std::path::Path,
channel: ReleaseChannel,
) -> anyhow::Result<Vec<ThreadMetadata>> {
let db_path = db::db_path(database_dir, channel);
if !db_path.exists() {
return Ok(Vec::new());
}
let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
crate::thread_metadata_store::list_thread_metadata_from_connection(&connection)
}
fn show_cross_channel_import_toast(
workspace: &WeakEntity<Workspace>,
imported_count: usize,
cx: &mut App,
) {
let status_toast = if imported_count == 0 {
StatusToast::new("No new threads found to import.", cx, |this, _cx| {
this.icon(Icon::new(IconName::Info).color(Color::Muted))
.dismiss_button(true)
})
} else {
let message = if imported_count == 1 {
"Imported 1 thread from other channels.".to_string()
} else {
format!("Imported {imported_count} threads from other channels.")
};
StatusToast::new(message, cx, |this, _cx| {
this.icon(Icon::new(IconName::Check).color(Color::Success))
.dismiss_button(true)
})
};
workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(status_toast, cx);
})
.log_err();
}
#[cfg(test)]
mod tests {
use super::*;
use acp_thread::AgentSessionInfo;
use chrono::Utc;
use gpui::TestAppContext;
use std::path::Path;
use workspace::PathList;
@@ -584,8 +740,8 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "new-1");
assert_eq!(result[0].title.as_ref(), "Brand New");
assert_eq!(result[0].session_id.as_ref().unwrap().0.as_ref(), "new-1");
assert_eq!(result[0].display_title(), "Brand New");
}
#[test]
@@ -605,7 +761,10 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "has-dirs");
assert_eq!(
result[0].session_id.as_ref().unwrap().0.as_ref(),
"has-dirs"
);
}
#[test]
@@ -657,11 +816,11 @@ mod tests {
assert_eq!(result.len(), 2);
let s1 = result
.iter()
.find(|t| t.session_id.0.as_ref() == "s1")
.find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s1"))
.unwrap();
let s2 = result
.iter()
.find(|t| t.session_id.0.as_ref() == "s2")
.find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s2"))
.unwrap();
assert_eq!(s1.agent_id.as_ref(), "agent-a");
assert_eq!(s2.agent_id.as_ref(), "agent-b");
@@ -700,7 +859,10 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert_eq!(result.len(), 1);
assert_eq!(result[0].session_id.0.as_ref(), "shared-session");
assert_eq!(
result[0].session_id.as_ref().unwrap().0.as_ref(),
"shared-session"
);
assert_eq!(
result[0].agent_id.as_ref(),
"agent-a",
@@ -726,4 +888,212 @@ mod tests {
let result = collect_importable_threads(sessions_by_agent, existing);
assert!(result.is_empty());
}
fn create_channel_db(
db_dir: &std::path::Path,
channel: ReleaseChannel,
) -> db::sqlez::connection::Connection {
let db_path = db::db_path(db_dir, channel);
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
let connection = db::sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
crate::thread_metadata_store::run_thread_metadata_migrations(&connection);
connection
}
fn insert_thread(
connection: &db::sqlez::connection::Connection,
title: &str,
updated_at: &str,
archived: bool,
) {
let thread_id = uuid::Uuid::new_v4();
let session_id = uuid::Uuid::new_v4().to_string();
connection
.exec_bound::<(uuid::Uuid, &str, &str, &str, bool)>(
"INSERT INTO sidebar_threads \
(thread_id, session_id, title, updated_at, archived) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.unwrap()((thread_id, session_id.as_str(), title, updated_at, archived))
.unwrap();
}
#[test]
fn test_returns_empty_when_channel_db_missing() {
let dir = tempfile::tempdir().unwrap();
let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
assert!(threads.is_empty());
}
#[test]
fn test_preserves_archived_state() {
let dir = tempfile::tempdir().unwrap();
let connection = create_channel_db(dir.path(), ReleaseChannel::Nightly);
insert_thread(&connection, "Active Thread", "2025-01-15T10:00:00Z", false);
insert_thread(&connection, "Archived Thread", "2025-01-15T09:00:00Z", true);
drop(connection);
let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
assert_eq!(threads.len(), 2);
let active = threads
.iter()
.find(|t| t.display_title().as_ref() == "Active Thread")
.unwrap();
assert!(!active.archived);
let archived = threads
.iter()
.find(|t| t.display_title().as_ref() == "Archived Thread")
.unwrap();
assert!(archived.archived);
}
fn init_test(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
release_channel::init("0.0.0".parse().unwrap(), cx);
<dyn fs::Fs>::set_global(fs, cx);
ThreadMetadataStore::init_global(cx);
});
cx.run_until_parked();
}
/// Returns two release channels that are not the current one and not Dev.
/// This ensures tests work regardless of which release channel branch
/// they run on.
fn foreign_channels(cx: &TestAppContext) -> (ReleaseChannel, ReleaseChannel) {
let current = cx.update(|cx| ReleaseChannel::global(cx));
let mut channels = ReleaseChannel::ALL
.iter()
.copied()
.filter(|ch| *ch != current && *ch != ReleaseChannel::Dev);
(channels.next().unwrap(), channels.next().unwrap())
}
#[gpui::test]
async fn test_import_threads_from_other_channels(cx: &mut TestAppContext) {
init_test(cx);
let dir = tempfile::tempdir().unwrap();
let database_dir = dir.path().to_path_buf();
let (channel_a, channel_b) = foreign_channels(cx);
// Set up databases for two foreign channels.
let db_a = create_channel_db(dir.path(), channel_a);
insert_thread(&db_a, "Thread A1", "2025-01-15T10:00:00Z", false);
insert_thread(&db_a, "Thread A2", "2025-01-15T11:00:00Z", true);
drop(db_a);
let db_b = create_channel_db(dir.path(), channel_b);
insert_thread(&db_b, "Thread B1", "2025-01-15T12:00:00Z", false);
drop(db_b);
// Create a workspace and run the import.
let fs = fs::FakeFs::new(cx.executor());
let project = project::Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace_entity = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
import_threads_from_other_channels_in(database_dir, cx);
});
cx.run_until_parked();
// Verify all three threads were imported into the store.
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
let store = store.read(cx);
let titles: collections::HashSet<String> = store
.entries()
.map(|m| m.display_title().to_string())
.collect();
assert_eq!(titles.len(), 3);
assert!(titles.contains("Thread A1"));
assert!(titles.contains("Thread A2"));
assert!(titles.contains("Thread B1"));
// Verify archived state is preserved.
let thread_a2 = store
.entries()
.find(|m| m.display_title().as_ref() == "Thread A2")
.unwrap();
assert!(thread_a2.archived);
let thread_b1 = store
.entries()
.find(|m| m.display_title().as_ref() == "Thread B1")
.unwrap();
assert!(!thread_b1.archived);
});
}
#[gpui::test]
async fn test_import_skips_already_existing_threads(cx: &mut TestAppContext) {
init_test(cx);
let dir = tempfile::tempdir().unwrap();
let database_dir = dir.path().to_path_buf();
let (channel_a, _) = foreign_channels(cx);
// Set up a database for a foreign channel.
let db_a = create_channel_db(dir.path(), channel_a);
insert_thread(&db_a, "Thread A", "2025-01-15T10:00:00Z", false);
insert_thread(&db_a, "Thread B", "2025-01-15T11:00:00Z", false);
drop(db_a);
// Read the threads so we can pre-populate one into the store.
let foreign_threads = read_threads_from_channel(dir.path(), channel_a).unwrap();
let thread_a = foreign_threads
.iter()
.find(|t| t.display_title().as_ref() == "Thread A")
.unwrap()
.clone();
// Pre-populate Thread A into the store.
cx.update(|cx| {
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(thread_a, cx));
});
cx.run_until_parked();
// Run the import.
let fs = fs::FakeFs::new(cx.executor());
let project = project::Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace_entity = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
import_threads_from_other_channels_in(database_dir, cx);
});
cx.run_until_parked();
// Verify only Thread B was added (Thread A already existed).
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
let store = store.read(cx);
assert_eq!(store.entries().count(), 2);
let titles: collections::HashSet<String> = store
.entries()
.map(|m| m.display_title().to_string())
.collect();
assert!(titles.contains("Thread A"));
assert!(titles.contains("Thread B"));
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+279 -40
View File
@@ -1,15 +1,19 @@
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use crate::agent_connection_store::AgentConnectionStore;
use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
use crate::{Agent, RemoveSelectedThread};
use crate::thread_metadata_store::{
ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths,
};
use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
use agent::ThreadStore;
use agent_client_protocol as acp;
use agent_settings::AgentSettings;
use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use collections::HashMap;
use editor::Editor;
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
@@ -26,10 +30,9 @@ use picker::{
use project::{AgentId, AgentServerStore};
use settings::Settings as _;
use theme::ActiveTheme;
use ui::{AgentThreadStatus, ThreadItem};
use ui::{
Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar,
prelude::*, utils::platform_title_bar_height,
AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tab,
ThreadItem, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
};
use ui_input::ErasedEditor;
use util::ResultExt;
@@ -42,6 +45,13 @@ use workspace::{
use zed_actions::agents_sidebar::FocusSidebarFilter;
use zed_actions::editor::{MoveDown, MoveUp};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum ThreadFilter {
#[default]
All,
ArchivedOnly,
}
#[derive(Clone)]
enum ArchiveListItem {
BucketSeparator(TimeBucket),
@@ -112,8 +122,9 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
pub enum ThreadsArchiveViewEvent {
Close,
Unarchive { thread: ThreadMetadata },
CancelRestore { session_id: acp::SessionId },
Activate { thread: ThreadMetadata },
CancelRestore { thread_id: ThreadId },
Import,
}
impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
@@ -132,7 +143,11 @@ pub struct ThreadsArchiveView {
workspace: WeakEntity<Workspace>,
agent_connection_store: WeakEntity<AgentConnectionStore>,
agent_server_store: WeakEntity<AgentServerStore>,
restoring: HashSet<acp::SessionId>,
restoring: HashSet<ThreadId>,
archived_thread_ids: HashSet<ThreadId>,
archived_branch_names: HashMap<ThreadId, HashMap<PathBuf, String>>,
_load_branch_names_task: Task<()>,
thread_filter: ThreadFilter,
}
impl ThreadsArchiveView {
@@ -147,7 +162,7 @@ impl ThreadsArchiveView {
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Search archive", window, cx);
editor.set_placeholder_text("Search all threads", window, cx);
editor
});
@@ -175,6 +190,7 @@ impl ThreadsArchiveView {
&ThreadMetadataStore::global(cx),
|this: &mut Self, _, cx| {
this.update_items(cx);
this.reload_branch_names_if_threads_changed(cx);
},
);
@@ -202,9 +218,14 @@ impl ThreadsArchiveView {
agent_connection_store,
agent_server_store,
restoring: HashSet::default(),
archived_thread_ids: HashSet::default(),
archived_branch_names: HashMap::default(),
_load_branch_names_task: Task::ready(()),
thread_filter: ThreadFilter::All,
};
this.update_items(cx);
this.reload_branch_names_if_threads_changed(cx);
this
}
@@ -216,13 +237,13 @@ impl ThreadsArchiveView {
self.selection = None;
}
pub fn mark_restoring(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
self.restoring.insert(session_id.clone());
pub fn mark_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
self.restoring.insert(*thread_id);
cx.notify();
}
pub fn clear_restoring(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
self.restoring.remove(session_id);
pub fn clear_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
self.restoring.remove(thread_id);
cx.notify();
}
@@ -239,9 +260,14 @@ impl ThreadsArchiveView {
}
fn update_items(&mut self, cx: &mut Context<Self>) {
let thread_filter = self.thread_filter;
let sessions = ThreadMetadataStore::global(cx)
.read(cx)
.archived_entries()
.entries()
.filter(|t| match thread_filter {
ThreadFilter::All => true,
ThreadFilter::ArchivedOnly => t.archived,
})
.sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
.rev()
.cloned()
@@ -255,7 +281,14 @@ impl ThreadsArchiveView {
for session in sessions {
let highlight_positions = if !query.is_empty() {
match fuzzy_match_positions(&query, &session.title) {
match fuzzy_match_positions(
&query,
session
.title
.as_ref()
.map(|t| t.as_ref())
.unwrap_or(DEFAULT_THREAD_TITLE),
) {
Some(positions) => positions,
None => continue,
}
@@ -324,19 +357,73 @@ impl ThreadsArchiveView {
cx.notify();
}
fn reload_branch_names_if_threads_changed(&mut self, cx: &mut Context<Self>) {
let current_ids: HashSet<ThreadId> = self
.items
.iter()
.filter_map(|item| match item {
ArchiveListItem::Entry { thread, .. } => Some(thread.thread_id),
_ => None,
})
.collect();
if current_ids != self.archived_thread_ids {
self.archived_thread_ids = current_ids;
self.load_archived_branch_names(cx);
}
}
fn load_archived_branch_names(&mut self, cx: &mut Context<Self>) {
let task = ThreadMetadataStore::global(cx)
.read(cx)
.get_all_archived_branch_names(cx);
self._load_branch_names_task = cx.spawn(async move |this, cx| {
if let Some(branch_names) = task.await.log_err() {
this.update(cx, |this, cx| {
this.archived_branch_names = branch_names;
cx.notify();
})
.log_err();
}
});
}
fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.filter_editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}
fn archive_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
self.preserve_selection_on_next_update = true;
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(thread_id, None, cx));
}
fn archive_selected_thread(
&mut self,
_: &ArchiveSelectedThread,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.selection else { return };
let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
return;
};
if thread.archived {
return;
}
self.archive_thread(thread.thread_id, cx);
}
fn unarchive_thread(
&mut self,
thread: ThreadMetadata,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.restoring.contains(&thread.session_id) {
if self.restoring.contains(&thread.thread_id) {
return;
}
@@ -345,10 +432,10 @@ impl ThreadsArchiveView {
return;
}
self.mark_restoring(&thread.session_id, cx);
self.mark_restoring(&thread.thread_id, cx);
self.selection = None;
self.reset_filter_editor_text(window, cx);
cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
cx.emit(ThreadsArchiveViewEvent::Activate { thread });
}
fn show_project_picker_for_thread(
@@ -528,16 +615,41 @@ impl ThreadsArchiveView {
IconName::Sparkle
};
let is_restoring = self.restoring.contains(&thread.session_id);
let is_restoring = self.restoring.contains(&thread.thread_id);
let base = ThreadItem::new(id, thread.title.clone())
let is_archived = thread.archived;
let branch_names_for_thread: HashMap<PathBuf, SharedString> = self
.archived_branch_names
.get(&thread.thread_id)
.map(|map| {
map.iter()
.map(|(k, v)| (k.clone(), SharedString::from(v.clone())))
.collect()
})
.unwrap_or_default();
let worktrees = worktree_info_from_thread_paths(
&thread.worktree_paths,
&branch_names_for_thread,
);
let archived_color = Color::Custom(cx.theme().colors().icon_muted.opacity(0.6));
let base = ThreadItem::new(id, thread.display_title())
.icon(icon)
.when(is_archived, |this| {
this.archived(true)
.icon_color(archived_color)
.title_label_color(Color::Muted)
})
.when_some(icon_from_external_svg, |this, svg| {
this.custom_icon_from_external_svg(svg)
})
.timestamp(timestamp)
.highlight_positions(highlight_positions.clone())
.project_paths(thread.folder_paths().paths_owned())
.worktrees(worktrees)
.focused(is_focused)
.hovered(is_hovered)
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
@@ -557,19 +669,18 @@ impl ThreadsArchiveView {
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Cancel Restore"))
.on_click({
let session_id = thread.session_id.clone();
let thread_id = thread.thread_id;
cx.listener(move |this, _, _, cx| {
this.clear_restoring(&session_id, cx);
this.clear_restoring(&thread_id, cx);
cx.emit(ThreadsArchiveViewEvent::CancelRestore {
session_id: session_id.clone(),
thread_id,
});
cx.stop_propagation();
})
}),
)
.tooltip(Tooltip::text("Restoring…"))
.into_any_element()
} else {
} else if is_archived {
base.action_slot(
IconButton::new("delete-thread", IconName::Trash)
.icon_size(IconSize::Small)
@@ -586,15 +697,20 @@ impl ThreadsArchiveView {
})
.on_click({
let agent = thread.agent_id.clone();
let thread_id = thread.thread_id;
let session_id = thread.session_id.clone();
cx.listener(move |this, _, _, cx| {
this.preserve_selection_on_next_update = true;
this.delete_thread(session_id.clone(), agent.clone(), cx);
this.delete_thread(
thread_id,
session_id.clone(),
agent.clone(),
cx,
);
cx.stop_propagation();
})
}),
)
.tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
.on_click({
let thread = thread.clone();
cx.listener(move |this, _, window, cx| {
@@ -602,6 +718,45 @@ impl ThreadsArchiveView {
})
})
.into_any_element()
} else {
base.action_slot(
IconButton::new("archive-thread", IconName::Archive)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip({
move |_window, cx| {
Tooltip::for_action_in(
"Archive Thread",
&ArchiveSelectedThread,
&focus_handle,
cx,
)
}
})
.on_click({
let thread_id = thread.thread_id;
cx.listener(move |this, _, _, cx| {
this.archive_thread(thread_id, cx);
cx.stop_propagation();
})
}),
)
.on_click({
let thread = thread.clone();
cx.listener(move |this, _, window, cx| {
let side = match AgentSettings::get_global(cx).sidebar_side() {
settings::SidebarSide::Left => "left",
settings::SidebarSide::Right => "right",
};
telemetry::event!(
"Archived Thread Opened",
agent = thread.agent_id.as_ref(),
side = side
);
this.unarchive_thread(thread.clone(), window, cx);
})
})
.into_any_element()
}
}
}
@@ -619,17 +774,22 @@ impl ThreadsArchiveView {
};
self.preserve_selection_on_next_update = true;
self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
self.delete_thread(
thread.thread_id,
thread.session_id.clone(),
thread.agent_id.clone(),
cx,
);
}
fn delete_thread(
&mut self,
session_id: acp::SessionId,
thread_id: ThreadId,
session_id: Option<acp::SessionId>,
agent: AgentId,
cx: &mut Context<Self>,
) {
ThreadMetadataStore::global(cx)
.update(cx, |store, cx| store.delete(session_id.clone(), cx));
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.delete(thread_id, cx));
let agent = Agent::from(agent);
@@ -645,13 +805,16 @@ impl ThreadsArchiveView {
.wait_for_connection()
});
cx.spawn(async move |_this, cx| {
crate::thread_worktree_archive::cleanup_thread_archived_worktrees(&session_id, cx)
.await;
crate::thread_worktree_archive::cleanup_thread_archived_worktrees(thread_id, cx).await;
let state = task.await?;
let task = cx.update(|cx| {
if let Some(list) = state.connection.session_list(cx) {
list.delete_session(&session_id, cx)
if let Some(session_id) = &session_id {
if let Some(list) = state.connection.session_list(cx) {
list.delete_session(session_id, cx)
} else {
Task::ready(Ok(()))
}
} else {
Task::ready(Ok(()))
}
@@ -720,6 +883,72 @@ impl ThreadsArchiveView {
)
})
}
fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let entry_count = self
.items
.iter()
.filter(|item| matches!(item, ArchiveListItem::Entry { .. }))
.count();
let has_archived_threads = {
let store = ThreadMetadataStore::global(cx).read(cx);
store.archived_entries().next().is_some()
};
let count_label = if entry_count == 1 {
"1 thread".to_string()
} else {
format!("{} threads", entry_count)
};
h_flex()
.mt_px()
.pl_2p5()
.pr_1p5()
.h(Tab::content_height(cx))
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
Label::new(count_label)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
.child(
IconButton::new("thread-import", IconName::Download)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Import Threads"))
.on_click(cx.listener(|_this, _, _, cx| {
cx.emit(ThreadsArchiveViewEvent::Import);
})),
)
.child(
IconButton::new("filter-archived-only", IconName::Archive)
.icon_size(IconSize::Small)
.disabled(!has_archived_threads)
.toggle_state(self.thread_filter == ThreadFilter::ArchivedOnly)
.tooltip(Tooltip::text(
if self.thread_filter == ThreadFilter::ArchivedOnly {
"Show All Threads"
} else {
"Show Only Archived Threads"
},
))
.on_click(cx.listener(|this, _, _, cx| {
this.thread_filter =
if this.thread_filter == ThreadFilter::ArchivedOnly {
ThreadFilter::All
} else {
ThreadFilter::ArchivedOnly
};
this.update_items(cx);
})),
),
)
}
}
pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
@@ -760,7 +989,7 @@ impl Render for ThreadsArchiveView {
let message = if has_query {
"No threads match your search."
} else {
"No archived or hidden threads yet."
"No threads yet."
};
v_flex()
@@ -800,8 +1029,10 @@ impl Render for ThreadsArchiveView {
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::remove_selected_thread))
.on_action(cx.listener(Self::archive_selected_thread))
.size_full()
.child(self.render_header(window, cx))
.when(!has_query, |this| this.child(self.render_toolbar(cx)))
.child(content)
}
}
@@ -929,16 +1160,16 @@ impl ProjectPickerDelegate {
cx: &mut Context<Picker<Self>>,
) {
self.thread.worktree_paths =
super::thread_metadata_store::ThreadWorktreePaths::from_folder_paths(&paths);
super::thread_metadata_store::WorktreePaths::from_folder_paths(&paths);
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.update_working_directories(&self.thread.session_id, paths, cx);
store.update_working_directories(self.thread.thread_id, paths, cx);
});
self.archive_view
.update(cx, |view, cx| {
view.selection = None;
view.reset_filter_editor_text(window, cx);
cx.emit(ThreadsArchiveViewEvent::Unarchive {
cx.emit(ThreadsArchiveViewEvent::Activate {
thread: self.thread.clone(),
});
})
@@ -995,7 +1226,15 @@ impl PickerDelegate for ProjectPickerDelegate {
type ListItem = AnyElement;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
format!("Associate the \"{}\" thread with...", self.thread.title).into()
format!(
"Associate the \"{}\" thread with...",
self.thread
.title
.as_ref()
.map(|t| t.as_ref())
.unwrap_or(DEFAULT_THREAD_TITLE)
)
.into()
}
fn render_editor(
-2
View File
@@ -1,4 +1,3 @@
mod acp_onboarding_modal;
mod agent_notification;
mod end_trial_upsell;
mod hold_for_default;
@@ -6,7 +5,6 @@ mod mention_crease;
mod model_selector_components;
mod undo_reject_toast;
pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use end_trial_upsell::*;
pub use hold_for_default::*;
@@ -1,253 +0,0 @@
use agent_servers::GEMINI_ID;
use gpui::{
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
linear_color_stop, linear_gradient,
};
use ui::{TintColor, Vector, VectorName, prelude::*};
use workspace::{ModalView, Workspace};
use crate::{Agent, agent_panel::AgentPanel};
macro_rules! acp_onboarding_event {
($name:expr) => {
telemetry::event!($name, source = "ACP Onboarding");
};
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+);
};
}
pub struct AcpOnboardingModal {
focus_handle: FocusHandle,
workspace: Entity<Workspace>,
}
impl AcpOnboardingModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
let workspace_entity = cx.entity();
workspace.toggle_modal(window, cx, |_window, cx| Self {
workspace: workspace_entity,
focus_handle: cx.focus_handle(),
});
}
fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.new_agent_thread(
Agent::Custom {
id: GEMINI_ID.into(),
},
window,
cx,
);
});
}
});
cx.emit(DismissEvent);
acp_onboarding_event!("Open Panel Clicked");
}
fn open_agent_registry(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx);
cx.notify();
acp_onboarding_event!("Open Agent Registry Clicked");
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for AcpOnboardingModal {}
impl Focusable for AcpOnboardingModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for AcpOnboardingModal {}
impl Render for AcpOnboardingModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let illustration_element = |label: bool, opacity: f32| {
h_flex()
.px_1()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.border_dashed()
.child(
Icon::new(IconName::Stop)
.size(IconSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
)
.map(|this| {
if label {
this.child(
Label::new("Your Agent Here")
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(
div().w_16().h_1().rounded_full().bg(cx
.theme()
.colors()
.element_active
.opacity(0.6)),
)
}
})
.opacity(opacity)
};
let illustration = h_flex()
.relative()
.h(rems_from_px(126.))
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.justify_center()
.gap_8()
.rounded_t_md()
.overflow_hidden()
.child(
div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
),
)
.child(div().absolute().inset_0().size_full().bg(linear_gradient(
0.,
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.1),
0.9,
),
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.),
0.,
),
)))
.child(
div()
.absolute()
.inset_0()
.size_full()
.bg(gpui::black().opacity(0.15)),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()
.gap_1p5()
.child(illustration_element(false, 0.15))
.child(illustration_element(true, 0.3))
.child(
h_flex()
.pl_1()
.pr_2()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.2))
.border_1()
.border_color(cx.theme().colors().border)
.child(
Icon::new(IconName::AiGemini)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)),
)
.child(illustration_element(true, 0.3))
.child(illustration_element(false, 0.15)),
);
let heading = v_flex()
.w_full()
.gap_1()
.child(
Label::new("Now Available")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large));
let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration.";
let open_panel_button = Button::new("open-panel", "Start with Gemini CLI")
.style(ButtonStyle::Tinted(TintColor::Accent))
.full_width()
.on_click(cx.listener(Self::open_panel));
let docs_button = Button::new("add-other-agents", "Add Other Agents")
.end_icon(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Indicator)
.color(Color::Muted),
)
.full_width()
.on_click(cx.listener(Self::open_agent_registry));
let close_button = h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
acp_onboarding_event!("Canceled", trigger = "X click");
cx.emit(DismissEvent);
},
)),
);
v_flex()
.id("acp-onboarding")
.key_context("AcpOnboardingModal")
.relative()
.w(rems(34.))
.h_full()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
acp_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
this.focus_handle.focus(window, cx);
}))
.child(illustration)
.child(
v_flex()
.p_4()
.gap_2()
.child(heading)
.child(Label::new(copy).color(Color::Muted))
.child(
v_flex()
.w_full()
.mt_2()
.gap_1()
.child(open_panel_button)
.child(docs_button),
),
)
.child(close_button)
}
}
@@ -15,6 +15,7 @@ impl HoldForDefault {
}
}
#[allow(dead_code)]
pub fn more_content(mut self, more_content: bool) -> Self {
self.more_content = more_content;
self
+1
View File
@@ -284,6 +284,7 @@ fn open_thread(
None,
Some(name.into()),
true,
"agent_panel",
window,
cx,
)
+14 -10
View File
@@ -1,6 +1,6 @@
use action_log::ActionLog;
use gpui::{App, Entity};
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use ui::prelude::*;
use workspace::Workspace;
@@ -11,15 +11,19 @@ pub fn show_undo_reject_toast(
) {
let action_log_weak = action_log.downgrade();
let status_toast = StatusToast::new("Agent Changes Rejected", cx, move |this, _cx| {
this.icon(ToastIcon::new(IconName::Undo).color(Color::Muted))
.action("Undo", move |_window, cx| {
if let Some(action_log) = action_log_weak.upgrade() {
action_log
.update(cx, |action_log, cx| action_log.undo_last_reject(cx))
.detach();
}
})
.dismiss_button(true)
this.icon(
Icon::new(IconName::Undo)
.size(IconSize::Small)
.color(Color::Muted),
)
.action("Undo", move |_window, cx| {
if let Some(action_log) = action_log_weak.upgrade() {
action_log
.update(cx, |action_log, cx| action_log.undo_last_reject(cx))
.detach();
}
})
.dismiss_button(true)
});
workspace.toggle_status_toast(status_toast, cx);
}
@@ -54,14 +54,14 @@ const NOUNS: &[&str] = &[
"vole", "walrus", "warbler", "willow", "wolf", "wren", "yew", "zenith",
];
/// Generates a branch name in `"adjective-noun"` format (e.g. `"swift-falcon"`).
/// Generates a worktree name in `"adjective-noun"` format (e.g. `"swift-falcon"`).
///
/// Tries up to 100 random combinations, skipping any name that already appears
/// in `existing_branches`. Returns `None` if no unused name is found.
pub fn generate_branch_name(existing_branches: &[&str], rng: &mut impl Rng) -> Option<String> {
let existing: HashSet<&str> = existing_branches.iter().copied().collect();
/// Tries up to 10 random combinations, skipping any name that already appears
/// in `existing_names`. Returns `None` if no unused name is found.
pub fn generate_worktree_name(existing_names: &[&str], rng: &mut impl Rng) -> Option<String> {
let existing: HashSet<&str> = existing_names.iter().copied().collect();
for _ in 0..100 {
for _ in 0..10 {
let adjective = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())];
let noun = NOUNS[rng.random_range(0..NOUNS.len())];
let name = format!("{adjective}-{noun}");
@@ -80,8 +80,8 @@ mod tests {
use rand::rngs::StdRng;
#[gpui::test(iterations = 10)]
fn test_generate_branch_name_format(mut rng: StdRng) {
let name = generate_branch_name(&[], &mut rng).unwrap();
fn test_generate_worktree_name_format(mut rng: StdRng) {
let name = generate_worktree_name(&[], &mut rng).unwrap();
let (adjective, noun) = name.split_once('-').expect("name should contain a hyphen");
assert!(
ADJECTIVES.contains(&adjective),
@@ -91,9 +91,9 @@ mod tests {
}
#[gpui::test(iterations = 100)]
fn test_generate_branch_name_avoids_existing(mut rng: StdRng) {
fn test_generate_worktree_name_avoids_existing(mut rng: StdRng) {
let existing = &["swift-falcon", "calm-river", "bold-cedar"];
let name = generate_branch_name(existing, &mut rng).unwrap();
let name = generate_worktree_name(existing, &mut rng).unwrap();
for &branch in existing {
assert_ne!(
name, branch,
@@ -103,13 +103,13 @@ mod tests {
}
#[gpui::test]
fn test_generate_branch_name_returns_none_when_stuck(mut rng: StdRng) {
fn test_generate_worktree_name_returns_none_when_stuck(mut rng: StdRng) {
let all_names: Vec<String> = ADJECTIVES
.iter()
.flat_map(|adj| NOUNS.iter().map(move |noun| format!("{adj}-{noun}")))
.collect();
let refs: Vec<&str> = all_names.iter().map(|s| s.as_str()).collect();
let result = generate_branch_name(&refs, &mut rng);
let result = generate_worktree_name(&refs, &mut rng);
assert!(result.is_none());
}
+1 -131
View File
@@ -17,9 +17,7 @@ use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
use ui::{
Divider, List, ListBulletItem, RegisterComponent, Tooltip, Vector, VectorName, prelude::*,
};
use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*};
#[derive(PartialEq)]
pub enum SignInStatus {
@@ -442,131 +440,3 @@ impl Component for ZedAiOnboarding {
)
}
}
#[derive(RegisterComponent)]
pub struct AgentLayoutOnboarding {
pub use_agent_layout: Arc<dyn Fn(&mut Window, &mut App)>,
pub revert_to_editor_layout: Arc<dyn Fn(&mut Window, &mut App)>,
pub dismissed: Arc<dyn Fn(&mut Window, &mut App)>,
pub is_agent_layout: bool,
}
impl Render for AgentLayoutOnboarding {
fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
let description = "With the new Threads Sidebar, you can manage multiple agents across several projects, all in one window.";
let dismiss_button = div().absolute().top_0().right_0().child(
IconButton::new("dismiss", IconName::Close)
.icon_size(IconSize::Small)
.on_click({
let dismiss = self.dismissed.clone();
move |_, window, cx| {
telemetry::event!("Agentic Layout Onboarding Dismissed");
dismiss(window, cx)
}
}),
);
let primary_button = if self.is_agent_layout {
Button::new("revert", "Use Previous Layout")
.label_size(LabelSize::Small)
.style(ButtonStyle::Outlined)
.on_click({
let revert = self.revert_to_editor_layout.clone();
let dismiss = self.dismissed.clone();
move |_, window, cx| {
telemetry::event!("Clicked to Use Previous Layout");
revert(window, cx);
dismiss(window, cx);
}
})
} else {
Button::new("start", "Use New Layout")
.label_size(LabelSize::Small)
.style(ButtonStyle::Outlined)
.on_click({
let use_layout = self.use_agent_layout.clone();
let dismiss = self.dismissed.clone();
move |_, window, cx| {
telemetry::event!("Clicked to Use New Layout");
use_layout(window, cx);
dismiss(window, cx);
}
})
};
let content = v_flex()
.min_w_0()
.w_full()
.relative()
.gap_1()
.child(Label::new("A new workspace layout for agentic workflows"))
.child(Label::new(description).color(Color::Muted).mb_2())
.child(
List::new()
.child(ListBulletItem::new(
"The Sidebar and Agent Panel are on the left by default",
))
.child(ListBulletItem::new(
"The Project Panel and all other panels shift to the right",
))
.child(ListBulletItem::new(
"You can always customize your workspace layout in your Settings",
)),
)
.child(
h_flex()
.w_full()
.gap_1()
.flex_wrap()
.justify_end()
.child(
Button::new("learn", "Learn More")
.label_size(LabelSize::Small)
.style(ButtonStyle::OutlinedGhost)
.on_click(move |_, _, cx| {
cx.open_url(&zed_urls::parallel_agents_blog(cx))
}),
)
.child(primary_button),
)
.child(dismiss_button);
AgentPanelOnboardingCard::new().child(content)
}
}
impl Component for AgentLayoutOnboarding {
fn scope() -> ComponentScope {
ComponentScope::Onboarding
}
fn name() -> &'static str {
"Agent Layout Onboarding"
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let onboarding = cx.new(|_cx| AgentLayoutOnboarding {
use_agent_layout: Arc::new(|_, _| {}),
revert_to_editor_layout: Arc::new(|_, _| {}),
dismissed: Arc::new(|_, _| {}),
is_agent_layout: false,
});
Some(
v_flex()
.min_w_0()
.gap_4()
.child(single_example(
"Agent Layout Onboarding",
div()
.w_full()
.min_w_40()
.max_w(px(1100.))
.child(onboarding)
.into_any_element(),
))
.into_any_element(),
)
}
}
+118 -21
View File
@@ -70,6 +70,17 @@ pub enum Model {
alias = "claude-opus-4-6-1m-context-thinking-latest"
)]
ClaudeOpus4_6,
#[serde(
rename = "claude-opus-4-7",
alias = "claude-opus-4-7-latest",
alias = "claude-opus-4-7-1m-context",
alias = "claude-opus-4-7-1m-context-latest",
alias = "claude-opus-4-7-thinking",
alias = "claude-opus-4-7-thinking-latest",
alias = "claude-opus-4-7-1m-context-thinking",
alias = "claude-opus-4-7-1m-context-thinking-latest"
)]
ClaudeOpus4_7,
#[serde(
rename = "claude-sonnet-4",
alias = "claude-sonnet-4-latest",
@@ -130,6 +141,10 @@ impl Model {
}
pub fn from_id(id: &str) -> Result<Self> {
if id.starts_with("claude-opus-4-7") {
return Ok(Self::ClaudeOpus4_7);
}
if id.starts_with("claude-opus-4-6") {
return Ok(Self::ClaudeOpus4_6);
}
@@ -175,6 +190,7 @@ impl Model {
Self::ClaudeOpus4_1 => "claude-opus-4-1-latest",
Self::ClaudeOpus4_5 => "claude-opus-4-5-latest",
Self::ClaudeOpus4_6 => "claude-opus-4-6-latest",
Self::ClaudeOpus4_7 => "claude-opus-4-7-latest",
Self::ClaudeSonnet4 => "claude-sonnet-4-latest",
Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest",
Self::ClaudeSonnet4_6 => "claude-sonnet-4-6-latest",
@@ -191,6 +207,7 @@ impl Model {
Self::ClaudeOpus4_1 => "claude-opus-4-1-20250805",
Self::ClaudeOpus4_5 => "claude-opus-4-5-20251101",
Self::ClaudeOpus4_6 => "claude-opus-4-6",
Self::ClaudeOpus4_7 => "claude-opus-4-7",
Self::ClaudeSonnet4 => "claude-sonnet-4-20250514",
Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-20250929",
Self::ClaudeSonnet4_6 => "claude-sonnet-4-6",
@@ -206,6 +223,7 @@ impl Model {
Self::ClaudeOpus4_1 => "Claude Opus 4.1",
Self::ClaudeOpus4_5 => "Claude Opus 4.5",
Self::ClaudeOpus4_6 => "Claude Opus 4.6",
Self::ClaudeOpus4_7 => "Claude Opus 4.7",
Self::ClaudeSonnet4 => "Claude Sonnet 4",
Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5",
Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6",
@@ -223,6 +241,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeSonnet4_6
@@ -248,7 +267,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeHaiku4_5
| Self::Claude3Haiku => 200_000,
Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 => 1_000_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
@@ -261,7 +280,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeSonnet4_6
| Self::ClaudeHaiku4_5 => 64_000,
Self::ClaudeOpus4_6 => 128_000,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000,
Self::Claude3Haiku => 4_096,
Self::Custom {
max_output_tokens, ..
@@ -275,6 +294,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeSonnet4_6
@@ -288,33 +308,51 @@ impl Model {
}
pub fn mode(&self) -> AnthropicModelMode {
if self.supports_adaptive_thinking() {
AnthropicModelMode::AdaptiveThinking
} else if self.supports_thinking() {
AnthropicModelMode::Thinking {
match self {
Self::Custom { mode, .. } => mode.clone(),
_ if self.supports_adaptive_thinking() => AnthropicModelMode::AdaptiveThinking,
_ if self.supports_thinking() => AnthropicModelMode::Thinking {
budget_tokens: Some(4_096),
}
} else {
AnthropicModelMode::Default
},
_ => AnthropicModelMode::Default,
}
}
pub fn supports_thinking(&self) -> bool {
matches!(
self,
Self::ClaudeOpus4
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeSonnet4_6
| Self::ClaudeHaiku4_5
)
match self {
Self::Custom { mode, .. } => {
matches!(
mode,
AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking
)
}
_ => matches!(
self,
Self::ClaudeOpus4
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeSonnet4_6
| Self::ClaudeHaiku4_5
),
}
}
pub fn supports_speed(&self) -> bool {
matches!(self, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6)
}
pub fn supports_adaptive_thinking(&self) -> bool {
matches!(self, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6)
match self {
Self::Custom { mode, .. } => matches!(mode, AnthropicModelMode::AdaptiveThinking),
_ => matches!(
self,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6
),
}
}
pub fn beta_headers(&self) -> Option<String> {
@@ -1110,6 +1148,65 @@ impl From<ApiError> for language_model_core::LanguageModelCompletionError {
}
}
#[test]
fn custom_mode_thinking_is_preserved() {
let model = Model::Custom {
name: "my-custom-model".to_string(),
max_tokens: 8192,
display_name: None,
tool_override: None,
cache_configuration: None,
max_output_tokens: None,
default_temperature: None,
extra_beta_headers: vec![],
mode: AnthropicModelMode::Thinking {
budget_tokens: Some(2048),
},
};
assert_eq!(
model.mode(),
AnthropicModelMode::Thinking {
budget_tokens: Some(2048)
}
);
assert!(model.supports_thinking());
}
#[test]
fn custom_mode_adaptive_is_preserved() {
let model = Model::Custom {
name: "my-custom-model".to_string(),
max_tokens: 8192,
display_name: None,
tool_override: None,
cache_configuration: None,
max_output_tokens: None,
default_temperature: None,
extra_beta_headers: vec![],
mode: AnthropicModelMode::AdaptiveThinking,
};
assert_eq!(model.mode(), AnthropicModelMode::AdaptiveThinking);
assert!(model.supports_adaptive_thinking());
assert!(model.supports_thinking());
}
#[test]
fn custom_mode_default_disables_thinking() {
let model = Model::Custom {
name: "my-custom-model".to_string(),
max_tokens: 8192,
display_name: None,
tool_override: None,
cache_configuration: None,
max_output_tokens: None,
default_temperature: None,
extra_beta_headers: vec![],
mode: AnthropicModelMode::Default,
};
assert!(!model.supports_thinking());
assert!(!model.supports_adaptive_thinking());
}
#[test]
fn test_match_window_exceeded() {
let error = ApiError {
+1
View File
@@ -19,6 +19,7 @@ client.workspace = true
db.workspace = true
fs.workspace = true
editor.workspace = true
notifications.workspace = true
gpui.workspace = true
markdown_preview.workspace = true
release_channel.workspace = true
+138 -78
View File
@@ -9,6 +9,7 @@ use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
};
use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
use notifications::status_toast::StatusToast;
use release_channel::{AppVersion, ReleaseChannel};
use semver::Version;
use serde::Deserialize;
@@ -22,6 +23,7 @@ use workspace::{
simple_message_notification::MessageNotification,
},
};
use zed_actions::{ShowUpdateNotification, assistant::FocusAgent};
actions!(
auto_update,
@@ -33,10 +35,19 @@ actions!(
pub fn init(cx: &mut App) {
notify_if_app_was_updated(cx);
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
cx.observe_new(|workspace: &mut Workspace, _window, cx| {
workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, window, cx| {
view_release_notes_locally(workspace, window, cx);
});
if matches!(
ReleaseChannel::global(cx),
ReleaseChannel::Nightly | ReleaseChannel::Dev
) {
workspace.register_action(|_workspace, _: &ShowUpdateNotification, _window, cx| {
show_update_notification(cx);
});
}
})
.detach();
}
@@ -186,42 +197,90 @@ impl Dismissable for ParallelAgentAnnouncement {
}
fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementContent> {
match (version.major, version.minor, version.patch) {
(0, 232, _) => {
if ParallelAgentAnnouncement::dismissed(cx) {
None
} else {
let fs = <dyn Fs>::global(cx);
Some(AnnouncementContent {
heading: "Introducing Parallel Agents".into(),
description: "Run multiple agent threads simultaneously across projects."
.into(),
bullet_items: vec![
"Use your favorite agents in parallel".into(),
"Optionally isolate agents using worktrees".into(),
"Combine multiple projects in one window".into(),
],
primary_action_label: "Try Now".into(),
primary_action_url: None,
primary_action_callback: Some(Arc::new(move |window, cx| {
let already_agent_layout =
matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
let version_with_parallel_agents = match ReleaseChannel::global(cx) {
ReleaseChannel::Stable => Version::new(0, 233, 0),
ReleaseChannel::Dev | ReleaseChannel::Nightly | ReleaseChannel::Preview => {
Version::new(0, 232, 0)
}
};
if !already_agent_layout {
AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
if *version >= version_with_parallel_agents && !ParallelAgentAnnouncement::dismissed(cx) {
let fs = <dyn Fs>::global(cx);
Some(AnnouncementContent {
heading: "Introducing Parallel Agents".into(),
description: "Run multiple threads of your favorite agents simultaneously across projects in a new workspace layout, tailored for agentic workflows.".into(),
bullet_items: vec![
"Use your favorite agents in parallel".into(),
"Optionally isolate agents using worktrees".into(),
"Combine multiple projects in one window".into(),
],
primary_action_label: "Try Agentic Layout".into(),
primary_action_url: None,
primary_action_callback: Some(Arc::new(move |window, cx| {
let get_layout = AgentSettings::get_layout(cx);
let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_));
let update;
if !already_agent_layout {
update = Some(AgentSettings::set_layout(
WindowLayout::Agent(None),
fs.clone(),
cx,
));
} else {
update = None;
}
let revert_fs = fs.clone();
window
.spawn(cx, async move |cx| {
if let Some(update) = update {
update.await.ok();
}
window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
window.dispatch_action(Box::new(zed_actions::assistant::ToggleFocus), cx);
})),
on_dismiss: Some(Arc::new(|cx| {
ParallelAgentAnnouncement::set_dismissed(true, cx)
})),
secondary_action_url: Some("https://zed.dev/blog/".into()),
})
}
}
_ => None,
cx.update(|window, cx| {
if !already_agent_layout {
if let Some(workspace) = Workspace::for_window(window, cx) {
let toast = StatusToast::new(
"You are in the new agentic layout!",
cx,
move |this, _cx| {
this.icon(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
.action("Revert", move |_window, cx| {
let _ = AgentSettings::set_layout(
get_layout.clone(),
revert_fs.clone(),
cx,
);
})
.auto_dismiss(false)
.dismiss_button(true)
},
);
workspace.update(cx, |workspace, cx| {
workspace.toggle_status_toast(toast, cx);
});
}
}
window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
window.dispatch_action(Box::new(FocusAgent), cx);
})
})
.detach();
})),
on_dismiss: Some(Arc::new(|cx| {
ParallelAgentAnnouncement::set_dismissed(true, cx)
})),
secondary_action_url: Some("https://zed.dev/blog/".into()),
})
} else {
None
}
}
@@ -299,6 +358,48 @@ impl Render for AnnouncementToastNotification {
}
}
struct UpdateNotification;
fn show_update_notification(cx: &mut App) {
let Some(updater) = AutoUpdater::get(cx) else {
return;
};
let mut version = updater.read(cx).current_version();
version.pre = semver::Prerelease::EMPTY;
version.build = semver::BuildMetadata::EMPTY;
let app_name = ReleaseChannel::global(cx).display_name();
if let Some(content) = announcement_for_version(&version, cx) {
show_app_notification(
NotificationId::unique::<UpdateNotification>(),
cx,
move |cx| cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx)),
);
} else {
show_app_notification(
NotificationId::unique::<UpdateNotification>(),
cx,
move |cx| {
let workspace_handle = cx.entity().downgrade();
cx.new(|cx| {
MessageNotification::new(format!("Updated to {app_name} {}", version), cx)
.primary_message("View Release Notes")
.primary_on_click(move |window, cx| {
if let Some(workspace) = workspace_handle.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::view_release_notes_locally(workspace, window, cx);
})
}
cx.emit(DismissEvent);
})
.show_suppress_button(false)
})
},
);
}
}
/// Shows a notification across all workspaces if an update was previously automatically installed
/// and this notification had not yet been shown.
pub fn notify_if_app_was_updated(cx: &mut App) {
@@ -310,55 +411,14 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
return;
}
struct UpdateNotification;
let should_show_notification = updater.read(cx).should_show_update_notification(cx);
cx.spawn(async move |cx| {
let should_show_notification = should_show_notification.await?;
// if true { // Hardcode it to true for testing it outside of the component preview
if should_show_notification {
cx.update(|cx| {
let mut version = updater.read(cx).current_version();
version.pre = semver::Prerelease::EMPTY;
version.build = semver::BuildMetadata::EMPTY;
let app_name = ReleaseChannel::global(cx).display_name();
if let Some(content) = announcement_for_version(&version, cx) {
show_app_notification(
NotificationId::unique::<UpdateNotification>(),
cx,
move |cx| {
cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx))
},
);
} else {
show_app_notification(
NotificationId::unique::<UpdateNotification>(),
cx,
move |cx| {
let workspace_handle = cx.entity().downgrade();
cx.new(|cx| {
MessageNotification::new(
format!("Updated to {app_name} {}", version),
cx,
)
.primary_message("View Release Notes")
.primary_on_click(move |window, cx| {
if let Some(workspace) = workspace_handle.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::view_release_notes_locally(
workspace, window, cx,
);
})
}
cx.emit(DismissEvent);
})
.show_suppress_button(false)
})
},
);
}
show_update_notification(cx);
updater.update(cx, |updater, cx| {
updater
.set_should_show_update_notification(false, cx)
+43 -3
View File
@@ -84,6 +84,13 @@ pub enum Model {
alias = "claude-opus-4-6-thinking-latest"
)]
ClaudeOpus4_6,
#[serde(
rename = "claude-opus-4-7",
alias = "claude-opus-4-7-latest",
alias = "claude-opus-4-7-thinking",
alias = "claude-opus-4-7-thinking-latest"
)]
ClaudeOpus4_7,
#[serde(
rename = "claude-sonnet-4-6",
alias = "claude-sonnet-4-6-latest",
@@ -203,7 +210,9 @@ impl Model {
}
pub fn from_id(id: &str) -> anyhow::Result<Self> {
if id.starts_with("claude-opus-4-6") {
if id.starts_with("claude-opus-4-7") {
Ok(Self::ClaudeOpus4_7)
} else if id.starts_with("claude-opus-4-6") {
Ok(Self::ClaudeOpus4_6)
} else if id.starts_with("claude-opus-4-5") {
Ok(Self::ClaudeOpus4_5)
@@ -230,6 +239,7 @@ impl Model {
Self::ClaudeOpus4_1 => "claude-opus-4-1",
Self::ClaudeOpus4_5 => "claude-opus-4-5",
Self::ClaudeOpus4_6 => "claude-opus-4-6",
Self::ClaudeOpus4_7 => "claude-opus-4-7",
Self::ClaudeSonnet4_6 => "claude-sonnet-4-6",
Self::Llama4Scout17B => "llama-4-scout-17b",
Self::Llama4Maverick17B => "llama-4-maverick-17b",
@@ -279,6 +289,7 @@ impl Model {
Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0",
Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0",
Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1",
Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7-v1",
Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6",
Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0",
Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0",
@@ -328,6 +339,7 @@ impl Model {
Self::ClaudeOpus4_1 => "Claude Opus 4.1",
Self::ClaudeOpus4_5 => "Claude Opus 4.5",
Self::ClaudeOpus4_6 => "Claude Opus 4.6",
Self::ClaudeOpus4_7 => "Claude Opus 4.7",
Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6",
Self::Llama4Scout17B => "Llama 4 Scout 17B",
Self::Llama4Maverick17B => "Llama 4 Maverick 17B",
@@ -383,6 +395,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => 200_000,
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
@@ -416,7 +429,7 @@ impl Model {
| Self::ClaudeOpus4_5
| Self::ClaudeSonnet4_6 => 64_000,
Self::ClaudeOpus4_1 => 32_000,
Self::ClaudeOpus4_6 => 128_000,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000,
Self::Llama4Scout17B
| Self::Llama4Maverick17B
| Self::Gemma3_4B
@@ -454,6 +467,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => 1.0,
Self::Custom {
default_temperature,
@@ -471,6 +485,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true,
Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true,
@@ -501,6 +516,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro => true,
Self::PixtralLarge => true,
@@ -517,6 +533,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
)
}
@@ -529,6 +546,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => true,
Self::Custom {
cache_configuration,
@@ -545,6 +563,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => Some(BedrockModelCacheConfiguration {
max_cache_anchors: 4,
min_total_token: 1024,
@@ -570,12 +589,16 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
)
}
pub fn supports_adaptive_thinking(&self) -> bool {
matches!(self, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6)
matches!(
self,
Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6
)
}
pub fn thinking_mode(&self) -> BedrockModelMode {
@@ -606,6 +629,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
| Self::Nova2Lite
);
@@ -665,6 +689,7 @@ impl Model {
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
| Self::Nova2Lite,
"global",
@@ -681,6 +706,7 @@ impl Model {
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
| Self::Llama4Scout17B
| Self::Llama4Maverick17B
@@ -702,6 +728,7 @@ impl Model {
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
| Self::NovaLite
| Self::NovaPro
@@ -714,6 +741,7 @@ impl Model {
Self::ClaudeHaiku4_5
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6,
"au",
) => Ok(format!("{}.{}", region_group, model_id)),
@@ -787,6 +815,10 @@ mod tests {
Model::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?,
"eu.anthropic.claude-opus-4-7-v1"
);
Ok(())
}
@@ -817,6 +849,10 @@ mod tests {
Model::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?,
"au.anthropic.claude-opus-4-7-v1"
);
Ok(())
}
@@ -877,6 +913,10 @@ mod tests {
Model::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-6-v1"
);
assert_eq!(
Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?,
"global.anthropic.claude-opus-4-7-v1"
);
assert_eq!(
Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?,
"global.amazon.nova-2-lite-v1:0"
+25 -10
View File
@@ -1515,11 +1515,17 @@ pub struct DiffChanged {
#[derive(Clone, Debug)]
pub enum BufferDiffEvent {
BaseTextChanged,
DiffChanged(DiffChanged),
LanguageChanged,
HunksStagedOrUnstaged(Option<Rope>),
}
struct SetSnapshotResult {
change: DiffChanged,
base_text_changed: bool,
}
impl EventEmitter<BufferDiffEvent> for BufferDiff {}
impl BufferDiff {
@@ -1784,7 +1790,7 @@ impl BufferDiff {
secondary_diff_change: Option<Range<Anchor>>,
clear_pending_hunks: bool,
cx: &mut Context<Self>,
) -> impl Future<Output = DiffChanged> + use<> {
) -> impl Future<Output = SetSnapshotResult> + use<> {
log::debug!("set snapshot with secondary {secondary_diff_change:?}");
let old_snapshot = self.snapshot(cx);
@@ -1904,10 +1910,13 @@ impl BufferDiff {
if let Some(parsing_idle) = parsing_idle {
parsing_idle.await;
}
DiffChanged {
changed_range,
base_text_changed_range,
extended_range,
SetSnapshotResult {
change: DiffChanged {
changed_range,
base_text_changed_range,
extended_range,
},
base_text_changed,
}
}
}
@@ -1938,12 +1947,15 @@ impl BufferDiff {
);
cx.spawn(async move |this, cx| {
let change = fut.await;
let result = fut.await;
this.update(cx, |_, cx| {
cx.emit(BufferDiffEvent::DiffChanged(change.clone()));
if result.base_text_changed {
cx.emit(BufferDiffEvent::BaseTextChanged);
}
cx.emit(BufferDiffEvent::DiffChanged(result.change.clone()));
})
.ok();
change.changed_range
result.change.changed_range
})
}
@@ -2019,8 +2031,11 @@ impl BufferDiff {
let fg_executor = cx.foreground_executor().clone();
let snapshot = fg_executor.block_on(fut);
let fut = self.set_snapshot_with_secondary_inner(snapshot, buffer, None, false, cx);
let change = fg_executor.block_on(fut);
cx.emit(BufferDiffEvent::DiffChanged(change));
let result = fg_executor.block_on(fut);
if result.base_text_changed {
cx.emit(BufferDiffEvent::BaseTextChanged);
}
cx.emit(BufferDiffEvent::DiffChanged(result.change));
}
pub fn base_text_buffer(&self) -> &Entity<language::Buffer> {
+38 -5
View File
@@ -9,10 +9,45 @@ pub struct IpcHandshake {
pub responses: ipc::IpcReceiver<CliResponse>,
}
/// Controls how CLI paths are opened — whether to reuse existing windows,
/// create new ones, or add to the sidebar.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OpenBehavior {
/// Consult the user's `cli_default_open_behavior` setting to choose between
/// `ExistingWindow` or `Classic`.
#[default]
Default,
/// Always create a new window. No matching against existing worktrees.
/// Corresponds to `zed -n`.
AlwaysNew,
/// Match broadly including subdirectories, and fall back to any existing
/// window if no worktree matched. Corresponds to `zed -a`.
Add,
/// Open directories as a new workspace in the current Zed window's sidebar.
/// Reuse existing windows for files in open worktrees.
/// Corresponds to `zed -e`.
ExistingWindow,
/// New window for directories, reuse existing window for files in open
/// worktrees. The classic pre-sidebar behavior.
/// Corresponds to `zed --classic`.
Classic,
/// Replace the content of an existing window with a new workspace.
/// Corresponds to `zed -r`.
Reuse,
}
/// The setting-level enum for configuring default behavior. This only has
/// two values because the other modes are always explicitly requested via
/// CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliOpenBehavior {
pub enum CliBehaviorSetting {
/// Open directories as a new workspace in the current Zed window's sidebar.
ExistingWindow,
/// Classic behavior: open directories in a new window, but reuse an
/// existing window when opening files that are already part of an open
/// project.
NewWindow,
}
@@ -25,16 +60,14 @@ pub enum CliRequest {
diff_all: bool,
wsl: Option<String>,
wait: bool,
open_new_workspace: Option<bool>,
#[serde(default)]
force_existing_window: bool,
reuse: bool,
open_behavior: OpenBehavior,
env: Option<HashMap<String, String>>,
user_data_dir: Option<String>,
dev_container: bool,
},
SetOpenBehavior {
behavior: CliOpenBehavior,
behavior: CliBehaviorSetting,
},
}
+31 -21
View File
@@ -67,17 +67,20 @@ struct Args {
#[arg(short, long)]
wait: bool,
/// Add files to the currently open workspace
#[arg(short, long, overrides_with_all = ["new", "reuse", "existing"])]
#[arg(short, long, overrides_with_all = ["new", "reuse", "existing", "classic"])]
add: bool,
/// Create a new workspace
#[arg(short, long, overrides_with_all = ["add", "reuse", "existing"])]
#[arg(short, long, overrides_with_all = ["add", "reuse", "existing", "classic"])]
new: bool,
/// Reuse an existing window, replacing its workspace
#[arg(short, long, overrides_with_all = ["add", "new", "existing"])]
#[arg(short, long, overrides_with_all = ["add", "new", "existing", "classic"], hide = true)]
reuse: bool,
/// Open in existing Zed window
#[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse"])]
#[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse", "classic"])]
existing: bool,
/// Use the classic open behavior: new window for directories, reuse for files
#[arg(long, hide = true, overrides_with_all = ["add", "new", "reuse", "existing"])]
classic: bool,
/// Sets a custom directory for all user data (e.g., database, extensions, logs).
/// This overrides the default platform-specific data directory location:
#[cfg_attr(target_os = "macos", doc = "`~/Library/Application Support/Zed`.")]
@@ -538,16 +541,20 @@ fn main() -> Result<()> {
IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
let url = format!("zed-cli://{server_name}");
let open_new_workspace = if args.new {
Some(true)
let open_behavior = if args.new {
cli::OpenBehavior::AlwaysNew
} else if args.add {
Some(false)
cli::OpenBehavior::Add
} else if args.existing {
cli::OpenBehavior::ExistingWindow
} else if args.classic {
cli::OpenBehavior::Classic
} else if args.reuse {
cli::OpenBehavior::Reuse
} else {
None
cli::OpenBehavior::Default
};
let force_existing_window = args.existing;
let env = {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
@@ -676,9 +683,7 @@ fn main() -> Result<()> {
diff_all: diff_all_mode,
wsl,
wait: args.wait,
open_new_workspace,
force_existing_window,
reuse: args.reuse,
open_behavior,
env,
user_data_dir: user_data_dir_for_thread,
dev_container: args.dev_container,
@@ -697,7 +702,7 @@ fn main() -> Result<()> {
}
CliResponse::PromptOpenBehavior => {
let behavior = prompt_open_behavior()
.unwrap_or(cli::CliOpenBehavior::ExistingWindow);
.unwrap_or(cli::CliBehaviorSetting::ExistingWindow);
tx.send(CliRequest::SetOpenBehavior { behavior })?;
}
}
@@ -796,15 +801,18 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
/// Shows an interactive prompt asking the user to choose the default open
/// behavior for `zed <path>`. Returns `None` if the prompt cannot be shown
/// (e.g. stdin is not a terminal) or the user cancels.
fn prompt_open_behavior() -> Option<cli::CliOpenBehavior> {
fn prompt_open_behavior() -> Option<cli::CliBehaviorSetting> {
if !std::io::stdin().is_terminal() {
return None;
}
let blue = console::Style::new().blue();
let items = [
format!("Add to existing Zed window ({})", blue.apply_to("zed -e")),
format!("Open a new window ({})", blue.apply_to("zed -n")),
format!(
"Add to existing Zed window ({})",
blue.apply_to("zed --existing")
),
format!("Open a new window ({})", blue.apply_to("zed --classic")),
];
let prompt = format!(
@@ -821,9 +829,9 @@ fn prompt_open_behavior() -> Option<cli::CliOpenBehavior> {
.ok()?;
Some(if selection == 0 {
cli::CliOpenBehavior::ExistingWindow
cli::CliBehaviorSetting::ExistingWindow
} else {
cli::CliOpenBehavior::NewWindow
cli::CliBehaviorSetting::NewWindow
})
}
@@ -1217,7 +1225,9 @@ mod mac_os {
string::kCFStringEncodingUTF8,
url::{CFURL, CFURLCreateWithBytes},
};
use core_services::{LSLaunchURLSpec, LSOpenFromURLSpec, kLSLaunchDefaults};
use core_services::{
LSLaunchURLSpec, LSOpenFromURLSpec, kLSLaunchDefaults, kLSLaunchDontSwitch,
};
use serde::Deserialize;
use std::{
ffi::OsStr,
@@ -1316,7 +1326,7 @@ mod mac_os {
appURL: app_url.as_concrete_TypeRef(),
itemURLs: urls_to_open.as_concrete_TypeRef(),
passThruParams: ptr::null(),
launchFlags: kLSLaunchDefaults,
launchFlags: kLSLaunchDefaults | kLSLaunchDontSwitch,
asyncRefCon: ptr::null_mut(),
},
ptr::null_mut(),
+11 -4
View File
@@ -755,10 +755,8 @@ impl UserStore {
};
}
if let Some(organization) = &self.current_organization
&& let Some(plan) = self.plan_for_organization(&organization.id)
{
return Some(plan);
if let Some(organization) = &self.current_organization {
return self.plan_for_organization(&organization.id);
}
self.plan_info.as_ref().map(|info| info.plan())
@@ -784,7 +782,16 @@ impl UserStore {
}
/// Returns whether the user's account is too new to use the service.
///
/// This only applies when operating under the user's personal organization,
/// not a business organization.
pub fn account_too_young(&self) -> bool {
if let Some(org) = &self.current_organization {
if !org.is_personal {
return false;
}
}
self.plan_info
.as_ref()
.map(|plan| plan.is_account_too_young)
@@ -12,6 +12,9 @@ use uuid::Uuid;
/// The name of the header used to indicate which version of Zed the client is running.
pub const ZED_VERSION_HEADER_NAME: &str = "x-zed-version";
/// The name of the header used to indicate which edit prediction experiment should be used.
pub const PREFERRED_EXPERIMENT_HEADER_NAME: &str = "x-zed-preferred-experiment";
/// The name of the header used to indicate when a request failed due to an
/// expired LLM token.
///
@@ -2,6 +2,17 @@ use crate::PredictEditsRequestTrigger;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::ops::Range;
use strum::{AsRefStr, EnumString};
pub const PREDICT_EDITS_MODE_HEADER_NAME: &str = "X-Zed-Predict-Edits-Mode";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum PredictEditsMode {
Eager,
Subtle,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RawCompletionRequest {
+4
View File
@@ -439,6 +439,10 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GitCreateWorktree>)
.add_request_handler(disallow_guest_request::<proto::GitRemoveWorktree>)
.add_request_handler(disallow_guest_request::<proto::GitRenameWorktree>)
.add_request_handler(forward_mutating_project_request::<proto::GitEditRef>)
.add_request_handler(forward_mutating_project_request::<proto::GitRepairWorktrees>)
.add_request_handler(disallow_guest_request::<proto::GitCreateArchiveCheckpoint>)
.add_request_handler(disallow_guest_request::<proto::GitRestoreArchiveCheckpoint>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_request_handler(forward_mutating_project_request::<proto::ToggleLspLogs>)
.add_message_handler(broadcast_project_message_from_host::<proto::LanguageServerLog>)
@@ -514,6 +514,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/feature-branch".into()),
sha: "bbb222".into(),
is_main: false,
is_bare: false,
},
)
.await;
@@ -525,6 +526,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/bugfix-branch".into()),
sha: "ccc333".into(),
is_main: false,
is_bare: false,
},
)
.await;
@@ -597,6 +599,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/hotfix-branch".into()),
sha: "ddd444".into(),
is_main: false,
is_bare: false,
},
)
.await;
+3
View File
@@ -216,6 +216,9 @@ impl ChannelView {
})
}))
});
editor.set_show_bookmarks(false, cx);
editor.set_show_breakpoints(false, cx);
editor.set_show_runnables(false, cx);
editor
});
let _editor_event_subscription =
@@ -8,7 +8,7 @@ use gpui::{
};
use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
use language::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use persistence::ComponentPreviewDb;
use project::Project;
use std::{iter::Iterator, ops::Range, sync::Arc};
@@ -561,10 +561,14 @@ impl ComponentPreview {
workspace.update(cx, |workspace, cx| {
let status_toast =
StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
.action("Open Pull Request", |_, cx| {
cx.open_url("https://github.com/")
})
this.icon(
Icon::new(IconName::GitBranch)
.size(IconSize::Small)
.color(Color::Muted),
)
.action("Open Pull Request", |_, cx| {
cx.open_url("https://github.com/")
})
});
workspace.toggle_status_toast(status_toast, cx)
});
+49 -31
View File
@@ -15,11 +15,12 @@ pub use sqlez_macros;
pub use uuid;
pub use release_channel::RELEASE_CHANNEL;
use release_channel::ReleaseChannel;
use sqlez::domain::Migrator;
use sqlez::thread_safe_connection::ThreadSafeConnection;
use sqlez_macros::sql;
use std::future::Future;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, atomic::Ordering};
use util::{ResultExt, maybe};
@@ -61,8 +62,7 @@ impl AppDatabase {
/// migrations in dependency order.
pub fn new() -> Self {
let db_dir = database_dir();
let scope = RELEASE_CHANNEL.dev_name();
let connection = smol::block_on(open_db::<AppMigrator>(db_dir, scope));
let connection = smol::block_on(open_db::<AppMigrator>(db_dir, *RELEASE_CHANNEL));
Self(connection)
}
@@ -139,23 +139,55 @@ const DB_FILE_NAME: &str = "db.sqlite";
pub static ALL_FILE_DB_FAILED: LazyLock<AtomicBool> = LazyLock::new(|| AtomicBool::new(false));
/// A type that can be used as a database scope for path construction.
pub trait DbScope {
fn scope_name(&self) -> &str;
}
impl DbScope for ReleaseChannel {
fn scope_name(&self) -> &str {
self.dev_name()
}
}
/// A database scope shared across all release channels.
pub struct GlobalDbScope;
impl DbScope for GlobalDbScope {
fn scope_name(&self) -> &str {
"global"
}
}
/// Returns the path to the `AppDatabase` SQLite file for the given scope
/// under `db_dir`.
pub fn db_path(db_dir: &Path, scope: impl DbScope) -> PathBuf {
db_dir
.join(format!("0-{}", scope.scope_name()))
.join(DB_FILE_NAME)
}
/// Open or create a database at the given directory path.
/// This will retry a couple times if there are failures. If opening fails once, the db directory
/// is moved to a backup folder and a new one is created. If that fails, a shared in memory db is created.
/// In either case, static variables are set so that the user can be notified.
pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, scope: &str) -> ThreadSafeConnection {
pub async fn open_db<M: Migrator + 'static>(
db_dir: &Path,
scope: impl DbScope,
) -> ThreadSafeConnection {
if *ZED_STATELESS {
return open_fallback_db::<M>().await;
}
let main_db_dir = db_dir.join(format!("0-{}", scope));
let db_path = db_path(db_dir, scope);
let connection = maybe!(async {
smol::fs::create_dir_all(&main_db_dir)
.await
.context("Could not create db directory")
.log_err()?;
let db_path = main_db_dir.join(Path::new(DB_FILE_NAME));
if let Some(parent) = db_path.parent() {
smol::fs::create_dir_all(parent)
.await
.context("Could not create db directory")
.log_err()?;
}
open_main_db::<M>(&db_path).await
})
.await;
@@ -289,11 +321,7 @@ mod tests {
.prefix("DbTests")
.tempdir()
.unwrap();
let _bad_db = open_db::<BadDB>(
tempdir.path(),
release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
let _bad_db = open_db::<BadDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
}
/// Test that DB exists but corrupted (causing recreate)
@@ -320,19 +348,12 @@ mod tests {
.tempdir()
.unwrap();
{
let corrupt_db = open_db::<CorruptedDB>(
tempdir.path(),
release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
let corrupt_db =
open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
assert!(corrupt_db.persistent());
}
let good_db = open_db::<GoodDB>(
tempdir.path(),
release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
let good_db = open_db::<GoodDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
assert!(
good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
.unwrap()
@@ -366,11 +387,8 @@ mod tests {
.unwrap();
{
// Setup the bad database
let corrupt_db = open_db::<CorruptedDB>(
tempdir.path(),
release_channel::ReleaseChannel::Dev.dev_name(),
)
.await;
let corrupt_db =
open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
assert!(corrupt_db.persistent());
}
@@ -381,7 +399,7 @@ mod tests {
let guard = thread::spawn(move || {
let good_db = smol::block_on(open_db::<GoodDB>(
tmp_path.as_path(),
release_channel::ReleaseChannel::Dev.dev_name(),
release_channel::ReleaseChannel::Dev,
));
assert!(
good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
+2 -1
View File
@@ -244,7 +244,8 @@ static GLOBAL_KEY_VALUE_STORE: std::sync::LazyLock<GlobalKeyValueStore> =
std::sync::LazyLock::new(|| {
let db_dir = crate::database_dir();
GlobalKeyValueStore(smol::block_on(crate::open_db::<GlobalKeyValueStore>(
db_dir, "global",
db_dir,
crate::GlobalDbScope,
)))
});
+1
View File
@@ -761,6 +761,7 @@ impl DapLogView {
editor.set_text(log_contents, window, cx);
editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
editor.set_show_code_actions(false, cx);
editor.set_show_bookmarks(false, cx);
editor.set_show_breakpoints(false, cx);
editor.set_show_git_diff_gutter(false, cx);
editor.set_show_runnables(false, cx);
@@ -73,6 +73,7 @@ impl Console {
editor.disable_scrollbars_and_minimap(window, cx);
editor.set_show_gutter(false, cx);
editor.set_show_runnables(false, cx);
editor.set_show_bookmarks(false, cx);
editor.set_show_breakpoints(false, cx);
editor.set_show_code_actions(false, cx);
editor.set_show_line_numbers(false, cx);
@@ -14,7 +14,7 @@ use gpui::{
Subscription, Task, TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions,
anchored, deferred, uniform_list,
};
use notifications::status_toast::{StatusToast, ToastIcon};
use notifications::status_toast::StatusToast;
use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session};
use settings::Settings;
use theme_settings::ThemeSettings;
@@ -480,7 +480,7 @@ impl MemoryView {
cx.emit(DismissEvent)
});
}).detach();
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
this.icon(Icon::new(IconName::XCircle).size(IconSize::Small).color(Color::Error))
}),
cx,
);
@@ -1229,6 +1229,7 @@ async fn test_send_breakpoints_when_editor_has_been_saved(
editor.save(
SaveOptions {
format: true,
force_format: false,
autosave: false,
},
project.clone(),
@@ -75,6 +75,7 @@ pub enum DevContainerError {
DevContainerUpFailed(String),
DevContainerNotFound,
DevContainerParseFailed,
DevContainerValidationFailed(String),
FilesystemError,
ResourceFetchFailed,
NotInValidProject,
@@ -110,6 +111,7 @@ impl Display for DevContainerError {
"Error downloading resources locally".to_string(),
DevContainerError::ResourceFetchFailed =>
"Failed to fetch resources from template or feature repository".to_string(),
DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(),
}
)
}
+133 -1
View File
@@ -222,7 +222,7 @@ pub(crate) struct DevContainer {
#[serde(default, deserialize_with = "deserialize_mount_definition")]
pub(crate) workspace_mount: Option<MountDefinition>,
pub(crate) workspace_folder: Option<String>,
run_args: Option<Vec<String>>,
pub(crate) run_args: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_string_or_array")]
pub(crate) docker_compose_file: Option<Vec<String>>,
pub(crate) service: Option<String>,
@@ -259,6 +259,32 @@ impl DevContainer {
DevContainerBuildType::None
}
}
pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> {
match self.build_type() {
DevContainerBuildType::Image(_) => Ok(()),
DevContainerBuildType::Dockerfile(_) => {
if (self.workspace_folder.is_some() && self.workspace_mount.is_none())
|| (self.workspace_folder.is_none() && self.workspace_mount.is_some())
{
return Err(DevContainerError::DevContainerValidationFailed(
"workspaceMount and workspaceFolder must both be defined, or neither defined"
.to_string(),
));
}
Ok(())
}
DevContainerBuildType::DockerCompose => {
if self.service.is_none() {
return Err(DevContainerError::DevContainerValidationFailed(
"must specify a connecting service for docker-compose".to_string(),
));
}
Ok(())
}
DevContainerBuildType::None => Ok(()),
}
}
}
// Custom deserializer that parses the entire customizations object as a
@@ -1477,4 +1503,110 @@ mod test {
assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached");
}
#[test]
fn should_fail_validation_with_workspace_mount_only() {
let given_image_container_json = r#"
// These are some external comments. serde_lenient should handle them
{
// These are some internal comments
"build": {
"dockerfile": "Dockerfile",
},
"name": "myDevContainer",
"workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
"customizations": {
"vscode": {
// Just confirm that this can be included and ignored
},
"zed": {
"extensions": [
"html"
]
}
}
}
"#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok());
let devcontainer = result.expect("ok");
assert_eq!(
devcontainer.validate_devcontainer_contents(),
Err(DevContainerError::DevContainerValidationFailed(
"workspaceMount and workspaceFolder must both be defined, or neither defined"
.to_string()
))
);
}
#[test]
fn should_fail_validation_with_workspace_folder_only() {
let given_image_container_json = r#"
// These are some external comments. serde_lenient should handle them
{
// These are some internal comments
"build": {
"dockerfile": "Dockerfile",
},
"name": "myDevContainer",
"workspaceFolder": "/workspaces",
"customizations": {
"vscode": {
// Just confirm that this can be included and ignored
},
"zed": {
"extensions": [
"html"
]
}
}
}
"#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok());
let devcontainer = result.expect("ok");
assert_eq!(
devcontainer.validate_devcontainer_contents(),
Err(DevContainerError::DevContainerValidationFailed(
"workspaceMount and workspaceFolder must both be defined, or neither defined"
.to_string()
))
);
}
#[test]
fn should_pass_validation_with_workspace_folder_for_docker_compose() {
let given_image_container_json = r#"
// These are some external comments. serde_lenient should handle them
{
// These are some internal comments
"dockerComposeFile": "docker-compose-plain.yml",
"service": "app",
"name": "myDevContainer",
"workspaceFolder": "/workspaces",
"customizations": {
"vscode": {
// Just confirm that this can be included and ignored
},
"zed": {
"extensions": [
"html"
]
}
}
}
"#;
let result = deserialize_devcontainer_json(given_image_container_json);
assert!(result.is_ok());
let devcontainer = result.expect("ok");
assert!(devcontainer.validate_devcontainer_contents().is_ok());
}
}
+261 -48
View File
@@ -10,7 +10,7 @@ use regex::Regex;
use fs::Fs;
use http_client::HttpClient;
use util::{ResultExt, command::Command};
use util::{ResultExt, command::Command, normalize_path};
use crate::{
DevContainerConfig, DevContainerContext,
@@ -23,7 +23,6 @@ use crate::{
docker::{
Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
get_remote_dir_from_config,
},
features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref},
get_oci_token,
@@ -57,7 +56,7 @@ struct DevContainerManifest {
features_build_info: Option<FeaturesBuildInfo>,
features: Vec<FeatureManifest>,
}
const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces/";
const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces";
impl DevContainerManifest {
async fn new(
context: &DevContainerContext,
@@ -231,10 +230,14 @@ impl DevContainerManifest {
else {
return None;
};
main_service
.build
.and_then(|b| b.dockerfile)
.map(|dockerfile| self.config_directory.join(dockerfile))
main_service.build.and_then(|b| {
let compose_file = docker_compose_manifest.files.first()?;
resolve_compose_dockerfile(
compose_file,
b.context.as_deref(),
b.dockerfile.as_deref()?,
)
})
}
DevContainerBuildType::None => None,
}
@@ -768,17 +771,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
};
let remote_user = get_remote_user_from_config(&running_container, self)?;
let remote_workspace_folder = get_remote_dir_from_config(
&running_container,
(&self.local_project_directory.display()).to_string(),
)?;
let remote_workspace_folder = self.remote_workspace_folder()?;
let remote_env = self.runtime_remote_env(&running_container.config.env_as_map()?)?;
Ok(DevContainerUp {
container_id: running_container.id,
remote_user,
remote_workspace_folder,
remote_workspace_folder: remote_workspace_folder.display().to_string(),
extension_ids: self.extension_ids(),
remote_env,
})
@@ -1712,7 +1712,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
.as_ref()
.map(|folder| PathBuf::from(folder))
.or(Some(
PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).join(self.local_workspace_base_name()?),
// We explicitly use "/" here, instead of PathBuf::join
// because we want remote targets to use unix-style filepaths,
// even on a Windows host
PathBuf::from(format!(
"{}/{}",
DEFAULT_REMOTE_PROJECT_DIR,
self.local_workspace_base_name()?
)),
))
.ok_or(DevContainerError::DevContainerParseFailed)
}
@@ -1734,7 +1741,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
Ok(MountDefinition {
source: Some(self.local_workspace_folder()),
target: format!("/workspaces/{}", project_directory_name.display()),
// We explicitly use "/" here, instead of PathBuf::join
// because we want the remote target to use unix-style filepaths,
// even on a Windows host
target: format!(
"{}/{}",
PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).display(),
project_directory_name.display()
),
mount_type: None,
})
}
@@ -1754,11 +1768,36 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
command.arg("--privileged");
}
if &docker_cli == "podman" {
command.args(&["--security-opt", "label=disable", "--userns=keep-id"]);
let run_args = match &self.dev_container().run_args {
Some(run_args) => run_args,
None => &Vec::new(),
};
for arg in run_args {
command.arg(arg);
}
command.arg("--sig-proxy=false");
let run_if_missing = {
|arg_name: &str, arg: &str, command: &mut Command| {
if !run_args
.iter()
.any(|arg| arg.strip_prefix(arg_name).is_some())
{
command.arg(arg);
}
}
};
if &docker_cli == "podman" {
run_if_missing(
"--security-opt",
"--security-opt=label=disable",
&mut command,
);
run_if_missing("--userns", "--userns=keep-id", &mut command);
}
run_if_missing("--sig-proxy", "--sig-proxy=false", &mut command);
command.arg("-d");
command.arg("--mount");
command.arg(remote_workspace_mount.to_string());
@@ -1818,6 +1857,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
}
async fn build_and_run(&mut self) -> Result<DevContainerUp, DevContainerError> {
self.dev_container().validate_devcontainer_contents()?;
self.run_initialize_commands().await?;
self.download_feature_and_dockerfile_resources().await?;
@@ -1850,7 +1891,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
.run_docker_exec(
&devcontainer_up.container_id,
&remote_folder,
"root",
&devcontainer_up.remote_user,
&devcontainer_up.remote_env,
command,
)
@@ -1864,7 +1905,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
.run_docker_exec(
&devcontainer_up.container_id,
&remote_folder,
"root",
&devcontainer_up.remote_user,
&devcontainer_up.remote_env,
command,
)
@@ -1951,17 +1992,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
let remote_user = get_remote_user_from_config(&docker_inspect, self)?;
let remote_folder = get_remote_dir_from_config(
&docker_inspect,
(&self.local_project_directory.display()).to_string(),
)?;
let remote_folder = self.remote_workspace_folder()?;
let remote_env = self.runtime_remote_env(&docker_inspect.config.env_as_map()?)?;
let dev_container_up = DevContainerUp {
container_id: docker_ps.id,
remote_user: remote_user,
remote_workspace_folder: remote_folder,
remote_workspace_folder: remote_folder.display().to_string(),
extension_ids: self.extension_ids(),
remote_env,
};
@@ -2083,9 +2121,9 @@ pub(crate) async fn read_devcontainer_configuration(
environment: HashMap<String, String>,
) -> Result<DevContainer, DevContainerError> {
let docker = if context.use_podman {
Docker::new("podman")
Docker::new("podman").await
} else {
Docker::new("docker")
Docker::new("docker").await
};
let mut dev_container = DevContainerManifest::new(
context,
@@ -2107,9 +2145,9 @@ pub(crate) async fn spawn_dev_container(
local_project_path: &Path,
) -> Result<DevContainerUp, DevContainerError> {
let docker = if context.use_podman {
Docker::new("podman")
Docker::new("podman").await
} else {
Docker::new("docker")
Docker::new("docker").await
};
let mut devcontainer_manifest = DevContainerManifest::new(
context,
@@ -2164,6 +2202,33 @@ fn find_primary_service(
}
}
/// Resolves a compose service's dockerfile path according to the Docker Compose spec:
/// `dockerfile` is relative to the build `context`, and `context` is relative to
/// the compose file's directory.
fn resolve_compose_dockerfile(
compose_file: &Path,
context: Option<&str>,
dockerfile: &str,
) -> Option<PathBuf> {
let dockerfile = PathBuf::from(dockerfile);
if dockerfile.is_absolute() {
return Some(dockerfile);
}
let compose_dir = compose_file.parent()?;
let context_dir = match context {
Some(ctx) => {
let ctx = PathBuf::from(ctx);
if ctx.is_absolute() {
ctx
} else {
normalize_path(&compose_dir.join(ctx))
}
}
None => compose_dir.to_path_buf(),
};
Some(context_dir.join(dockerfile))
}
/// Destination folder inside the container where feature content is staged during build.
/// Mirrors the CLI's `FEATURES_CONTAINER_TEMP_DEST_FOLDER`.
const FEATURES_CONTAINER_TEMP_DEST_FOLDER: &str = "/tmp/dev-container-features";
@@ -2346,8 +2411,6 @@ fn image_from_dockerfile(dockerfile_contents: String, target: &Option<String>) -
})
}
// Container user things
// This should come from spec - see the docs
fn get_remote_user_from_config(
docker_config: &DockerInspect,
devcontainer: &DevContainerManifest,
@@ -2405,7 +2468,7 @@ mod test {
use std::{
collections::HashMap,
ffi::OsStr,
path::PathBuf,
path::{Path, PathBuf},
process::{ExitStatus, Output},
sync::{Arc, Mutex},
};
@@ -2431,7 +2494,7 @@ mod test {
devcontainer_manifest::{
ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources,
DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config,
image_from_dockerfile,
image_from_dockerfile, resolve_compose_dockerfile,
},
docker::{
DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
@@ -2440,7 +2503,10 @@ mod test {
},
oci::TokenResponse,
};
#[cfg(not(target_os = "windows"))]
const TEST_PROJECT_PATH: &str = "/path/to/local/project";
#[cfg(target_os = "windows")]
const TEST_PROJECT_PATH: &str = r#"C:\\path\to\local\project"#;
async fn build_tarball(content: Vec<(&str, &str)>) -> Vec<u8> {
let buffer = futures::io::Cursor::new(Vec::new());
@@ -2661,8 +2727,14 @@ mod test {
serde_json_lenient::Value::String("vsCode".to_string()),
);
let (_, devcontainer_manifest) =
init_default_devcontainer_manifest(cx, "{}").await.unwrap();
let (_, devcontainer_manifest) = init_default_devcontainer_manifest(
cx,
r#"{
"name": "TODO"
}"#,
)
.await
.unwrap();
let build_resources = DockerBuildResources {
image: DockerInspect {
id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(),
@@ -2695,11 +2767,11 @@ mod test {
OsStr::new("--sig-proxy=false"),
OsStr::new("-d"),
OsStr::new("--mount"),
OsStr::new(
"type=bind,source=/path/to/local/project,target=/workspaces/project,consistency=cached"
),
OsStr::new(&format!(
"type=bind,source={TEST_PROJECT_PATH},target=/workspaces/project,consistency=cached"
)),
OsStr::new("-l"),
OsStr::new("devcontainer.local_folder=/path/to/local/project"),
OsStr::new(&format!("devcontainer.local_folder={TEST_PROJECT_PATH}")),
OsStr::new("-l"),
OsStr::new(&format!(
"devcontainer.config_file={expected_config_file_label}"
@@ -2875,7 +2947,8 @@ mod test {
.remote_env
.as_ref()
.and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
Some(&TEST_PROJECT_PATH.to_string())
// We replace backslashes with forward slashes during variable replacement for JSON safety
Some(&TEST_PROJECT_PATH.replace("\\", "/"))
);
// ${localEnv:VARIABLE_NAME}
@@ -2978,7 +3051,8 @@ mod test {
.remote_env
.as_ref()
.and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
Some(&TEST_PROJECT_PATH.to_string())
// We replace backslashes with forward slashes during variable replacement for JSON safety
Some(&TEST_PROJECT_PATH.replace("\\", "/"))
);
}
@@ -3011,6 +3085,11 @@ mod test {
"source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
],
"runArgs": [
"--cap-add=SYS_PTRACE",
"--sig-proxy=true",
],
"forwardPorts": [
8082,
8083,
@@ -3303,7 +3382,8 @@ chmod +x ./install.sh
vec![
"run".to_string(),
"--privileged".to_string(),
"--sig-proxy=false".to_string(),
"--cap-add=SYS_PTRACE".to_string(),
"--sig-proxy=true".to_string(),
"-d".to_string(),
"--mount".to_string(),
"type=bind,source=/path/to/local/project,target=/workspace2,consistency=cached".to_string(),
@@ -3674,6 +3754,74 @@ ENV DOCKER_BUILDKIT=1
)
}
#[test]
fn test_resolve_compose_dockerfile() {
let compose = Path::new("/project/.devcontainer/docker-compose.yml");
// Bug case (#53473): context ".." with relative dockerfile
assert_eq!(
resolve_compose_dockerfile(compose, Some(".."), ".devcontainer/Dockerfile"),
Some(PathBuf::from("/project/.devcontainer/Dockerfile")),
);
// Compose path containing ".." (as docker_compose_manifest() produces)
assert_eq!(
resolve_compose_dockerfile(
Path::new("/project/.devcontainer/../docker-compose.yml"),
Some("."),
"docker/Dockerfile",
),
Some(PathBuf::from("/project/docker/Dockerfile")),
);
// Absolute dockerfile returned as-is
assert_eq!(
resolve_compose_dockerfile(compose, Some("."), "/absolute/Dockerfile"),
Some(PathBuf::from("/absolute/Dockerfile")),
);
// Absolute context used directly
assert_eq!(
resolve_compose_dockerfile(compose, Some("/abs/context"), "Dockerfile"),
Some(PathBuf::from("/abs/context/Dockerfile")),
);
// No context defaults to compose file's directory
assert_eq!(
resolve_compose_dockerfile(compose, None, "Dockerfile"),
Some(PathBuf::from("/project/.devcontainer/Dockerfile")),
);
}
#[gpui::test]
async fn test_dockerfile_location_with_compose_context_parent(cx: &mut TestAppContext) {
cx.executor().allow_parking();
env_logger::try_init().ok();
let given_devcontainer_contents = r#"
{
"name": "Test",
"dockerComposeFile": "docker-compose-context-parent.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}"
}
"#;
let (_, mut devcontainer_manifest) =
init_default_devcontainer_manifest(cx, given_devcontainer_contents)
.await
.unwrap();
devcontainer_manifest.parse_nonremote_vars().unwrap();
let expected = PathBuf::from(TEST_PROJECT_PATH)
.join(".devcontainer")
.join("Dockerfile");
assert_eq!(
devcontainer_manifest.dockerfile_location().await,
Some(expected)
);
}
#[gpui::test]
async fn test_spawns_devcontainer_with_docker_compose_and_no_update_uid(
cx: &mut TestAppContext,
@@ -4437,6 +4585,33 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
);
}
#[cfg(target_os = "windows")]
#[gpui::test]
async fn test_spawns_devcontainer_with_plain_image(cx: &mut TestAppContext) {
cx.executor().allow_parking();
env_logger::try_init().ok();
let given_devcontainer_contents = r#"
{
"name": "cli-${devcontainerId}",
"image": "test_image:latest",
}
"#;
let (_, mut devcontainer_manifest) =
init_default_devcontainer_manifest(cx, given_devcontainer_contents)
.await
.unwrap();
devcontainer_manifest.parse_nonremote_vars().unwrap();
let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
assert_eq!(
devcontainer_up.remote_workspace_folder,
"/workspaces/project"
);
}
#[cfg(not(target_os = "windows"))]
#[gpui::test]
async fn test_spawns_devcontainer_with_docker_compose_and_plain_image(cx: &mut TestAppContext) {
@@ -4747,12 +4922,14 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
pub(crate) struct FakeDocker {
exec_commands_recorded: Mutex<Vec<RecordedExecCommand>>,
podman: bool,
has_buildx: bool,
}
impl FakeDocker {
pub(crate) fn new() -> Self {
Self {
podman: false,
has_buildx: true,
exec_commands_recorded: Mutex::new(Vec::new()),
}
}
@@ -4883,11 +5060,14 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
&self,
config_files: &Vec<PathBuf>,
) -> Result<Option<DockerComposeConfig>, DevContainerError> {
let project_path = PathBuf::from(TEST_PROJECT_PATH);
if config_files.len() == 1
&& config_files.get(0)
== Some(&PathBuf::from(
"/path/to/local/project/.devcontainer/docker-compose.yml",
))
== Some(
&project_path
.join(".devcontainer")
.join("docker-compose.yml"),
)
{
return Ok(Some(DockerComposeConfig {
name: None,
@@ -4933,9 +5113,42 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
}
if config_files.len() == 1
&& config_files.get(0)
== Some(&PathBuf::from(
"/path/to/local/project/.devcontainer/docker-compose-plain.yml",
))
== Some(
&project_path
.join(".devcontainer")
.join("docker-compose-context-parent.yml"),
)
{
return Ok(Some(DockerComposeConfig {
name: None,
services: HashMap::from([(
"app".to_string(),
DockerComposeService {
build: Some(DockerComposeServiceBuild {
context: Some("..".to_string()),
dockerfile: Some(
PathBuf::from(".devcontainer")
.join("Dockerfile")
.display()
.to_string(),
),
args: None,
additional_contexts: None,
target: None,
}),
..Default::default()
},
)]),
volumes: HashMap::new(),
}));
}
if config_files.len() == 1
&& config_files.get(0)
== Some(
&project_path
.join(".devcontainer")
.join("docker-compose-plain.yml"),
)
{
return Ok(Some(DockerComposeConfig {
name: None,
@@ -4992,7 +5205,7 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
}))
}
fn supports_compose_buildkit(&self) -> bool {
!self.podman
!self.podman && self.has_buildx
}
fn docker_cli(&self) -> String {
if self.podman {
+63 -289
View File
@@ -57,8 +57,8 @@ impl DockerInspectConfig {
let mut map = HashMap::new();
for env_var in &self.env {
let Some((key, value)) = env_var.split_once('=') else {
log::error!("Unable to parse {env_var} into an environment key-value");
return Err(DevContainerError::DevContainerParseFailed);
log::warn!("Skipping environment variable without a value: {env_var}");
continue;
};
map.insert(key.to_string(), value.to_string());
}
@@ -175,6 +175,7 @@ pub(crate) struct DockerComposeConfig {
pub(crate) struct Docker {
docker_cli: String,
has_buildx: bool,
}
impl DockerInspect {
@@ -184,9 +185,24 @@ impl DockerInspect {
}
impl Docker {
pub(crate) fn new(docker_cli: &str) -> Self {
pub(crate) async fn new(docker_cli: &str) -> Self {
let has_buildx = if docker_cli == "podman" {
false
} else {
let output = Command::new(docker_cli)
.args(["buildx", "version"])
.output()
.await;
output.map(|o| o.status.success()).unwrap_or(false)
};
if !has_buildx && docker_cli != "podman" {
log::info!(
"docker buildx not found; dev container builds will use the scratch-image fallback"
);
}
Self {
docker_cli: docker_cli.to_string(),
has_buildx,
}
}
@@ -372,7 +388,7 @@ impl DockerClient for Docker {
}
fn supports_compose_buildkit(&self) -> bool {
!self.is_podman()
self.has_buildx
}
}
@@ -506,36 +522,6 @@ where
}
}
pub(crate) fn get_remote_dir_from_config(
config: &DockerInspect,
local_dir: String,
) -> Result<String, DevContainerError> {
let local_path = PathBuf::from(&local_dir);
let Some(mounts) = &config.mounts else {
log::error!("No mounts defined for container");
return Err(DevContainerError::ContainerNotValid(config.id.clone()));
};
for mount in mounts {
// Sometimes docker will mount the local filesystem on host_mnt for system isolation
let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
let remote_dir = format!(
"{}/{}",
&mount.destination,
relative_path_to_project.display()
);
return Ok(remote_dir);
}
if mount.source == local_dir {
return Ok(mount.destination.clone());
}
}
log::error!("No mounts to local folder");
Err(DevContainerError::ContainerNotValid(config.id.clone()))
}
#[cfg(test)]
mod test {
use std::{
@@ -549,7 +535,7 @@ mod test {
devcontainer_json::MountDefinition,
docker::{
Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
DockerComposeVolume, DockerInspect, DockerPs,
},
};
@@ -577,6 +563,44 @@ mod test {
assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
}
#[test]
fn should_parse_database_url_with_equals_in_query_string() {
let config = super::DockerInspectConfig {
labels: super::DockerConfigLabels { metadata: None },
image_user: None,
env: vec![
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
"TEST_DATABASE_URL=postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
.to_string(),
],
};
let map = config.env_as_map().unwrap();
assert_eq!(
map.get("TEST_DATABASE_URL").unwrap(),
"postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
);
}
#[test]
fn should_skip_env_var_without_equals() {
let config = super::DockerInspectConfig {
labels: super::DockerConfigLabels { metadata: None },
image_user: None,
env: vec![
"VALID_KEY=valid_value".to_string(),
"NO_EQUALS_VAR".to_string(),
"ANOTHER_VALID=value".to_string(),
],
};
let map = config.env_as_map().unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("VALID_KEY").unwrap(), "valid_value");
assert_eq!(map.get("ANOTHER_VALID").unwrap(), "value");
assert!(!map.contains_key("NO_EQUALS_VAR"));
}
#[test]
fn should_parse_simple_label() {
let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
@@ -595,7 +619,10 @@ mod test {
#[test]
fn should_create_docker_inspect_command() {
let docker = Docker::new("docker");
let docker = Docker {
docker_cli: "docker".to_string(),
has_buildx: false,
};
let given_id = "given_docker_id";
let command = docker.create_docker_inspect(given_id);
@@ -691,259 +718,6 @@ mod test {
assert_eq!(result.id, "abdb6ab59573".to_string());
}
#[test]
fn should_get_target_dir_from_docker_inspect() {
let given_config = r#"
{
"Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
"Created": "2026-02-04T23:44:21.802688084Z",
"Path": "/bin/sh",
"Args": [
"-c",
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
"-"
],
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 23087,
"ExitCode": 0,
"Error": "",
"StartedAt": "2026-02-04T23:44:21.954875084Z",
"FinishedAt": "0001-01-01T00:00:00Z"
},
"Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
"ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
"HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
"LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
"Name": "/objective_haslett",
"RestartCount": 0,
"Driver": "overlayfs",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "",
"ExecIDs": [
"008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
],
"HostConfig": {
"Binds": null,
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {}
},
"NetworkMode": "bridge",
"PortBindings": {},
"RestartPolicy": {
"Name": "no",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"ConsoleSize": [
0,
0
],
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "private",
"Dns": [],
"DnsOptions": [],
"DnsSearch": [],
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": [],
"BlkioDeviceReadBps": [],
"BlkioDeviceWriteBps": [],
"BlkioDeviceReadIOps": [],
"BlkioDeviceWriteIOps": [],
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DeviceRequests": null,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": null,
"PidsLimit": null,
"Ulimits": [],
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"Mounts": [
{
"Type": "bind",
"Source": "/somepath/cli",
"Target": "/workspaces/cli",
"Consistency": "cached"
}
],
"MaskedPaths": [
"/proc/asound",
"/proc/acpi",
"/proc/interrupts",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware",
"/sys/devices/virtual/powercap"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"Mounts": [
{
"Type": "bind",
"Source": "/somepath/cli",
"Destination": "/workspaces/cli",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "abdb6ab59573",
"Domainname": "",
"User": "root",
"AttachStdin": false,
"AttachStdout": true,
"AttachStderr": true,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"-c",
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
"-"
],
"Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": [
"/bin/sh"
],
"OnBuild": null,
"Labels": {
"dev.containers.features": "common",
"dev.containers.id": "base-ubuntu",
"dev.containers.release": "v0.4.24",
"dev.containers.source": "https://github.com/devcontainers/images",
"dev.containers.timestamp": "Fri, 30 Jan 2026 16:52:34 GMT",
"dev.containers.variant": "noble",
"devcontainer.config_file": "/somepath/cli/.devcontainer/dev_container_2/devcontainer.json",
"devcontainer.local_folder": "/somepath/cli",
"devcontainer.metadata": "[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}]",
"org.opencontainers.image.ref.name": "ubuntu",
"org.opencontainers.image.version": "24.04",
"version": "2.1.6"
},
"StopTimeout": 1
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
"SandboxKey": "/var/run/docker/netns/2a94990d542f",
"Ports": {},
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
"Gateway": "172.17.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"MacAddress": "",
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"MacAddress": "9a:ec:af:8a:ac:81",
"DriverOpts": null,
"GwPriority": 0,
"NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
"EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": null
}
}
},
"ImageManifestDescriptor": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
"size": 2195,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
}
"#;
let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
assert!(target_dir.is_ok());
assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
}
#[test]
fn should_deserialize_object_metadata_from_docker_compose_container() {
// The devcontainer CLI writes metadata as a bare JSON object (not an array)
+1
View File
@@ -31,6 +31,7 @@ credentials_provider.workspace = true
db.workspace = true
edit_prediction_types.workspace = true
edit_prediction_context.workspace = true
edit_prediction_metrics.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
+25 -9
View File
@@ -3,12 +3,14 @@ use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, globa
use cloud_api_client::LlmApiToken;
use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody};
use cloud_llm_client::predict_edits_v3::{
PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
PREDICT_EDITS_MODE_HEADER_NAME, PredictEditsMode, PredictEditsV3Request,
PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
};
use cloud_llm_client::{
EditPredictionRejectReason, EditPredictionRejection,
MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, MINIMUM_REQUIRED_VERSION_HEADER_NAME,
PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, ZED_VERSION_HEADER_NAME,
PREFERRED_EXPERIMENT_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef,
ZED_VERSION_HEADER_NAME,
};
use collections::{HashMap, HashSet};
use copilot::{Copilot, Reinstall, SignIn, SignOut};
@@ -29,9 +31,10 @@ use gpui::{
prelude::*,
};
use heapless::Vec as ArrayVec;
use language::language_settings::all_language_settings;
use language::{Anchor, Buffer, EditPreview, File, Point, TextBufferSnapshot, ToOffset, ToPoint};
use language::{BufferSnapshot, OffsetRangeExt};
use language::{
Anchor, Buffer, BufferSnapshot, EditPredictionsMode, EditPreview, File, OffsetRangeExt, Point,
TextBufferSnapshot, ToOffset, ToPoint, language_settings::all_language_settings,
};
use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
use release_channel::AppVersion;
use semver::Version;
@@ -176,6 +179,7 @@ pub struct EditPredictionModelInput {
position: Anchor,
events: Vec<Arc<zeta_prompt::Event>>,
related_files: Vec<RelatedFile>,
mode: PredictEditsMode,
trigger: PredictEditsRequestTrigger,
diagnostic_search_range: Range<Point>,
debug_tx: Option<mpsc::UnboundedSender<DebugEvent>>,
@@ -2366,6 +2370,10 @@ impl EditPredictionStore {
Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0);
let related_files = self.context_for_project(&project, cx);
let mode = match all_language_settings(snapshot.file(), cx).edit_predictions_mode() {
EditPredictionsMode::Eager => PredictEditsMode::Eager,
EditPredictionsMode::Subtle => PredictEditsMode::Subtle,
};
let is_open_source = snapshot
.file()
@@ -2377,7 +2385,6 @@ impl EditPredictionStore {
&& is_open_source
&& self.is_data_collection_enabled(cx)
&& matches!(self.edit_prediction_model, EditPredictionModel::Zeta);
let inputs = EditPredictionModelInput {
project: project.clone(),
buffer: active_buffer,
@@ -2385,8 +2392,9 @@ impl EditPredictionStore {
position,
events,
related_files,
mode,
trigger,
diagnostic_search_range: diagnostic_search_range,
diagnostic_search_range,
debug_tx,
can_collect_data,
is_open_source,
@@ -2579,11 +2587,13 @@ impl EditPredictionStore {
pub(crate) async fn send_v3_request(
input: ZetaPromptInput,
preferred_experiment: Option<String>,
client: Arc<Client>,
llm_token: LlmApiToken,
organization_id: Option<OrganizationId>,
app_version: Version,
trigger: PredictEditsRequestTrigger,
mode: PredictEditsMode,
) -> Result<(PredictEditsV3Response, Option<EditPredictionUsage>)> {
let url = client
.http_client()
@@ -2596,10 +2606,16 @@ impl EditPredictionStore {
Self::send_api_request(
|builder| {
let req = builder
let builder = builder
.uri(url.as_ref())
.header("Content-Encoding", "zstd")
.body(compressed.clone().into());
.header(PREDICT_EDITS_MODE_HEADER_NAME, mode.as_ref());
let builder = if let Some(preferred_experiment) = preferred_experiment.as_deref() {
builder.header(PREFERRED_EXPERIMENT_HEADER_NAME, preferred_experiment)
} else {
builder
};
let req = builder.body(compressed.clone().into());
Ok(req?)
},
client,
@@ -2481,7 +2481,6 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
excerpt_start_row: None,
excerpt_ranges: Default::default(),
syntax_ranges: None,
experiment: None,
in_open_source_repo: false,
can_collect_data: false,
repo_url: None,
-1
View File
@@ -87,7 +87,6 @@ pub fn request_prediction(
cursor_excerpt,
excerpt_ranges: Default::default(),
syntax_ranges: None,
experiment: None,
in_open_source_repo: false,
can_collect_data: false,
repo_url: None,
@@ -319,7 +319,7 @@ impl LicenseDetectionWatcher {
}
worktree::Event::DeletedEntry(_)
| worktree::Event::UpdatedGitRepositories(_)
| worktree::Event::UpdatedRootRepoCommonDir
| worktree::Event::UpdatedRootRepoCommonDir { .. }
| worktree::Event::Deleted => {}
});
-1
View File
@@ -109,7 +109,6 @@ impl Mercury {
- excerpt_offset_range.start,
cursor_path: full_path.clone(),
cursor_excerpt,
experiment: None,
excerpt_start_row: Some(excerpt_point_range.start.row),
excerpt_ranges,
syntax_ranges: Some(syntax_ranges),
+7 -9
View File
@@ -1,10 +1,8 @@
mod kept_rate;
mod tokenize;
mod tree_sitter;
pub use edit_prediction_metrics::KeptRateResult;
pub use kept_rate::KeptRateResult;
#[cfg(test)]
pub use kept_rate::TokenAnnotation;
pub use kept_rate::compute_kept_rate;
pub(crate) use tokenize::tokenize;
pub use tree_sitter::count_tree_sitter_errors;
pub use edit_prediction_metrics::compute_kept_rate;
use language::SyntaxLayer;
pub fn count_tree_sitter_errors<'a>(layers: impl Iterator<Item = SyntaxLayer<'a>>) -> usize {
edit_prediction_metrics::count_tree_sitter_errors(layers.map(|layer| layer.node()))
}
@@ -1,88 +0,0 @@
use language::SyntaxLayer;
pub fn count_tree_sitter_errors<'a>(layers: impl Iterator<Item = SyntaxLayer<'a>>) -> usize {
let mut total_count: usize = 0;
for layer in layers {
let node = layer.node();
let mut cursor = node.walk();
'layer: loop {
let current = cursor.node();
if current.is_error() || current.is_missing() {
total_count += 1;
}
if current.has_error() && cursor.goto_first_child() {
continue;
}
if cursor.goto_next_sibling() {
continue;
}
loop {
if !cursor.goto_parent() {
break 'layer;
}
if cursor.goto_next_sibling() {
continue;
}
}
}
}
total_count
}
#[cfg(test)]
mod tests {
use std::ops::Range;
use super::count_tree_sitter_errors;
use gpui::{AppContext as _, TestAppContext};
use language::{Buffer, BufferSnapshot, rust_lang};
fn error_count_in_range(edited_buffer_snapshot: &BufferSnapshot, range: Range<usize>) -> usize {
let layers = edited_buffer_snapshot.syntax_layers_for_range(range, true);
count_tree_sitter_errors(layers)
}
fn rust_snapshot(text: &str, cx: &mut TestAppContext) -> BufferSnapshot {
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx));
while buffer.read_with(cx, |buffer, _| buffer.is_parsing()) {
cx.run_until_parked();
}
buffer.read_with(cx, |buffer, _| buffer.snapshot())
}
#[gpui::test]
async fn counts_no_errors_for_valid_rust(cx: &mut TestAppContext) {
let text = "fn helper(value: usize) -> usize {\n value + 1\n}\n";
let snapshot = rust_snapshot(text, cx);
assert_eq!(error_count_in_range(&snapshot, 0..snapshot.text.len()), 0);
}
#[gpui::test]
async fn counts_errors_for_invalid_rust(cx: &mut TestAppContext) {
let text = "fn helper(value: usize) -> usize {\n let total = ;\n total\n}\n";
let snapshot = rust_snapshot(text, cx);
assert_eq!(error_count_in_range(&snapshot, 0..snapshot.text.len()), 1);
}
#[gpui::test]
async fn counts_no_errors_for_subrange_of_valid_rust(cx: &mut TestAppContext) {
let text = "fn first() -> usize {\n let value = 1;\n value + 1\n}\n";
let snapshot = rust_snapshot(text, cx);
let body_start = text.find("let value").unwrap();
let body_end = body_start + "let value = 1;".len();
assert_eq!(error_count_in_range(&snapshot, body_start..body_end), 0);
}
#[gpui::test]
async fn counts_errors_for_subrange_of_invalid_rust(cx: &mut TestAppContext) {
let text = "fn second() -> usize {\n let broken = ;\n broken\n}\n";
let snapshot = rust_snapshot(text, cx);
let error_start = text.find("let broken = ;").unwrap();
let error_end = error_start + "let broken = ;".len();
assert_eq!(error_count_in_range(&snapshot, error_start..error_end), 1);
}
}
-1
View File
@@ -155,7 +155,6 @@ mod tests {
excerpt_start_row: None,
excerpt_ranges: Default::default(),
syntax_ranges: None,
experiment: None,
in_open_source_repo: false,
can_collect_data: false,
repo_url: None,
+3 -3
View File
@@ -43,6 +43,7 @@ pub fn request_prediction_with_zeta(
related_files,
events,
debug_tx,
mode,
trigger,
project,
diagnostic_search_range,
@@ -119,7 +120,6 @@ pub fn request_prediction_with_zeta(
diagnostic_search_range,
excerpt_path,
cursor_offset,
preferred_experiment,
is_open_source,
can_collect_data,
repo_url,
@@ -273,11 +273,13 @@ pub fn request_prediction_with_zeta(
// Use V3 endpoint - server handles model/version selection and suffix stripping
let (response, usage) = EditPredictionStore::send_v3_request(
prompt_input.clone(),
preferred_experiment.clone(),
client,
llm_token,
organization_id,
app_version,
trigger,
mode,
)
.await?;
@@ -533,7 +535,6 @@ pub fn zeta2_prompt_input(
diagnostic_search_range: Range<Point>,
excerpt_path: Arc<Path>,
cursor_offset: usize,
preferred_experiment: Option<String>,
is_open_source: bool,
can_collect_data: bool,
repo_url: Option<String>,
@@ -565,7 +566,6 @@ pub fn zeta2_prompt_input(
active_buffer_diagnostics,
excerpt_ranges,
syntax_ranges: Some(syntax_ranges),
experiment: preferred_experiment,
in_open_source_repo: is_open_source,
can_collect_data,
repo_url,
+1
View File
@@ -61,6 +61,7 @@ terminal_view.workspace = true
util.workspace = true
watch.workspace = true
edit_prediction = { workspace = true, features = ["cli-support"] }
edit_prediction_metrics.workspace = true
telemetry_events.workspace = true
wasmtime.workspace = true
zeta_prompt.workspace = true
@@ -108,7 +108,6 @@ pub async fn run_load_project(
syntax_ranges: Some(syntax_ranges),
in_open_source_repo: false,
can_collect_data: false,
experiment: None,
repo_url: None,
},
language_name,

Some files were not shown because too many files have changed in this diff Show More