Merge remote-tracking branch 'upstream/main' into feat/global-menu

This commit is contained in:
Nihal 2026-05-08 21:22:33 +05:30
commit bd25a6c67d
No known key found for this signature in database
GPG key ID: F03BA2AF8A4121C8
400 changed files with 25183 additions and 16195 deletions

207
Cargo.lock generated
View file

@ -3571,7 +3571,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
"async-process",
"async-trait",
"base64 0.22.1",
"collections",
@ -3960,36 +3959,36 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb1ffe339f197d6645b4d3037edf67c13cd3aa8871f29c2c9c046c729c1b9a17"
checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e81a21df73d1b12ed19eba481c08de8891e179e1870ed28d6e397f7746108f5"
checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cf917d0180c15c945c13c8dde615d32a015769513b29158f728311d85a8f80d"
checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6f4e1af2df00798c2895d228bb53d65c5aa09acace8525096f0b53830ffe42c"
checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086"
dependencies = [
"serde",
"serde_derive",
@ -3997,9 +3996,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a5d7300e4b44933dcf2947399945abe3f30f92c789b496ad72949e3ee15a6"
checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@ -4027,9 +4026,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "becdb5c3111800d7f8e666fe5f35693bfc77de4401bfcaea19815caf7c482fb9"
checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@ -4040,24 +4039,24 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8fa77efffa12934971f757e154b16dd5e369a7f388a0f3adff74aadfd4c5a1d"
checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac"
[[package]]
name = "cranelift-control"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62441d3aae3372381e03a121880482158ce90ca3bc2a56607cc122ee07536fe4"
checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bdc9832a010e0d411439aa016e1664dd23ca5c8953bf26b90fe34ad4b76822d"
checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a"
dependencies = [
"cranelift-bitset",
"serde",
@ -4066,9 +4065,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9530b689b7c3accdbb32263ca318e19ab3bcf616d3a160c8456537c99b4c565b"
checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858"
dependencies = [
"cranelift-codegen",
"log",
@ -4078,15 +4077,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fcd3258a4d87376f2681c72269a42009286a3d3707b2af4024ba5b3750ad477"
checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a"
[[package]]
name = "cranelift-native"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "642c5703a22b58abccbf46f46c0dae65f0535bbe725beec70527a1ffcbbc1d34"
checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830"
dependencies = [
"cranelift-codegen",
"libc",
@ -4095,9 +4094,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
version = "0.123.8"
version = "0.123.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d200dcd5a37de108ec1329e0ba924e2badd2c0ef2343c338310135159ae454e2"
checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40"
[[package]]
name = "crash-context"
@ -4942,6 +4941,7 @@ dependencies = [
"component",
"ctor",
"editor",
"futures-lite 1.13.0",
"gpui",
"indoc",
"itertools 0.14.0",
@ -5021,7 +5021,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5397,8 +5397,8 @@ dependencies = [
name = "edit_prediction_metrics"
version = "0.1.0"
dependencies = [
"imara-diff",
"indoc",
"language",
"pretty_assertions",
"serde",
"serde_json",
@ -5549,6 +5549,24 @@ dependencies = [
"ztracing",
]
[[package]]
name = "editor_benchmarks"
version = "0.1.0"
dependencies = [
"anyhow",
"editor",
"gpui",
"gpui_platform",
"language",
"multi_buffer",
"project",
"release_channel",
"semver",
"settings",
"theme",
"workspace",
]
[[package]]
name = "either"
version = "1.15.0"
@ -5811,7 +5829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -7269,7 +7287,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8599,7 +8617,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@ -8617,7 +8635,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.56.0",
"windows-core 0.57.0",
]
[[package]]
@ -11266,7 +11284,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -13881,9 +13899,9 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
[[package]]
name = "pulley-interpreter"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35eaba3163b9faf1d707f0704a7370bfdbe73622c766acdaf1fa4addb87510de"
checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683"
dependencies = [
"cranelift-bitset",
"log",
@ -13893,9 +13911,9 @@ dependencies = [
[[package]]
name = "pulley-macros"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac294897a29ce07919714f9f25c11a819d75759d47eb9f3273845ffea5a5760d"
checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42"
dependencies = [
"proc-macro2",
"quote",
@ -13998,7 +14016,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.40",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",
"tracing",
@ -14035,9 +14053,9 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@ -14919,9 +14937,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "1.3.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad"
dependencies = [
"async-trait",
"base64 0.22.1",
@ -14941,9 +14959,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "1.3.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43"
checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@ -15016,13 +15034,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rpassword"
version = "7.4.0"
version = "7.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -15253,7 +15271,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -16314,6 +16332,7 @@ dependencies = [
"git",
"gpui",
"http_client",
"itertools 0.14.0",
"language",
"language_model",
"log",
@ -17741,7 +17760,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -19936,9 +19955,9 @@ dependencies = [
[[package]]
name = "wasmtime"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2060d93be880840d764ab537464b916e22c07758ac5d43e5f07cc86fec6d1bec"
checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492"
dependencies = [
"addr2line",
"anyhow",
@ -19997,9 +20016,9 @@ dependencies = [
[[package]]
name = "wasmtime-environ"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "902f991ca8c2e5abc03119eb5d7f7f57da1b7c2123addb8214b49c188737711e"
checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f"
dependencies = [
"anyhow",
"cpp_demangle",
@ -20024,9 +20043,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-asm-macros"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b02cec619b54ce7652d1d7676718a42ccf5f16b2fb23c27cd6e3c307bc93907a"
checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93"
dependencies = [
"cfg-if",
]
@ -20043,9 +20062,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-macro"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad82a87bc24b6014c5271e1558e466fd029dcc80896f143b3693394a162f3be"
checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0"
dependencies = [
"anyhow",
"proc-macro2",
@ -20058,15 +20077,15 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-util"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bc24aba0bfd3d39fa8f0012835bc4d4efc75b1350b5e519181319eb8bb306b2"
checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e"
[[package]]
name = "wasmtime-internal-cranelift"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54eb7fc20c8692dc96148365d7a00a1b79fee810833c75bdf8ec073a46e4721a"
checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca"
dependencies = [
"anyhow",
"cfg-if",
@ -20091,9 +20110,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-fiber"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30708e122dcc1e175c66345c209c01752ca0cd20c9021721b6f56968342e9dbe"
checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f"
dependencies = [
"anyhow",
"cc",
@ -20107,9 +20126,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-debug"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1eeaab071a646d9ae205266adf186c63fa6d077d36b0b33628dd6c3d321d3195"
checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
@ -20117,9 +20136,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09979561e6e4a17bf55722463b066ccb968f010ac6ec5d647e4dff19eddbb19e"
checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028"
dependencies = [
"anyhow",
"cfg-if",
@ -20129,24 +20148,24 @@ dependencies = [
[[package]]
name = "wasmtime-internal-math"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9193eb852e5c68aeb95a5ea7538c2bec503023169a0b24430224b4f1ded24988"
checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480"
dependencies = [
"libm",
]
[[package]]
name = "wasmtime-internal-slab"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "289bfa4fbb43f406f36166737f1f25522c215ef2ef11f98423089a6a7590a3d1"
checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183"
[[package]]
name = "wasmtime-internal-unwinder"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e748c970993865d9bf474465c3f10f96e541c472bc8f7ec0b031779f4ac29c6"
checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639"
dependencies = [
"anyhow",
"cfg-if",
@ -20157,9 +20176,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e97e07438cb8b50df3bc9659c56757830a15235c94268dbbd54186524fd4ed84"
checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a"
dependencies = [
"proc-macro2",
"quote",
@ -20168,9 +20187,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-winch"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "107aa0c3f71cc590c786d6d6e09893558b383f4d78107b864a9fd978929d0244"
checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54"
dependencies = [
"anyhow",
"cranelift-codegen",
@ -20185,9 +20204,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeb3d8e4efdaae10aa01264e9946bba507e53707125dd0aa8584b5e13229a3c0"
checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339"
dependencies = [
"anyhow",
"bitflags 2.10.0",
@ -20198,9 +20217,9 @@ dependencies = [
[[package]]
name = "wasmtime-wasi"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86fffc455304d2750ea2456394cdf6513d8771eb5b256876685b8bb9413bfb0e"
checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0"
dependencies = [
"anyhow",
"async-trait",
@ -20229,9 +20248,9 @@ dependencies = [
[[package]]
name = "wasmtime-wasi-io"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5666a220e8318309225b54a55b270e1b506385adcce10bf5698380441afa0df3"
checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029"
dependencies = [
"anyhow",
"async-trait",
@ -20696,9 +20715,9 @@ dependencies = [
[[package]]
name = "wiggle"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e176546937d1311c7608276c8511d3ea9b8e7b916e89b720e12c4d4bbae067c"
checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792"
dependencies = [
"anyhow",
"async-trait",
@ -20711,9 +20730,9 @@ dependencies = [
[[package]]
name = "wiggle-generate"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3f012ad76133d9ac70633c7f954e289fb4c21986059f324fec3c476664ab643"
checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f"
dependencies = [
"anyhow",
"heck 0.5.0",
@ -20725,9 +20744,9 @@ dependencies = [
[[package]]
name = "wiggle-macro"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4301e6203d3d13eef139fa3aca5f04e9156b4a5f7636ca965b2c10bce410b3d2"
checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735"
dependencies = [
"proc-macro2",
"quote",
@ -20757,7 +20776,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@ -20768,9 +20787,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "36.0.8"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "646e2d01f59d7006e24a370762abfb63d5918696ff02197e027efd15252a1f79"
checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e"
dependencies = [
"anyhow",
"cranelift-assembler-x64",
@ -22379,7 +22398,7 @@ dependencies = [
[[package]]
name = "zed"
version = "1.2.0"
version = "1.3.0"
dependencies = [
"acp_thread",
"acp_tools",

View file

@ -61,6 +61,7 @@ members = [
"crates/edit_prediction_types",
"crates/edit_prediction_ui",
"crates/editor",
"crates/editor_benchmarks",
"crates/encoding_selector",
"crates/env_var",
"crates/etw_tracing",

View file

@ -1247,6 +1247,12 @@
"ctrl->": "agent::AddSelectionToThread",
},
},
{
"context": "AgentPanel && Terminal",
"bindings": {
"ctrl-n": "agent::NewThread",
},
},
{
"context": "ZedPredictModal",
"bindings": {
@ -1499,6 +1505,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes",
},
},

View file

@ -1316,6 +1316,13 @@
"cmd->": "agent::AddSelectionToThread",
},
},
{
"context": "AgentPanel > Terminal",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewThread",
},
},
{
"context": "RatePredictionsModal",
"use_key_equivalents": true,
@ -1552,6 +1559,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-shift-backspace": "branch_picker::DeleteBranch",
"cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"cmd-shift-i": "branch_picker::FilterRemotes",
},
},

View file

@ -1262,6 +1262,13 @@
"ctrl-shift-.": "agent::AddSelectionToThread",
},
},
{
"context": "AgentPanel > Terminal",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewThread",
},
},
{
"context": "Terminal && selection",
"bindings": {
@ -1479,6 +1486,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes",
},
},

View file

@ -527,6 +527,7 @@
"space w d": "pane::SplitDown", // not a helix default
// Space mode
"space b": "tab_switcher::ToggleAll",
"space f": "file_finder::Toggle",
"space k": "editor::Hover",
"space s": "outline::Toggle",

View file

@ -1110,6 +1110,7 @@
"diagnostics": true,
"apply_code_action": true,
"edit_file": true,
"write_file": true,
"fetch": true,
"find_path": true,
"find_references": true,
@ -1121,8 +1122,6 @@
"now": true,
"rename_symbol": true,
"read_file": true,
"restore_file_from_disk": true,
"save_file": true,
"open": true,
"grep": true,
"spawn_agent": true,
@ -2514,6 +2513,9 @@
"gdefault": false,
"highlight_on_yank_duration": 200,
"custom_digraphs": {},
// When enabled, edit predictions are shown in Vim normal mode.
// By default, edit predictions are only shown in insert and replace modes.
"show_edit_predictions_in_normal_mode": false,
// Cursor shape for each mode.
// The shape can be one of the following: "block", "bar", "underline", "hollow".
"cursor_shape": {

View file

@ -570,6 +570,22 @@ impl From<RequestPermissionOutcome> for acp::RequestPermissionOutcome {
}
}
/// What a `WaitingForConfirmation` prompt represents semantically.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationKind {
/// The user is granting or denying permission for the tool call to
/// proceed. The selected `PermissionOptionKind` determines whether the
/// tool call transitions to `InProgress` (allow) or `Rejected` (reject).
/// This is the default for tool authorization prompts.
PermissionGrant,
/// The user is choosing between actions for the tool to take next
/// (for example, "Save" vs "Discard" before editing a dirty buffer).
/// The tool call always transitions to `InProgress` regardless of the
/// selected `PermissionOptionKind`; the caller interprets the chosen
/// `option_id` to decide what to do.
ActionChoice,
}
#[derive(Debug)]
pub enum ToolCallStatus {
/// The tool call hasn't started running yet, but we start showing it to
@ -579,6 +595,7 @@ pub enum ToolCallStatus {
WaitingForConfirmation {
options: PermissionOptions,
respond_tx: oneshot::Sender<SelectedPermissionOutcome>,
kind: AuthorizationKind,
},
/// The tool call is currently running.
InProgress,
@ -2080,6 +2097,7 @@ impl AcpThread {
&mut self,
tool_call: acp::ToolCallUpdate,
options: PermissionOptions,
kind: AuthorizationKind,
cx: &mut Context<Self>,
) -> Result<Task<RequestPermissionOutcome>> {
let (tx, rx) = oneshot::channel();
@ -2087,6 +2105,7 @@ impl AcpThread {
let status = ToolCallStatus::WaitingForConfirmation {
options,
respond_tx: tx,
kind,
};
let tool_call_id = tool_call.tool_call_id.clone();
@ -2118,15 +2137,25 @@ impl AcpThread {
return;
};
let new_status = match outcome.option_kind {
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
ToolCallStatus::Rejected
let is_action_choice = matches!(
call.status,
ToolCallStatus::WaitingForConfirmation {
kind: AuthorizationKind::ActionChoice,
..
}
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => {
);
let new_status =
if is_action_choice {
ToolCallStatus::InProgress
}
_ => ToolCallStatus::InProgress,
};
} else {
match outcome.option_kind {
acp::PermissionOptionKind::RejectOnce
| acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected,
acp::PermissionOptionKind::AllowOnce
| acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress,
_ => ToolCallStatus::InProgress,
}
};
let curr_status = mem::replace(&mut call.status, new_status);
@ -2294,10 +2323,6 @@ impl AcpThread {
this.project
.update(cx, |project, cx| project.set_agent_location(None, cx));
}
let Ok(response) = response else {
// tx dropped, just return
return Ok(None);
};
let is_same_turn = this
.running_turn
@ -2306,11 +2331,18 @@ impl AcpThread {
// If the user submitted a follow up message, running_turn might
// already point to a different turn. Therefore we only want to
// take the task if it's the same turn.
// take the task if it's the same turn. We do this before the
// dropped-tx guard below so the panel exits its generating
// state even when the send_task is cancelled before tx.send().
if is_same_turn {
this.running_turn.take();
}
let Ok(response) = response else {
// tx dropped, just return
return Ok(None);
};
match response {
Ok(r) => {
Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
@ -5517,4 +5549,63 @@ mod tests {
);
});
}
/// Regression test: if the inner send_task is cancelled before it can
/// fire `tx.send(...)` (e.g. because the underlying future was dropped),
/// the outer task observes `rx.await` returning `Err(Cancelled)` and
/// must still clear `running_turn` so the panel transitions out of
/// `Generating`. Without this, the agent thread is wedged in the
/// loading state until Zed restarts.
#[gpui::test]
async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
// Handler hangs forever so the spawn at run_turn is parked inside
// `f(this, cx).await` with `tx` still alive but unsent.
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
|_params, _thread, _cx| {
async move { futures::future::pending::<Result<acp::PromptResponse>>().await }
.boxed_local()
},
));
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
let request = thread.update(cx, |thread, cx| thread.send_raw("hello", cx));
cx.run_until_parked();
assert_eq!(
thread.read_with(cx, |t, _| t.status()),
ThreadStatus::Generating,
"thread should be generating while the handler is parked"
);
// Replace the in-flight send_task with a no-op. Dropping the original
// Task cancels its inner future, which drops `tx` without ever calling
// `tx.send(...)`. This mirrors the production scenario where the
// send_task future is cancelled before completion.
thread.update(cx, |thread, _| {
thread.running_turn.as_mut().unwrap().send_task = Task::ready(());
});
let result = request.await;
assert!(
matches!(result, Ok(None)),
"outer task should resolve to Ok(None) on dropped tx, got {result:?}"
);
assert_eq!(
thread.read_with(cx, |t, _| t.status()),
ThreadStatus::Idle,
"running_turn must be cleared even when tx was dropped without send"
);
}
}

View file

@ -49,6 +49,10 @@ pub trait AgentConnection {
fn telemetry_id(&self) -> SharedString;
fn agent_version(&self) -> Option<SharedString> {
None
}
fn new_session(
self: Rc<Self>,
project: Entity<Project>,
@ -637,6 +641,8 @@ mod test_support {
use gpui::{AppContext as _, WeakEntity};
use parking_lot::Mutex;
use crate::AuthorizationKind;
use super::*;
/// Creates a PNG image encoded as base64 for testing.
@ -911,6 +917,7 @@ mod test_support {
thread.request_tool_call_authorization(
tool_call.clone().into(),
options.clone(),
AuthorizationKind::PermissionGrant,
cx,
)
})??

View file

@ -817,4 +817,9 @@ impl StatusItemView for ActivityIndicator {
_: &mut Context<Self>,
) {
}
fn hide_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
// Activity indicator auto-hides when there's no work to display.
None
}
}

View file

@ -37,7 +37,7 @@ use futures::future::Shared;
use futures::{FutureExt as _, StreamExt as _, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
WeakEntity,
TaskExt, WeakEntity,
};
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree};
@ -1298,9 +1298,12 @@ impl NativeAgentConnection {
options,
response,
context: _,
kind,
}) => {
let outcome_task = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, cx)
thread.request_tool_call_authorization(
tool_call, options, kind, cx,
)
})??;
cx.background_spawn(async move {
if let acp_thread::RequestPermissionOutcome::Selected(outcome) =

View file

@ -5545,6 +5545,105 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont
});
}
#[gpui::test]
async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({})).await;
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
let project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
let environment = Rc::new(cx.update(|cx| {
FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
}));
let thread = cx.new(|cx| {
let mut thread = Thread::new(
project,
project_context,
context_server_registry,
Templates::new(),
Some(model.clone() as Arc<dyn LanguageModel>),
cx,
);
thread.add_default_tools(environment, cx);
thread
});
let lsp_tool_names = [
FindReferencesTool::NAME,
GetCodeActionsTool::NAME,
ApplyCodeActionTool::NAME,
GoToDefinitionTool::NAME,
RenameTool::NAME,
];
// All LSP tools should be registered on the thread regardless of the flag,
// since the feature flag now only controls exposure to the model rather
// than registration.
thread.read_with(cx, |thread, _| {
for name in &lsp_tool_names {
assert!(
thread.has_registered_tool(name),
"expected LSP tool {name} to be registered"
);
}
});
// Without the `lsp-tool` flag, sending a message should produce a
// completion request whose tool list excludes the LSP tools.
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["hello"], cx)
})
.unwrap();
cx.run_until_parked();
let completion = model.pending_completions().pop().unwrap();
let tool_names = tool_names_for_completion(&completion);
for name in &lsp_tool_names {
assert!(
!tool_names.iter().any(|t| t == name),
"expected LSP tool {name} to be hidden without the lsp-tool flag, \
but completion tools were: {tool_names:?}"
);
}
// Sanity check: a non-LSP default tool should still be exposed.
assert!(
tool_names.iter().any(|t| t == ReadFileTool::NAME),
"expected non-LSP tools to still be exposed, got: {tool_names:?}"
);
model.end_last_completion_stream();
cx.run_until_parked();
// Enable the `lsp-tool` flag and send another message; the LSP tools
// should now appear in the completion request.
cx.update(|cx| {
cx.update_flags(false, vec!["lsp-tool".to_string()]);
});
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["hello again"], cx)
})
.unwrap();
cx.run_until_parked();
let completion = model.pending_completions().pop().unwrap();
let tool_names = tool_names_for_completion(&completion);
for name in &lsp_tool_names {
assert!(
tool_names.iter().any(|t| t == name),
"expected LSP tool {name} to be exposed when lsp-tool flag is on, \
but completion tools were: {tool_names:?}"
);
}
}
#[gpui::test]
async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
init_test(cx);
@ -6062,9 +6161,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
path: "root/sensitive_config.txt".into(),
mode: crate::EditFileMode::Edit,
content: None,
edits: Some(vec![]),
edits: vec![],
}),
event_stream,
cx,
@ -6294,112 +6391,6 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"normal.txt": "normal content",
"readonly": {
"config.txt": "readonly content"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
SaveFileTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
agent_settings::AgentSettings::override_global(settings, cx);
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::SaveFileTool::new(project));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![
std::path::PathBuf::from("root/normal.txt"),
std::path::PathBuf::from("root/readonly/config.txt"),
],
}),
event_stream,
cx,
)
});
let result = task.await;
assert!(
result.is_err(),
"expected save to be blocked due to denied path"
);
assert!(
result.unwrap_err().contains("blocked"),
"error should mention the save was blocked"
);
}
#[gpui::test]
async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"config.secret": "secret config"}))
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
SaveFileTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
agent_settings::AgentSettings::override_global(settings, cx);
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::SaveFileTool::new(project));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![std::path::PathBuf::from("root/config.secret")],
}),
event_stream,
cx,
)
});
let result = task.await;
assert!(result.is_err(), "expected save to be blocked");
assert!(
result.unwrap_err().contains("blocked"),
"error should mention the save was blocked"
);
}
#[gpui::test]
async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
init_test(cx);
@ -6496,9 +6487,7 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
path: "root/README.md".into(),
mode: crate::EditFileMode::Edit,
content: None,
edits: Some(vec![]),
edits: vec![],
}),
event_stream,
cx,
@ -6568,9 +6557,7 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
tool.run(
ToolInput::resolved(crate::EditFileToolInput {
path: "root/.zed/settings.json".into(),
mode: crate::EditFileMode::Edit,
content: None,
edits: Some(vec![]),
edits: vec![],
}),
event_stream,
cx,

View file

@ -3,8 +3,8 @@ use crate::{
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool,
SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool,
ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
@ -822,9 +822,9 @@ impl ToolPermissionContext {
} else if tool_name == CopyPathTool::NAME
|| tool_name == MovePathTool::NAME
|| tool_name == EditFileTool::NAME
|| tool_name == WriteFileTool::NAME
|| tool_name == DeletePathTool::NAME
|| tool_name == CreateDirectoryTool::NAME
|| tool_name == SaveFileTool::NAME
{
(
extract_path_pattern(value),
@ -924,6 +924,7 @@ pub struct ToolCallAuthorization {
pub options: acp_thread::PermissionOptions,
pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
pub context: Option<ToolPermissionContext>,
pub kind: acp_thread::AuthorizationKind,
}
#[derive(Debug, thiserror::Error)]
@ -1544,6 +1545,12 @@ impl Thread {
self.action_log.clone(),
));
self.add_tool(EditFileTool::new(
self.project.clone(),
cx.weak_entity(),
self.action_log.clone(),
language_registry.clone(),
));
self.add_tool(WriteFileTool::new(
self.project.clone(),
cx.weak_entity(),
self.action_log.clone(),
@ -1564,26 +1571,23 @@ impl Thread {
self.action_log.clone(),
update_agent_location,
));
self.add_tool(SaveFileTool::new(self.project.clone()));
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(TerminalTool::new(self.project.clone(), environment.clone()));
self.add_tool(WebSearchTool);
self.add_tool(DiagnosticsTool::new(self.project.clone()));
if cx.has_flag::<LspToolFeatureFlag>() {
let code_action_store: CodeActionStore = cx.new(|_cx| None);
self.add_tool(FindReferencesTool::new(self.project.clone()));
self.add_tool(GetCodeActionsTool::new(
self.project.clone(),
code_action_store.clone(),
));
self.add_tool(ApplyCodeActionTool::new(
self.project.clone(),
code_action_store,
));
self.add_tool(GoToDefinitionTool::new(self.project.clone()));
self.add_tool(RenameTool::new(self.project.clone()));
}
let code_action_store: CodeActionStore = cx.new(|_cx| None);
self.add_tool(FindReferencesTool::new(self.project.clone()));
self.add_tool(GetCodeActionsTool::new(
self.project.clone(),
code_action_store.clone(),
));
self.add_tool(ApplyCodeActionTool::new(
self.project.clone(),
code_action_store,
));
self.add_tool(GoToDefinitionTool::new(self.project.clone()));
self.add_tool(RenameTool::new(self.project.clone()));
if self.depth() < MAX_SUBAGENT_DEPTH {
self.add_tool(SpawnAgentTool::new(environment));
@ -2887,6 +2891,17 @@ impl Thread {
None
}
})
.filter(|(tool_name, _)| {
cx.has_flag::<LspToolFeatureFlag>()
|| !matches!(
tool_name.as_ref(),
FindReferencesTool::NAME
| GetCodeActionsTool::NAME
| ApplyCodeActionTool::NAME
| GoToDefinitionTool::NAME
| RenameTool::NAME
)
})
.collect::<BTreeMap<_, _>>();
let mut context_server_tools = Vec::new();
@ -2950,10 +2965,6 @@ impl Thread {
self.tools.contains_key(name)
}
pub fn registered_tool_names(&self) -> Vec<SharedString> {
self.tools.keys().cloned().collect()
}
pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
self.running_subagents.push(subagent);
}
@ -3865,6 +3876,57 @@ impl ToolCallEventStream {
self.run_authorization_loop(title, options, Some(context), None, cx)
}
/// Prompts the user to choose between an explicit set of actions and
/// returns the chosen `option_id`.
///
/// Unlike [`Self::authorize`] / [`Self::authorize_always_prompt`], this
/// does not interpret the user's choice as a permission grant — callers
/// are responsible for handling each `option_id` explicitly. Use this
/// when a tool needs the user to pick between several side-effecting
/// actions (for example, "Save" vs "Discard" for a dirty buffer).
pub fn prompt_for_decision(
&self,
title: Option<String>,
message: Option<String>,
options: Vec<acp::PermissionOption>,
cx: &mut App,
) -> Task<Result<acp::PermissionOptionId>> {
let options = acp_thread::PermissionOptions::Flat(options);
let stream = self.stream.clone();
let tool_use_id = self.tool_use_id.clone();
cx.spawn(async move |_cx| {
let mut fields = acp::ToolCallUpdateFields::new();
if let Some(title) = title {
fields = fields.title(title);
}
if let Some(message) = message {
fields = fields.content(vec![acp::ToolCallContent::from(message)]);
}
let (response_tx, response_rx) = oneshot::channel();
if let Err(error) = stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: acp::ToolCallUpdate::new(tool_use_id.to_string(), fields),
options,
response: response_tx,
context: None,
kind: acp_thread::AuthorizationKind::ActionChoice,
},
)))
{
log::error!("Failed to send tool call decision prompt: {error}");
return Err(anyhow!("Failed to send tool call decision prompt: {error}"));
}
let outcome = response_rx
.await
.map_err(|_| anyhow!("authorization channel closed"))?;
Ok(outcome.option_id)
})
}
/// Prompts the user for authorization.
///
/// When `check_settings` is `Some`, this gate is settings-driven: the
@ -3912,6 +3974,7 @@ impl ToolCallEventStream {
options,
response: response_tx,
context,
kind: acp_thread::AuthorizationKind::PermissionGrant,
},
)))
{

View file

@ -5,6 +5,7 @@ mod create_directory_tool;
mod delete_path_tool;
mod diagnostics_tool;
mod edit_file_tool;
mod edit_session;
#[cfg(all(test, feature = "unit-eval"))]
mod evals;
mod fetch_tool;
@ -19,14 +20,13 @@ mod now_tool;
mod open_tool;
mod read_file_tool;
mod rename_tool;
mod restore_file_from_disk_tool;
mod save_file_tool;
mod spawn_agent_tool;
mod symbol_locator;
mod terminal_tool;
mod tool_permissions;
mod update_plan_tool;
mod web_search_tool;
mod write_file_tool;
use crate::AgentTool;
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
@ -77,14 +77,13 @@ pub use now_tool::*;
pub use open_tool::*;
pub use read_file_tool::*;
pub use rename_tool::*;
pub use restore_file_from_disk_tool::*;
pub use save_file_tool::*;
pub use spawn_agent_tool::*;
pub use symbol_locator::*;
pub use terminal_tool::*;
pub use tool_permissions::*;
pub use update_plan_tool::*;
pub use web_search_tool::*;
pub use write_file_tool::*;
macro_rules! tools {
($($tool:ty),* $(,)?) => {
@ -173,10 +172,9 @@ tools! {
OpenTool,
ReadFileTool,
RenameTool,
RestoreFileFromDiskTool,
SaveFileTool,
SpawnAgentTool,
TerminalTool,
UpdatePlanTool,
WebSearchTool,
WriteFileTool,
}

View file

@ -54,7 +54,7 @@ impl AgentTool for CreateDirectoryTool {
const NAME: &'static str = "create_directory";
fn kind() -> acp::ToolKind {
acp::ToolKind::Read
acp::ToolKind::Edit
}
fn initial_title(

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
use smallvec::SmallVec;
use crate::{Edit, PartialEdit};
use super::{Edit, PartialEdit};
/// Events emitted by `StreamingParser` for edit-mode input.
#[derive(Debug, PartialEq, Eq)]
@ -33,6 +33,8 @@ struct EditStreamState {
old_text_done: bool,
new_text_emitted_len: usize,
new_text_done: bool,
hold_until_complete: bool,
buffer_new_text_until_old_text_done: bool,
}
/// Converts incrementally-growing tool call JSON into a stream of chunk events.
@ -68,7 +70,15 @@ impl StreamingParser {
for (index, partial) in edits.iter().enumerate() {
if index >= self.edit_states.len() {
// A new edit appeared — finalize the previous one if there was one.
if let Some(previous) = self.finalize_previous_edit(index) {
if let Some(previous) = self.finalize_previous_edit(
index,
edits
.get(index.saturating_sub(1))
.and_then(|edit| edit.old_text.as_deref()),
edits
.get(index.saturating_sub(1))
.and_then(|edit| edit.new_text.as_deref()),
) {
events.extend(previous);
}
self.edit_states.push(EditStreamState::default());
@ -76,12 +86,33 @@ impl StreamingParser {
let state = &mut self.edit_states[index];
if state.old_text_emitted_len == 0
&& state.new_text_emitted_len == 0
&& !state.old_text_done
&& partial.new_text.is_some()
&& !state.buffer_new_text_until_old_text_done
{
if partial
.old_text
.as_ref()
.is_some_and(|old_text| !old_text.is_empty())
{
state.hold_until_complete = true;
} else {
state.buffer_new_text_until_old_text_done = true;
}
}
if state.hold_until_complete {
continue;
}
// Process old_text changes.
if let Some(old_text) = &partial.old_text
&& !state.old_text_done
{
if partial.new_text.is_some() {
// new_text appeared, so old_text is done — emit everything.
if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done {
// new_text appeared after old_text, so old_text is done — emit everything.
let start = state.old_text_emitted_len.min(old_text.len());
let chunk = normalize_done_chunk(old_text[start..].to_string());
state.old_text_done = true;
@ -108,6 +139,7 @@ impl StreamingParser {
// Process new_text changes.
if let Some(new_text) = &partial.new_text
&& state.old_text_done
&& !state.new_text_done
{
let safe_end = safe_emit_end_for_edit_text(new_text);
@ -157,7 +189,15 @@ impl StreamingParser {
for (index, edit) in edits.iter().enumerate() {
if index >= self.edit_states.len() {
// This edit was never seen in partials — emit it fully.
if let Some(previous) = self.finalize_previous_edit(index) {
if let Some(previous) = self.finalize_previous_edit(
index,
edits
.get(index.saturating_sub(1))
.map(|edit| edit.old_text.as_str()),
edits
.get(index.saturating_sub(1))
.map(|edit| edit.new_text.as_str()),
) {
events.extend(previous);
}
self.edit_states.push(EditStreamState::default());
@ -165,6 +205,26 @@ impl StreamingParser {
let state = &mut self.edit_states[index];
if state.hold_until_complete {
state.old_text_done = true;
state.old_text_emitted_len = edit.old_text.len();
state.new_text_done = true;
state.new_text_emitted_len = edit.new_text.len();
state.hold_until_complete = false;
state.buffer_new_text_until_old_text_done = false;
events.push(EditEvent::OldTextChunk {
edit_index: index,
chunk: normalize_done_chunk(edit.old_text.clone()),
done: true,
});
events.push(EditEvent::NewTextChunk {
edit_index: index,
chunk: normalize_done_chunk(edit.new_text.clone()),
done: true,
});
continue;
}
if !state.old_text_done {
let start = state.old_text_emitted_len.min(edit.old_text.len());
let chunk = normalize_done_chunk(edit.old_text[start..].to_string());
@ -209,7 +269,12 @@ impl StreamingParser {
/// When a new edit appears at `index`, finalize the edit at `index - 1`
/// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized.
fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[EditEvent; 2]>> {
fn finalize_previous_edit(
&mut self,
new_index: usize,
old_text: Option<&str>,
new_text: Option<&str>,
) -> Option<SmallVec<[EditEvent; 2]>> {
if new_index == 0 || self.edit_states.is_empty() {
return None;
}
@ -222,22 +287,49 @@ impl StreamingParser {
let state = &mut self.edit_states[previous_index];
let mut events = SmallVec::new();
// If old_text was never finalized, finalize it now with an empty done chunk.
if !state.old_text_done {
if state.hold_until_complete {
let old_text = old_text.unwrap_or_default();
let new_text = new_text.unwrap_or_default();
state.old_text_done = true;
state.old_text_emitted_len = old_text.len();
state.new_text_done = true;
state.new_text_emitted_len = new_text.len();
state.hold_until_complete = false;
state.buffer_new_text_until_old_text_done = false;
events.push(EditEvent::OldTextChunk {
edit_index: previous_index,
chunk: String::new(),
chunk: normalize_done_chunk(old_text.to_string()),
done: true,
});
events.push(EditEvent::NewTextChunk {
edit_index: previous_index,
chunk: normalize_done_chunk(new_text.to_string()),
done: true,
});
return Some(events);
}
if !state.old_text_done {
let old_text = old_text.unwrap_or_default();
let start = state.old_text_emitted_len.min(old_text.len());
state.old_text_done = true;
state.old_text_emitted_len = old_text.len();
events.push(EditEvent::OldTextChunk {
edit_index: previous_index,
chunk: normalize_done_chunk(old_text[start..].to_string()),
done: true,
});
}
// Emit a done event for new_text if not already finalized.
if !state.new_text_done {
let new_text = new_text.unwrap_or_default();
let start = state.new_text_emitted_len.min(new_text.len());
state.new_text_done = true;
state.new_text_emitted_len = new_text.len();
state.buffer_new_text_until_old_text_done = false;
events.push(EditEvent::NewTextChunk {
edit_index: previous_index,
chunk: String::new(),
chunk: normalize_done_chunk(new_text[start..].to_string()),
done: true,
});
}
@ -279,6 +371,43 @@ fn normalize_done_chunk(mut chunk: String) -> String {
mod tests {
use super::*;
#[test]
fn test_first_edit_with_new_text_in_first_chunk_is_held_until_finalize() {
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("old".into()),
new_text: Some("new".into()),
}]);
assert!(events.is_empty());
let events = parser.push_edits(&[PartialEdit {
old_text: Some("old text".into()),
new_text: Some("new text".into()),
}]);
assert!(events.is_empty());
let events = parser.finalize_edits(&[Edit {
old_text: "old text".into(),
new_text: "new text".into(),
}]);
assert_eq!(
events.as_slice(),
&[
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "old text".into(),
done: true,
},
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "new text".into(),
done: true,
},
]
);
}
#[test]
fn test_single_edit_streamed_incrementally() {
let mut parser = StreamingParser::default();
@ -393,6 +522,12 @@ mod tests {
old_text: Some("before\n".into()),
new_text: Some("after\n".into()),
}]);
assert!(events.is_empty());
let events = parser.finalize_edits(&[Edit {
old_text: "before\n".into(),
new_text: "after\n".into(),
}]);
assert_eq!(
events.as_slice(),
&[
@ -404,23 +539,10 @@ mod tests {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "after".into(),
done: false,
done: true,
},
]
);
let events = parser.finalize_edits(&[Edit {
old_text: "before\n".into(),
new_text: "after\n".into(),
}]);
assert_eq!(
events.as_slice(),
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
}]
);
}
#[test]
@ -731,13 +853,31 @@ mod tests {
}
#[test]
fn test_empty_old_text_with_new_text() {
fn test_new_text_before_old_text_buffers_new_text_but_streams_old_text() {
let mut parser = StreamingParser::default();
// old_text is empty, new_text appears immediately
let events = parser.push_edits(&[PartialEdit {
old_text: Some("".into()),
new_text: Some("inserted".into()),
old_text: None,
new_text: Some("new".into()),
}]);
assert!(events.is_empty());
let events = parser.push_edits(&[PartialEdit {
old_text: Some("old".into()),
new_text: Some("new".into()),
}]);
assert_eq!(
events.as_slice(),
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "old".into(),
done: false,
}]
);
let events = parser.finalize_edits(&[Edit {
old_text: "old".into(),
new_text: "new".into(),
}]);
assert_eq!(
events.as_slice(),
@ -749,8 +889,8 @@ mod tests {
},
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "inserted".into(),
done: false,
chunk: "new".into(),
done: true,
},
]
);
@ -794,13 +934,17 @@ mod tests {
},
]);
// Should finalize edit 1 (index=1) and start edit 2 (index=2)
assert_eq!(
events.as_slice(),
&[
EditEvent::OldTextChunk {
edit_index: 1,
chunk: "b".into(),
done: true,
},
EditEvent::NewTextChunk {
edit_index: 1,
chunk: "".into(),
chunk: "B".into(),
done: true,
},
EditEvent::OldTextChunk {
@ -874,50 +1018,34 @@ mod tests {
);
}
#[test]
fn test_finalize_with_partially_seen_new_text() {
let mut parser = StreamingParser::default();
parser.push_edits(&[PartialEdit {
old_text: Some("old".into()),
new_text: Some("partial".into()),
}]);
let events = parser.finalize_edits(&[Edit {
old_text: "old".into(),
new_text: "partial new text".into(),
}]);
assert_eq!(
events.as_slice(),
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: " new text".into(),
done: true,
}]
);
}
#[test]
fn test_repeated_pushes_with_no_change() {
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("stable".into()),
new_text: Some("also stable".into()),
new_text: None,
}]);
assert_eq!(events.len(), 2); // old done + new chunk
assert_eq!(
events.as_slice(),
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "stable".into(),
done: false,
}]
);
// Push the exact same data again
let events = parser.push_edits(&[PartialEdit {
old_text: Some("stable".into()),
new_text: Some("also stable".into()),
new_text: None,
}]);
assert!(events.is_empty());
// And again
let events = parser.push_edits(&[PartialEdit {
old_text: Some("stable".into()),
new_text: Some("also stable".into()),
new_text: None,
}]);
assert!(events.is_empty());
}

View file

@ -1,2 +1,52 @@
#[cfg(all(test, feature = "unit-eval"))]
use futures::future::LocalBoxFuture;
#[cfg(all(test, feature = "unit-eval"))]
use gpui::TestAppContext;
#[cfg(all(test, feature = "unit-eval"))]
use std::fmt::Display;
#[cfg(all(test, feature = "unit-eval"))]
mod edit_file;
#[cfg(all(test, feature = "unit-eval"))]
mod terminal_tool;
#[cfg(all(test, feature = "unit-eval"))]
mod write_file;
#[cfg(all(test, feature = "unit-eval"))]
fn run_gpui_eval<T>(
eval: impl for<'a> FnOnce(&'a mut TestAppContext) -> LocalBoxFuture<'a, anyhow::Result<T>>,
outcome: impl FnOnce(&T) -> eval_utils::OutcomeKind,
) -> eval_utils::EvalOutput<()>
where
T: Display,
{
let dispatcher = gpui::TestDispatcher::new(rand::random());
let mut cx = TestAppContext::build(dispatcher.clone(), None);
let entity_refcounts = cx.app.borrow().ref_counts_drop_handle();
let foreground_executor = cx.foreground_executor().clone();
let result = foreground_executor.block_test(eval(&mut cx));
cx.run_until_parked();
cx.update(|cx| {
cx.background_executor().forbid_parking();
cx.quit();
});
cx.run_until_parked();
drop(cx);
dispatcher.drain_tasks();
drop(dispatcher);
drop(entity_refcounts);
match result {
Ok(output) => eval_utils::EvalOutput {
data: output.to_string(),
outcome: outcome(&output),
metadata: (),
},
Err(err) => eval_utils::EvalOutput {
data: format!("{err:?}"),
outcome: eval_utils::OutcomeKind::Error,
metadata: (),
},
}
}

View file

@ -1,8 +1,7 @@
use crate::tools::edit_file_tool::*;
use crate::{
AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool,
ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, Template, Templates, Thread,
ToolCallEventStream, ToolInput,
AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ReadFileTool,
ReadFileToolInput, Template, Templates, Thread, ToolCallEventStream, ToolInput,
};
use Role::*;
use anyhow::{Context as _, Result};
@ -124,20 +123,6 @@ impl EvalAssertion {
EvalAssertion(Arc::new(f))
}
fn assert_eq(expected: impl Into<String>) -> Self {
let expected = expected.into();
Self::new(async move |sample, _judge, _cx| {
Ok(EvalAssertionOutcome {
score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) {
100
} else {
0
},
message: None,
})
})
}
fn assert_diff_any(expected_diffs: Vec<impl Into<String>>) -> Self {
let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect();
Self::new(async move |sample, _judge, _cx| {
@ -562,33 +547,25 @@ impl EditToolTest {
}
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
let dispatcher = gpui::TestDispatcher::new(rand::random());
let mut cx = TestAppContext::build(dispatcher, None);
let foreground_executor = cx.foreground_executor().clone();
let result = foreground_executor.block_test(async {
let test = EditToolTest::new(&mut cx).await;
let result = test.eval(eval, &mut cx).await;
drop(test);
cx.run_until_parked();
result
});
cx.quit();
match result {
Ok(output) => eval_utils::EvalOutput {
data: output.to_string(),
outcome: if output.assertion.score < 80 {
super::run_gpui_eval(
|cx| {
async move {
let test = EditToolTest::new(cx).await;
let result = test.eval(eval, cx).await;
drop(test);
cx.run_until_parked();
result
}
.boxed_local()
},
|output| {
if output.assertion.score < 80 {
eval_utils::OutcomeKind::Failed
} else {
eval_utils::OutcomeKind::Passed
},
metadata: (),
}
},
Err(err) => eval_utils::EvalOutput {
data: format!("{err:?}"),
outcome: eval_utils::OutcomeKind::Error,
metadata: (),
},
}
)
}
fn message(
@ -1499,46 +1476,3 @@ fn eval_add_overwrite_test() {
))
});
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_create_empty_file() {
let input_file_path = "root/TODO3";
let input_file_content = None;
let expected_output_content = String::new();
eval_utils::eval(100, 0.99, eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![
message(User, [text("Create a second empty todo file ")]),
message(
Assistant,
[
text(indoc::formatdoc! {"
I'll help you create a second empty todo file.
First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
"}),
tool_use(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
ListDirectoryToolInput {
path: "root".to_string(),
},
),
],
),
message(
User,
[tool_result(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
"root/TODO\nroot/TODO2\nroot/new.txt\n",
)],
),
],
input_file_path,
input_file_content.clone(),
EvalAssertion::assert_eq(expected_output_content.clone()),
))
});
}

View file

@ -0,0 +1,520 @@
use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput};
use Role::*;
use anyhow::{Context as _, Result};
use client::{Client, RefreshLlmTokenListener, UserStore};
use futures::{FutureExt as _, StreamExt};
use gpui::{AppContext as _, AsyncApp, TestAppContext};
use http_client::StatusCode;
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role,
SelectedModel,
};
use prompt_store::{ProjectContext, WorktreeContext};
use rand::prelude::*;
use reqwest_client::ReqwestClient;
use settings::SettingsStore;
use std::{
fmt::{self, Display},
path::Path,
str::FromStr,
sync::Arc,
time::Duration,
};
#[derive(Clone)]
struct EvalInput {
conversation: Vec<LanguageModelRequestMessage>,
assertion: CommandAssertion,
}
impl EvalInput {
fn new(conversation: Vec<LanguageModelRequestMessage>, assertion: CommandAssertion) -> Self {
Self {
conversation,
assertion,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
struct EvalAssertionOutcome {
score: usize,
message: Option<String>,
}
type AssertionFn = Arc<dyn Fn(&TerminalToolInput) -> EvalAssertionOutcome + Send + Sync + 'static>;
#[derive(Clone)]
struct CommandAssertion {
description: &'static str,
check: AssertionFn,
}
impl CommandAssertion {
fn new(
description: &'static str,
check: impl Fn(&TerminalToolInput) -> EvalAssertionOutcome + Send + Sync + 'static,
) -> Self {
Self {
description,
check: Arc::new(check),
}
}
/// Passes when the command is a git command and every git subcommand that
/// could block on a pty (pager or editor) is guarded with the appropriate
/// environment variable or flag.
///
/// This is intentionally permissive about *which* git subcommand the model
/// chooses — for an indirect prompt like "combine my last 3 commits", the
/// model is free to first investigate with `git log` or jump straight to
/// `git rebase -i`. Either is fine, as long as whatever it picks won't
/// hang on a pager or editor.
fn git_pty_safe(description: &'static str) -> Self {
Self::new(description, |input| {
let cmd = input.command.as_str();
let words: Vec<&str> = cmd.split_whitespace().collect();
if !words.contains(&"git") {
return EvalAssertionOutcome {
score: 0,
message: Some(format!("Expected a `git` command, got: {cmd}")),
};
}
// Subcommands that pipe their output through a pager by default,
// and so will hang on `less` unless one of these escape hatches is
// present somewhere in the command:
const PAGER_SUBCMDS: &[&str] = &["log", "diff", "show", "blame"];
const PAGER_GUARDS: &[&str] = &["--no-pager", "GIT_PAGER=cat", "PAGER=cat"];
// Subcommands that may invoke an interactive editor and so will
// hang unless one of these escape hatches is present:
const EDITOR_SUBCMDS: &[&str] = &["rebase", "commit", "merge", "tag"];
const EDITOR_GUARDS: &[&str] =
&["GIT_EDITOR=true", "GIT_EDITOR=:", "EDITOR=true", "EDITOR=:"];
let has_pager_guard = PAGER_GUARDS.iter().any(|guard| cmd.contains(guard));
let has_editor_guard = EDITOR_GUARDS.iter().any(|guard| cmd.contains(guard));
for subcmd in PAGER_SUBCMDS {
if words.contains(subcmd) && !has_pager_guard {
return EvalAssertionOutcome {
score: 0,
message: Some(format!(
"`git {subcmd}` is missing a pager guard \
(one of {PAGER_GUARDS:?}). Command: {cmd}"
)),
};
}
}
for subcmd in EDITOR_SUBCMDS {
if words.contains(subcmd) && !has_editor_guard {
return EvalAssertionOutcome {
score: 0,
message: Some(format!(
"`git {subcmd}` is missing an editor guard \
(one of {EDITOR_GUARDS:?}). Command: {cmd}"
)),
};
}
}
EvalAssertionOutcome {
score: 100,
message: None,
}
})
}
}
struct EvalOutput {
tool_input: TerminalToolInput,
assertion: EvalAssertionOutcome,
assertion_description: &'static str,
}
impl Display for EvalOutput {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "Score: {}", self.assertion.score)?;
writeln!(f, "Assertion: {}", self.assertion_description)?;
if let Some(message) = self.assertion.message.as_ref() {
writeln!(f, "Message: {}", message)?;
}
writeln!(f, "Tool input: {:#?}", self.tool_input)?;
Ok(())
}
}
struct TerminalToolTest {
model: Arc<dyn LanguageModel>,
model_thinking_effort: Option<String>,
}
impl TerminalToolTest {
async fn new(cx: &mut TestAppContext) -> Self {
cx.executor().allow_parking();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
gpui_tokio::init(cx);
let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap());
cx.set_http_client(http_client);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(cx);
RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
language_models::init(user_store, client, cx);
});
let agent_model = SelectedModel::from_str(
&std::env::var("ZED_AGENT_MODEL")
.unwrap_or("anthropic/claude-sonnet-4-6-latest".into()),
)
.unwrap();
let authenticate_provider_tasks = cx.update(|cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry
.providers()
.iter()
.map(|p| p.authenticate(cx))
.collect::<Vec<_>>()
})
});
let model = cx
.update(|cx| {
cx.spawn(async move |cx| {
futures::future::join_all(authenticate_provider_tasks).await;
load_model(&agent_model, cx).await.unwrap()
})
})
.await;
let model_thinking_effort = model
.default_effort_level()
.map(|effort_level| effort_level.value.to_string());
Self {
model,
model_thinking_effort,
}
}
async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result<EvalOutput> {
eval.conversation
.last_mut()
.context("Conversation must not be empty")?
.cache = true;
let tools = crate::built_in_tools().collect::<Vec<_>>();
let system_prompt = {
let worktrees = vec![WorktreeContext {
root_name: "root".to_string(),
abs_path: Path::new("/path/to/root").into(),
rules_file: None,
}];
let project_context = ProjectContext::new(worktrees, Vec::default());
let tool_names = tools
.iter()
.map(|tool| tool.name.clone().into())
.collect::<Vec<_>>();
let template = crate::SystemPromptTemplate {
project: &project_context,
available_tools: tool_names,
model_name: None,
};
template.render(&Templates::new())?
};
let has_system_prompt = eval
.conversation
.first()
.is_some_and(|msg| msg.role == Role::System);
let messages = if has_system_prompt {
eval.conversation
} else {
[LanguageModelRequestMessage {
role: Role::System,
content: vec![MessageContent::Text(system_prompt)],
cache: true,
reasoning_details: None,
}]
.into_iter()
.chain(eval.conversation)
.collect::<Vec<_>>()
};
let request = LanguageModelRequest {
messages,
tools,
thinking_allowed: true,
thinking_effort: self.model_thinking_effort.clone(),
..Default::default()
};
let tool_input =
retry_on_rate_limit(async || extract_tool_use(&self.model, request.clone(), cx).await)
.await?;
let assertion = (eval.assertion.check)(&tool_input);
Ok(EvalOutput {
tool_input,
assertion,
assertion_description: eval.assertion.description,
})
}
}
async fn load_model(
selected_model: &SelectedModel,
cx: &mut AsyncApp,
) -> Result<Arc<dyn LanguageModel>> {
cx.update(|cx| {
let registry = LanguageModelRegistry::read_global(cx);
let provider = registry
.provider(&selected_model.provider)
.expect("Provider not found");
provider.authenticate(cx)
})
.await?;
Ok(cx.update(|cx| {
let models = LanguageModelRegistry::read_global(cx);
models
.available_models(cx)
.find(|model| {
model.provider_id() == selected_model.provider && model.id() == selected_model.model
})
.unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0))
}))
}
/// Stream the model completion and extract the first complete tool use whose
/// name matches `TerminalTool::NAME`, parsed as `TerminalToolInput`.
async fn extract_tool_use(
model: &Arc<dyn LanguageModel>,
request: LanguageModelRequest,
cx: &mut TestAppContext,
) -> Result<TerminalToolInput> {
let model = model.clone();
let events = cx
.update(|cx| {
let async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move { model.stream_completion(request, &async_cx).await })
})
.await
.map_err(|err| anyhow::anyhow!("completion error: {}", err))?;
let mut streamed_text = String::new();
let mut stop_reason = None;
let mut parse_errors = Vec::new();
let mut events = events.fuse();
while let Some(event) = events.next().await {
match event {
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME =>
{
let input: TerminalToolInput = serde_json::from_value(tool_use.input)
.context("Failed to parse tool input as TerminalToolInput")?;
return Ok(input);
}
Ok(LanguageModelCompletionEvent::Text(text)) => {
if streamed_text.len() < 2_000 {
streamed_text.push_str(&text);
}
}
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
stop_reason = Some(reason);
}
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
tool_name,
raw_input,
json_parse_error,
..
}) if tool_name.as_ref() == TerminalTool::NAME => {
parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}"));
}
Err(err) => {
return Err(anyhow::anyhow!("completion error: {}", err));
}
_ => {}
}
}
let streamed_text = streamed_text.trim();
let streamed_text_suffix = if streamed_text.is_empty() {
String::new()
} else {
format!("\nStreamed text:\n{streamed_text}")
};
let stop_reason_suffix = stop_reason
.map(|reason| format!("\nStop reason: {reason:?}"))
.unwrap_or_default();
let parse_errors_suffix = if parse_errors.is_empty() {
String::new()
} else {
format!("\nTool parse errors:\n{}", parse_errors.join("\n"))
};
anyhow::bail!(
"Stream ended without a terminal tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}"
)
}
async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
const MAX_RETRIES: usize = 20;
let mut attempt = 0;
loop {
attempt += 1;
let response = request().await;
if attempt >= MAX_RETRIES {
return response;
}
let retry_delay = match &response {
Ok(_) => None,
Err(err) => match err.downcast_ref::<LanguageModelCompletionError>() {
Some(err) => match &err {
LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
| LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
}
LanguageModelCompletionError::UpstreamProviderError {
status,
retry_after,
..
} => {
let should_retry = matches!(
*status,
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
) || status.as_u16() == 529;
if should_retry {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
} else {
None
}
}
LanguageModelCompletionError::ApiReadResponseError { .. }
| LanguageModelCompletionError::ApiInternalServerError { .. }
| LanguageModelCompletionError::HttpSend { .. } => {
Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
}
_ => None,
},
_ => None,
},
};
if let Some(retry_after) = retry_delay {
let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0));
eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
#[allow(clippy::disallowed_methods)]
async_io::Timer::after(retry_after + jitter).await;
} else {
return response;
}
}
}
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
super::run_gpui_eval(
|cx| {
async move {
let test = TerminalToolTest::new(cx).await;
let result = test.eval(eval, cx).await;
drop(test);
cx.run_until_parked();
result
}
.boxed_local()
},
|output| {
if output.assertion.score < 80 {
eval_utils::OutcomeKind::Failed
} else {
eval_utils::OutcomeKind::Passed
}
},
)
}
fn message(
role: Role,
contents: impl IntoIterator<Item = MessageContent>,
) -> LanguageModelRequestMessage {
LanguageModelRequestMessage {
role,
content: contents.into_iter().collect(),
cache: false,
reasoning_details: None,
}
}
fn text(text: impl Into<String>) -> MessageContent {
MessageContent::Text(text.into())
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_git_log_uses_no_pager() {
eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![message(
User,
[text(indoc::indoc! {"
Use the terminal tool to show me the most recent 3 commits
on the current branch (subject lines only is fine).
"})],
)],
CommandAssertion::git_pty_safe(
"`git log`-style prompt produces a pty-safe git command",
),
))
});
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_git_rebase_sets_git_editor() {
eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![message(
User,
[text(indoc::indoc! {"
Use the terminal tool to rebase the current branch onto
`origin/main`.
"})],
)],
CommandAssertion::git_pty_safe("`git rebase` prompt produces a pty-safe git command"),
))
});
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_git_rebase_implied_sets_git_editor() {
eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![message(
User,
[text(indoc::indoc! {"
My branch has 3 small commits that I'd like to combine
into a single clean commit before merging. Help me do
that with the terminal tool.
"})],
)],
CommandAssertion::git_pty_safe("indirect prompt produces a pty-safe git command"),
))
});
}

View file

@ -0,0 +1,551 @@
use crate::{
AgentTool, ContextServerRegistry, ListDirectoryTool, ListDirectoryToolInput, Template,
Templates, Thread, ToolCallEventStream, ToolInput, WriteFileTool, WriteFileToolInput,
};
use Role::*;
use anyhow::{Context as _, Result};
use client::{Client, RefreshLlmTokenListener, UserStore};
use fs::FakeFs;
use futures::{FutureExt as _, StreamExt};
use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _};
use http_client::StatusCode;
use language::language_settings::FormatOnSave;
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseId, MessageContent, Role, SelectedModel,
};
use project::Project;
use prompt_store::{ProjectContext, WorktreeContext};
use rand::prelude::*;
use reqwest_client::ReqwestClient;
use serde::Serialize;
use settings::SettingsStore;
use std::{
fmt::{self, Display},
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
time::Duration,
};
use util::path;
#[derive(Clone)]
struct EvalInput {
conversation: Vec<LanguageModelRequestMessage>,
input_file_path: PathBuf,
input_content: Option<String>,
expected_output_content: String,
}
impl EvalInput {
fn new(
conversation: Vec<LanguageModelRequestMessage>,
input_file_path: impl Into<PathBuf>,
input_content: Option<String>,
expected_output_content: String,
) -> Self {
Self {
conversation,
input_file_path: input_file_path.into(),
input_content,
expected_output_content,
}
}
}
struct WriteEvalOutput {
tool_input: WriteFileToolInput,
text_after: String,
}
impl Display for WriteEvalOutput {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "Tool Input:\n{:#?}", self.tool_input)?;
writeln!(f, "Text After:\n{}", self.text_after)?;
Ok(())
}
}
struct WriteToolTest {
fs: Arc<FakeFs>,
project: Entity<Project>,
model: Arc<dyn LanguageModel>,
model_thinking_effort: Option<String>,
}
impl WriteToolTest {
async fn new(cx: &mut TestAppContext) -> Self {
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings
.project
.all_languages
.defaults
.ensure_final_newline_on_save = Some(false);
settings.project.all_languages.defaults.format_on_save =
Some(FormatOnSave::Off);
});
});
gpui_tokio::init(cx);
let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap());
cx.set_http_client(http_client);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(cx);
RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
language_models::init(user_store, client, cx);
});
fs.insert_tree("/root", serde_json::json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let agent_model = SelectedModel::from_str(
&std::env::var("ZED_AGENT_MODEL")
.unwrap_or("anthropic/claude-sonnet-4-6-latest".into()),
)
.unwrap();
let authenticate_provider_tasks = cx.update(|cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry
.providers()
.iter()
.map(|p| p.authenticate(cx))
.collect::<Vec<_>>()
})
});
let model = cx
.update(|cx| {
cx.spawn(async move |cx| {
futures::future::join_all(authenticate_provider_tasks).await;
Self::load_model(&agent_model, cx).await.unwrap()
})
})
.await;
let model_thinking_effort = model
.default_effort_level()
.map(|effort_level| effort_level.value.to_string());
Self {
fs,
project,
model,
model_thinking_effort,
}
}
async fn load_model(
selected_model: &SelectedModel,
cx: &mut AsyncApp,
) -> Result<Arc<dyn LanguageModel>> {
cx.update(|cx| {
let registry = LanguageModelRegistry::read_global(cx);
let provider = registry
.provider(&selected_model.provider)
.expect("Provider not found");
provider.authenticate(cx)
})
.await?;
Ok(cx.update(|cx| {
let models = LanguageModelRegistry::read_global(cx);
models
.available_models(cx)
.find(|model| {
model.provider_id() == selected_model.provider
&& model.id() == selected_model.model
})
.unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0))
}))
}
async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result<WriteEvalOutput> {
eval.conversation
.last_mut()
.context("Conversation must not be empty")?
.cache = true;
if let Some(input_content) = eval.input_content.as_deref() {
let abs_path = Path::new("/root").join(
eval.input_file_path
.strip_prefix("root")
.unwrap_or(&eval.input_file_path),
);
self.fs.insert_file(&abs_path, input_content.into()).await;
cx.run_until_parked();
}
let tools = crate::built_in_tools().collect::<Vec<_>>();
let system_prompt = {
let worktrees = vec![WorktreeContext {
root_name: "root".to_string(),
abs_path: Path::new("/path/to/root").into(),
rules_file: None,
}];
let project_context = ProjectContext::new(worktrees, Vec::default());
let tool_names = tools
.iter()
.map(|tool| tool.name.clone().into())
.collect::<Vec<_>>();
let template = crate::SystemPromptTemplate {
project: &project_context,
available_tools: tool_names,
model_name: None,
};
let templates = Templates::new();
template.render(&templates)?
};
let messages = [LanguageModelRequestMessage {
role: Role::System,
content: vec![MessageContent::Text(system_prompt)],
cache: true,
reasoning_details: None,
}]
.into_iter()
.chain(eval.conversation)
.collect::<Vec<_>>();
let request = LanguageModelRequest {
messages,
tools,
thinking_allowed: true,
thinking_effort: self.model_thinking_effort.clone(),
..Default::default()
};
let tool_input =
retry_on_rate_limit(async || self.extract_tool_use(request.clone(), cx).await).await?;
let language_registry = self
.project
.read_with(cx, |project, _cx| project.languages().clone());
let context_server_registry = cx
.new(|cx| ContextServerRegistry::new(self.project.read(cx).context_server_store(), cx));
let thread = cx.new(|cx| {
Thread::new(
self.project.clone(),
cx.new(|_cx| ProjectContext::default()),
context_server_registry,
Templates::new(),
Some(self.model.clone()),
cx,
)
});
let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
let tool = Arc::new(WriteFileTool::new(
self.project.clone(),
thread.downgrade(),
action_log,
language_registry,
));
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(tool_input.clone()),
ToolCallEventStream::test().0,
cx,
)
})
.await;
let output = match result {
Ok(output) => output,
Err(output) => anyhow::bail!("Tool returned error: {}", output),
};
let crate::EditFileToolOutput::Success { new_text, .. } = &output else {
anyhow::bail!("Tool returned error output: {}", output);
};
if tool_input.path != eval.input_file_path {
anyhow::bail!(
"Tool path mismatch. Expected {:?}, got {:?}",
eval.input_file_path,
tool_input.path,
);
}
if new_text != &eval.expected_output_content {
anyhow::bail!(
"Output content mismatch. Expected {:?}, got {:?}",
eval.expected_output_content,
new_text,
);
}
Ok(WriteEvalOutput {
tool_input,
text_after: new_text.clone(),
})
}
async fn extract_tool_use(
&self,
request: LanguageModelRequest,
cx: &mut TestAppContext,
) -> Result<WriteFileToolInput> {
let model = self.model.clone();
let events = cx
.update(|cx| {
let async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move { model.stream_completion(request, &async_cx).await })
})
.await
.map_err(|err| anyhow::anyhow!("completion error: {}", err))?;
let mut streamed_text = String::new();
let mut stop_reason = None;
let mut parse_errors = Vec::new();
let mut events = events.fuse();
while let Some(event) = events.next().await {
match event {
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.is_input_complete
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
{
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
.context("Failed to parse tool input as WriteFileToolInput")?;
return Ok(input);
}
Ok(LanguageModelCompletionEvent::Text(text)) => {
if streamed_text.len() < 2_000 {
streamed_text.push_str(&text);
}
}
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
stop_reason = Some(reason);
}
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
tool_name,
raw_input,
json_parse_error,
..
}) if tool_name.as_ref() == WriteFileTool::NAME => {
parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}"));
}
Err(err) => return Err(anyhow::anyhow!("completion error: {}", err)),
_ => {}
}
}
let streamed_text = streamed_text.trim();
let streamed_text_suffix = if streamed_text.is_empty() {
String::new()
} else {
format!("\nStreamed text:\n{streamed_text}")
};
let stop_reason_suffix = stop_reason
.map(|reason| format!("\nStop reason: {reason:?}"))
.unwrap_or_default();
let parse_errors_suffix = if parse_errors.is_empty() {
String::new()
} else {
format!("\nTool parse errors:\n{}", parse_errors.join("\n"))
};
anyhow::bail!(
"Stream ended without a write_file tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}"
)
}
}
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
super::run_gpui_eval(
|cx| {
async move {
let test = WriteToolTest::new(cx).await;
let result = test.eval(eval, cx).await;
drop(test);
cx.run_until_parked();
result
}
.boxed_local()
},
|_| eval_utils::OutcomeKind::Passed,
)
}
fn message(
role: Role,
content: impl IntoIterator<Item = MessageContent>,
) -> LanguageModelRequestMessage {
LanguageModelRequestMessage {
role,
content: content.into_iter().collect(),
cache: false,
reasoning_details: None,
}
}
fn text(text: impl Into<String>) -> MessageContent {
MessageContent::Text(text.into())
}
fn tool_use(
id: impl Into<Arc<str>>,
name: impl Into<Arc<str>>,
input: impl Serialize,
) -> MessageContent {
MessageContent::ToolUse(LanguageModelToolUse {
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
is_input_complete: true,
thought_signature: None,
})
}
fn tool_result(
id: impl Into<Arc<str>>,
name: impl Into<Arc<str>>,
result: impl Into<Arc<str>>,
) -> MessageContent {
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: LanguageModelToolUseId::from(id.into()),
tool_name: name.into(),
is_error: false,
content: vec![LanguageModelToolResultContent::Text(result.into())],
output: None,
})
}
async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
const MAX_RETRIES: usize = 20;
let mut attempt = 0;
loop {
attempt += 1;
let response = request().await;
if attempt >= MAX_RETRIES {
return response;
}
let retry_delay = match &response {
Ok(_) => None,
Err(err) => match err.downcast_ref::<LanguageModelCompletionError>() {
Some(err) => match &err {
LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
| LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
}
LanguageModelCompletionError::UpstreamProviderError {
status,
retry_after,
..
} => {
let should_retry = matches!(
*status,
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
) || status.as_u16() == 529;
if should_retry {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
} else {
None
}
}
LanguageModelCompletionError::ApiReadResponseError { .. }
| LanguageModelCompletionError::ApiInternalServerError { .. }
| LanguageModelCompletionError::HttpSend { .. } => {
Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
}
_ => None,
},
_ => None,
},
};
if let Some(retry_after) = retry_delay {
let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0));
eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
#[allow(clippy::disallowed_methods)]
async_io::Timer::after(retry_after + jitter).await;
} else {
return response;
}
}
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_create_file() {
let input_file_path = "root/TODO3";
let expected_output_content = "todo".to_string();
eval_utils::eval(100, 1., eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![
message(
User,
[text("Create a third todo file. Write 'todo' inside it.")],
),
message(
Assistant,
[
text(indoc::formatdoc! {"
I'll help you create a third empty todo file.
First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
"}),
tool_use(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
ListDirectoryToolInput {
path: "root".to_string(),
},
),
],
),
message(
User,
[tool_result(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
"root/TODO\nroot/TODO2\nroot/new.txt\n",
)],
),
],
input_file_path,
None,
expected_output_content.clone(),
))
});
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_overwrite_file() {
let input_file_path = "root/notes.txt";
let input_file_content = "old notes\nkeep nothing\n".to_string();
let expected_output_content = "new notes".to_string();
eval_utils::eval(100, 1., eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![message(
User,
[text(indoc::formatdoc! {"
Overwrite `{input_file_path}` so that its complete contents are exactly: 'new notes'
"})],
)],
input_file_path,
Some(input_file_content.clone()),
expected_output_content.clone(),
))
});
}

View file

@ -184,6 +184,13 @@ impl AgentTool for ReadFileTool {
anyhow::Ok(())
}).map_err(tool_content_err)?;
if fs.is_dir(&abs_path).await {
return Err(tool_content_err(format!(
"{} is a directory, not a file. Use the list_directory tool to explore directory contents.",
&input.path
)));
}
if let Some(canonical_target) = &symlink_canonical_target {
let authorize = cx.update(|cx| {
authorize_symlink_access(
@ -356,6 +363,39 @@ mod test {
use std::sync::Arc;
use util::path;
#[gpui::test]
async fn test_read_directory_path(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"some_dir": {}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
let (event_stream, _) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/some_dir".to_string(),
start_line: None,
end_line: None,
};
tool.run(ToolInput::resolved(input), event_stream, cx)
})
.await;
assert_eq!(
error_text(result.unwrap_err()),
"root/some_dir is a directory, not a file. Use the list_directory tool to explore directory contents."
);
}
#[gpui::test]
async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -1,673 +0,0 @@
use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
path_has_symlink_escape, resolve_project_path, sensitive_settings_kind,
};
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use collections::FxHashSet;
use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
/// Discards unsaved changes in open buffers by reloading file contents from disk.
///
/// Use this tool when:
/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
/// - You want to reset files to the on-disk state before retrying an edit.
///
/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RestoreFileFromDiskToolInput {
/// The paths of the files to restore from disk.
pub paths: Vec<PathBuf>,
}
pub struct RestoreFileFromDiskTool {
project: Entity<Project>,
}
impl RestoreFileFromDiskTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for RestoreFileFromDiskTool {
type Input = RestoreFileFromDiskToolInput;
type Output = String;
const NAME: &'static str = "restore_file_from_disk";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
Err(_) => "Restore files from disk".into(),
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String, String>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
// Check for any immediate deny before doing async work.
for path in &input.paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
if let ToolPermissionDecision::Deny(reason) = decision {
return Err(reason);
}
}
let input_paths = input.paths;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
let mut confirmation_paths: Vec<String> = Vec::new();
for path in &input_paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
match decision {
ToolPermissionDecision::Allow => {
if !symlink_escape {
let is_sensitive = super::tool_permissions::is_sensitive_settings_path(
Path::new(&*path_str),
fs.as_ref(),
)
.await;
if is_sensitive {
confirmation_paths.push(path_str.to_string());
}
}
}
ToolPermissionDecision::Deny(reason) => {
return Err(reason);
}
ToolPermissionDecision::Confirm => {
if !symlink_escape {
confirmation_paths.push(path_str.to_string());
}
}
}
}
if !confirmation_paths.is_empty() {
let title = if confirmation_paths.len() == 1 {
format!(
"Restore {} from disk",
MarkdownInlineCode(&confirmation_paths[0])
)
} else {
let paths: Vec<_> = confirmation_paths
.iter()
.take(3)
.map(|p| p.as_str())
.collect();
if confirmation_paths.len() > 3 {
format!(
"Restore {}, and {} more from disk",
paths.join(", "),
confirmation_paths.len() - 3
)
} else {
format!("Restore {} from disk", paths.join(", "))
}
};
let mut settings_kind = None;
for p in &confirmation_paths {
if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await {
settings_kind = Some(kind);
break;
}
}
let context = crate::ToolPermissionContext::new(Self::NAME, confirmation_paths);
let authorize = cx.update(|cx| {
authorize_with_sensitive_settings(
settings_kind,
context,
&title,
&event_stream,
cx,
)
});
authorize.await.map_err(|e| e.to_string())?;
}
let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut restored_paths: Vec<PathBuf> = Vec::new();
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
let mut reload_errors: Vec<String> = Vec::new();
for path in input_paths {
let project_path = match project.read_with(cx, |project, cx| {
resolve_project_path(project, &path, &canonical_roots, cx)
}) {
Ok(resolved) => {
let (project_path, symlink_canonical_target) = match resolved {
ResolvedProjectPath::Safe(path) => (path, None),
ResolvedProjectPath::SymlinkEscape {
project_path,
canonical_target,
} => (project_path, Some(canonical_target)),
};
if let Some(canonical_target) = &symlink_canonical_target {
let path_str = path.to_string_lossy();
let authorize_task = cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&path_str,
canonical_target,
&event_stream,
cx,
)
});
let result = authorize_task.await;
if let Err(err) = result {
reload_errors.push(format!("{}: {}", path.to_string_lossy(), err));
continue;
}
}
project_path
}
Err(_) => {
not_found_paths.push(path);
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = futures::select! {
result = open_buffer_task.fuse() => {
match result {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
}
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Restore cancelled by user".to_string());
}
};
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
if is_dirty {
buffers_to_reload.insert(buffer);
restored_paths.push(path);
} else {
clean_paths.push(path);
}
}
if !buffers_to_reload.is_empty() {
let reload_task = project.update(cx, |project, cx| {
project.reload_buffers(buffers_to_reload, true, cx)
});
let result = futures::select! {
result = reload_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Restore cancelled by user".to_string());
}
};
if let Err(error) = result {
reload_errors.push(error.to_string());
}
}
let mut lines: Vec<String> = Vec::new();
if !restored_paths.is_empty() {
lines.push(format!("Restored {} file(s).", restored_paths.len()));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !dirty_check_errors.is_empty() {
lines.push(format!(
"Dirty check failed ({}):",
dirty_check_errors.len()
));
for (path, error) in &dirty_check_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !reload_errors.is_empty() {
lines.push(format!("Reload failed ({}):", reload_errors.len()));
for error in &reload_errors {
lines.push(format!("- {}", error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs as _;
use gpui::TestAppContext;
use language::LineEnding;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
AgentSettings::override_global(settings, cx);
});
}
#[gpui::test]
async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
// Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before restore"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention restored + clean.
assert!(
output.contains("Restored 1 file(s)."),
"expected restored count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should be restored back to disk content and become clean.
let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(
dirty_text, "on disk: dirty\n",
"dirty.txt buffer should be restored to disk contents"
);
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after restore"
);
// Disk contents should be unchanged (restore-from-disk should not write).
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(disk_dirty, "on disk: dirty\n");
// Sanity: clean buffer should remain clean and unchanged.
let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(clean_text, "on disk: clean\n");
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should remain clean"
);
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case (path outside the project root).
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
}
#[gpui::test]
async fn test_restore_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let _result = task.await;
}
#[gpui::test]
async fn test_restore_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
"restore_file_from_disk".into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Deny),
..Default::default()
},
);
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deny policy should not emit symlink authorization prompt",
);
}
#[gpui::test]
async fn test_restore_file_symlink_escape_confirm_requires_single_approval(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Expected a single authorization prompt",
);
let _result = task.await;
}
}

View file

@ -1,756 +0,0 @@
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use collections::FxHashSet;
use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
path_has_symlink_escape, resolve_project_path, sensitive_settings_kind,
};
use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
/// Saves files that have unsaved changes.
///
/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
/// Only use this tool after asking the user for permission to save their unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SaveFileToolInput {
/// The paths of the files to save.
pub paths: Vec<PathBuf>,
}
pub struct SaveFileTool {
project: Entity<Project>,
}
impl SaveFileTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for SaveFileTool {
type Input = SaveFileToolInput;
type Output = String;
const NAME: &'static str = "save_file";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Save file".into(),
Ok(input) => format!("Save {} files", input.paths.len()).into(),
Err(_) => "Save files".into(),
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String, String>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
// Check for any immediate deny before doing async work.
for path in &input.paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
if let ToolPermissionDecision::Deny(reason) = decision {
return Err(reason);
}
}
let input_paths = input.paths;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
let mut confirmation_paths: Vec<String> = Vec::new();
for path in &input_paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
match decision {
ToolPermissionDecision::Allow => {
if !symlink_escape {
let is_sensitive = super::tool_permissions::is_sensitive_settings_path(
Path::new(&*path_str),
fs.as_ref(),
)
.await;
if is_sensitive {
confirmation_paths.push(path_str.to_string());
}
}
}
ToolPermissionDecision::Deny(reason) => {
return Err(reason);
}
ToolPermissionDecision::Confirm => {
if !symlink_escape {
confirmation_paths.push(path_str.to_string());
}
}
}
}
if !confirmation_paths.is_empty() {
let title = if confirmation_paths.len() == 1 {
format!("Save {}", MarkdownInlineCode(&confirmation_paths[0]))
} else {
let paths: Vec<_> = confirmation_paths
.iter()
.take(3)
.map(|p| p.as_str())
.collect();
if confirmation_paths.len() > 3 {
format!(
"Save {}, and {} more",
paths.join(", "),
confirmation_paths.len() - 3
)
} else {
format!("Save {}", paths.join(", "))
}
};
let mut settings_kind = None;
for p in &confirmation_paths {
if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await {
settings_kind = Some(kind);
break;
}
}
let context =
crate::ToolPermissionContext::new(Self::NAME, confirmation_paths.clone());
let authorize = cx.update(|cx| {
authorize_with_sensitive_settings(
settings_kind,
context,
&title,
&event_stream,
cx,
)
});
authorize.await.map_err(|e| e.to_string())?;
}
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut dirty_count: usize = 0;
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let mut authorization_errors: Vec<(PathBuf, String)> = Vec::new();
let mut save_errors: Vec<(String, String)> = Vec::new();
for path in input_paths {
let project_path = match project.read_with(cx, |project, cx| {
resolve_project_path(project, &path, &canonical_roots, cx)
}) {
Ok(resolved) => {
let (project_path, symlink_canonical_target) = match resolved {
ResolvedProjectPath::Safe(path) => (path, None),
ResolvedProjectPath::SymlinkEscape {
project_path,
canonical_target,
} => (project_path, Some(canonical_target)),
};
if let Some(canonical_target) = &symlink_canonical_target {
let path_str = path.to_string_lossy();
let authorize_task = cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&path_str,
canonical_target,
&event_stream,
cx,
)
});
let result = authorize_task.await;
if let Err(err) = result {
authorization_errors.push((path.clone(), err.to_string()));
continue;
}
}
project_path
}
Err(_) => {
not_found_paths.push(path);
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = futures::select! {
result = open_buffer_task.fuse() => {
match result {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
}
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Save cancelled by user".to_string());
}
};
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
if is_dirty {
buffers_to_save.insert(buffer);
dirty_count += 1;
} else {
clean_paths.push(path);
}
}
// Save each buffer individually since there's no batch save API.
for buffer in buffers_to_save {
let path_for_buffer = buffer
.read_with(cx, |buffer, _| {
buffer
.file()
.map(|file| file.path().to_rel_path_buf())
.map(|path| path.as_rel_path().as_unix_str().to_owned())
})
.unwrap_or_else(|| "<unknown>".to_string());
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
let save_result = futures::select! {
result = save_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Save cancelled by user".to_string());
}
};
if let Err(error) = save_result {
save_errors.push((path_for_buffer, error.to_string()));
}
}
let mut lines: Vec<String> = Vec::new();
let successful_saves = dirty_count.saturating_sub(save_errors.len());
if successful_saves > 0 {
lines.push(format!("Saved {} file(s).", successful_saves));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !authorization_errors.is_empty() {
lines.push(format!(
"Authorization failed ({}):",
authorization_errors.len()
));
for (path, error) in &authorization_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !save_errors.is_empty() {
lines.push(format!("Save failed ({}):", save_errors.len()));
for (path, error) in &save_errors {
lines.push(format!("- {}: {}", path, error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs as _;
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
AgentSettings::override_global(settings, cx);
});
}
#[gpui::test]
async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(SaveFileTool::new(project.clone()));
// Make dirty.txt dirty in-memory.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before save"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention saved + clean.
assert!(
output.contains("Saved 1 file(s)."),
"expected saved count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should now be clean and disk should have new content.
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after save"
);
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(
disk_dirty, "in memory: dirty\n",
"dirty.txt disk content should be updated"
);
// Sanity: clean buffer should remain clean and disk unchanged.
let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
assert_eq!(disk_clean, "on disk: clean\n");
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
}
#[gpui::test]
async fn test_save_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let _result = task.await;
}
#[gpui::test]
async fn test_save_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
"save_file".into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Deny),
..Default::default()
},
);
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deny policy should not emit symlink authorization prompt",
);
}
#[gpui::test]
async fn test_save_file_symlink_escape_confirm_requires_single_approval(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Expected a single authorization prompt",
);
let _result = task.await;
}
#[gpui::test]
async fn test_save_file_symlink_denial_does_not_reduce_success_count(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"dirty.txt": "on disk value\n",
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("project/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory value\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt should be dirty before save"
);
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("project/dirty.txt"),
PathBuf::from("project/link.txt"),
],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("deny"),
acp::PermissionOptionKind::RejectOnce,
))
.unwrap();
let output = task.await.unwrap();
assert!(
output.contains("Saved 1 file(s)."),
"Expected successful save count to remain accurate, got:\n{output}",
);
assert!(
output.contains("Authorization failed (1):"),
"Expected authorization failure section, got:\n{output}",
);
assert!(
!output.contains("Save failed"),
"Authorization denials should not be counted as save failures, got:\n{output}",
);
}
}

View file

@ -34,11 +34,16 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
///
/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
///
/// The terminal emulator is an interactive pty, so commands may block waiting for user input.
/// Some commands can be configured not to do this, such as `git --no-pager diff` and similar.
/// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:
///
/// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`).
/// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`).
/// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct TerminalToolInput {
/// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user.
///
/// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
pub command: String,
/// Working directory for the command. This must be one of the root directories of the project.
pub cd: String,

View file

@ -2,6 +2,7 @@ use crate::{
Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision,
decide_permission_for_path,
};
use agent_client_protocol::schema as acp;
use anyhow::{Result, anyhow};
use fs::Fs;
use gpui::{App, Entity, Task, WeakEntity};
@ -521,6 +522,91 @@ pub fn authorize_file_edit(
})
}
/// The user's choice when prompted about how to handle unsaved changes
/// in a buffer that the agent wants to edit or overwrite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyBufferDecision {
/// Save the buffer's pending edits to disk, then proceed.
/// (Edit-mode prompt only.)
Save,
/// Discard the buffer's pending edits (reload from disk), then proceed.
Discard,
/// Keep the buffer's pending edits and cancel the agent's operation.
/// (Overwrite-mode prompt only.)
Keep,
}
/// Which prompt to show when the agent encounters a dirty buffer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyBufferPromptKind {
/// The agent wants to apply targeted edits on top of the current
/// content. Offers Save (persist edits, then edit on top) vs Discard
/// (revert to disk, then edit).
Edit,
/// The agent wants to overwrite the file's entire contents. Offers
/// Keep (cancel the overwrite to preserve the user's work) vs
/// Discard (reload from disk and let the agent overwrite).
Overwrite,
}
/// Prompts the user about how to handle a dirty buffer that the agent
/// wants to edit or overwrite. Returns the chosen action; the caller is
/// responsible for actually performing the corresponding side effect
/// (save / reload / cancel) before continuing.
pub fn authorize_dirty_buffer(
kind: DirtyBufferPromptKind,
event_stream: &ToolCallEventStream,
cx: &mut App,
) -> Task<Result<DirtyBufferDecision>> {
let (message, options) = match kind {
DirtyBufferPromptKind::Edit => (
"This file has unsaved changes. Do you want to save or discard them \
before the agent continues editing?"
.to_string(),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new("save"),
"Save",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new("discard"),
"Discard",
acp::PermissionOptionKind::RejectOnce,
),
],
),
DirtyBufferPromptKind::Overwrite => (
"This file has unsaved changes and the agent wants to overwrite it.".to_string(),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new("discard"),
"Overwrite",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new("keep"),
"Cancel",
acp::PermissionOptionKind::RejectOnce,
),
],
),
};
let prompt = event_stream.prompt_for_decision(None, Some(message), options, cx);
cx.spawn(async move |_cx| {
let option_id = prompt.await?;
match option_id.0.as_ref() {
"save" => Ok(DirtyBufferDecision::Save),
"discard" => Ok(DirtyBufferDecision::Discard),
"keep" => Ok(DirtyBufferDecision::Keep),
other => Err(anyhow!(
"Unexpected dirty-buffer decision option_id: {other}"
)),
}
})
}
#[cfg(test)]
mod tests {
use super::*;

File diff suppressed because it is too large Load diff

View file

@ -413,6 +413,7 @@ fn enqueue_notification<Notif>(
pub struct AcpConnection {
id: AgentId,
telemetry_id: SharedString,
agent_version: Option<SharedString>,
connection: ConnectionTo<Agent>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
pending_sessions: Rc<RefCell<HashMap<acp::SessionId, PendingAcpSession>>>,
@ -900,12 +901,15 @@ impl AcpConnection {
}
});
let telemetry_id = response
.agent_info
let agent_info = response.agent_info;
let telemetry_id = agent_info
.as_ref()
// Use the one the agent provides if we have one
.map(|info| info.name.into())
.map(|info| SharedString::from(info.name.clone()))
// Otherwise, just use the name
.unwrap_or_else(|| agent_id.0.clone());
let agent_version = agent_info
.and_then(|info| (!info.version.is_empty()).then(|| SharedString::from(info.version)));
let session_list = if response
.agent_capabilities
@ -945,6 +949,7 @@ impl AcpConnection {
agent_server_store,
connection,
telemetry_id,
agent_version,
sessions,
pending_sessions: Rc::new(RefCell::new(HashMap::default())),
agent_capabilities: response.agent_capabilities,
@ -978,6 +983,7 @@ impl AcpConnection {
Self {
id: AgentId::new("test"),
telemetry_id: "test".into(),
agent_version: None,
connection,
sessions,
pending_sessions: Rc::new(RefCell::new(HashMap::default())),
@ -1319,6 +1325,10 @@ impl AgentConnection for AcpConnection {
self.telemetry_id.clone()
}
fn agent_version(&self) -> Option<SharedString> {
self.agent_version.clone()
}
fn new_session(
self: Rc<Self>,
project: Entity<Project>,
@ -1984,6 +1994,10 @@ pub mod test_support {
self.inner.telemetry_id()
}
fn agent_version(&self) -> Option<SharedString> {
self.inner.agent_version()
}
fn new_session(
self: Rc<Self>,
project: Entity<Project>,
@ -3345,6 +3359,7 @@ fn handle_request_permission(
thread.request_tool_call_authorization(
args.tool_call,
acp_thread::PermissionOptions::Flat(args.options),
acp_thread::AuthorizationKind::PermissionGrant,
cx,
)
})

View file

@ -17,7 +17,7 @@ use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable,
ScrollHandle, Subscription, Task, WeakEntity,
ScrollHandle, Subscription, Task, TaskExt, WeakEntity,
};
use itertools::Itertools;
use language::LanguageRegistry;
@ -1135,10 +1135,13 @@ impl AgentConfiguration {
id: agent_server_name.clone(),
};
let connection_status = self
.agent_connection_store
.read(cx)
.connection_status(&agent, cx);
let (connection_status, running_version) = {
let connection_store = self.agent_connection_store.read(cx);
(
connection_store.connection_status(&agent, cx),
connection_store.agent_version(&agent, cx),
)
};
let restart_button = matches!(
connection_status,
@ -1252,6 +1255,7 @@ impl AgentConfiguration {
AiSettingItem::new(id, display_name, status, source_kind)
.icon(icon)
.when_some(running_version, |this, version| this.detail_label(version))
.when_some(restart_button, |this, button| this.action(button))
.when_some(uninstall_button, |this, button| this.action(button))
}

View file

@ -4,7 +4,7 @@ use anyhow::Result;
use collections::HashSet;
use fs::Fs;
use gpui::{
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt,
};
use language_model::LanguageModelRegistry;
use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities};

View file

@ -5,7 +5,8 @@ use editor::{Editor, EditorElement, EditorStyle};
use gpui::{
AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle,
Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity,
prelude::*,
};
use language::{Language, LanguageRegistry};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};

View file

@ -218,6 +218,11 @@ impl ManageProfilesModal {
window: &mut Window,
cx: &mut Context<Self>,
) {
telemetry::event!(
"Agent Profile Default Model Configured",
profile_id = profile_id.as_str(),
is_builtin = builtin_profiles::is_builtin(&profile_id)
);
let fs = self.fs.clone();
let profile_id_for_closure = profile_id.clone();
@ -314,6 +319,11 @@ impl ManageProfilesModal {
window: &mut Window,
cx: &mut Context<Self>,
) {
telemetry::event!(
"Agent Profile MCPs Configured",
profile_id = profile_id.as_str(),
is_builtin = builtin_profiles::is_builtin(&profile_id)
);
let settings = AgentSettings::get_global(cx);
let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
return;
@ -350,6 +360,11 @@ impl ManageProfilesModal {
window: &mut Window,
cx: &mut Context<Self>,
) {
telemetry::event!(
"Agent Profile Tools Configured",
profile_id = profile_id.as_str(),
is_builtin = builtin_profiles::is_builtin(&profile_id)
);
let settings = AgentSettings::get_global(cx);
let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
return;
@ -398,9 +413,16 @@ impl ManageProfilesModal {
Mode::ChooseProfile { .. } => {}
Mode::NewProfile(mode) => {
let name = mode.name_editor.read(cx).text(cx);
let base_profile_id = mode.base_profile_id.clone();
let profile_id =
AgentProfile::create(name, mode.base_profile_id.clone(), self.fs.clone(), cx);
AgentProfile::create(name, base_profile_id.clone(), self.fs.clone(), cx);
telemetry::event!(
"Agent Profile Created",
profile_id = profile_id.as_str(),
is_fork = base_profile_id.is_some(),
base_profile_id = base_profile_id.as_ref().map(|id| id.as_str())
);
self.view_profile(profile_id, window, cx);
}
Mode::ViewProfile(_) => {}
@ -421,6 +443,8 @@ impl ManageProfilesModal {
return;
}
telemetry::event!("Agent Profile Deleted", profile_id = profile_id.as_str());
let fs = self.fs.clone();
update_settings_file(fs, cx, move |settings, _cx| {

View file

@ -97,6 +97,13 @@ impl AgentConnectionStore {
.unwrap_or(AgentConnectionStatus::Disconnected)
}
pub fn agent_version(&self, key: &Agent, cx: &App) -> Option<SharedString> {
match self.entries.get(key)?.read(cx) {
AgentConnectionEntry::Connected(state) => state.connection.agent_version(),
AgentConnectionEntry::Connecting { .. } | AgentConnectionEntry::Error { .. } => None,
}
}
pub fn active_acp_connections(&self, cx: &App) -> Vec<ActiveAcpConnection> {
self.entries
.values()

View file

@ -15,7 +15,7 @@ use editor::{
use gpui::{
Action, AnyElement, App, AppContext, Empty, Entity, EventEmitter, FocusHandle, Focusable,
Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
Global, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, prelude::*,
};
use language::{Buffer, Capability, OffsetRangeExt, Point};

File diff suppressed because it is too large Load diff

View file

@ -61,7 +61,9 @@ use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore};
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads};
pub use crate::agent_panel::{
AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId,
};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use crate::thread_metadata_store::ThreadId;

View file

@ -12,7 +12,7 @@ use anyhow::Result;
use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH};
use futures::FutureExt as _;
use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
use gpui::{App, BackgroundExecutor, Entity, Focusable, SharedString, Task, WeakEntity, Window};
use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
use lsp::CompletionContext;
use multi_buffer::ToOffset as _;
@ -24,7 +24,7 @@ use project::{
};
use prompt_store::{PromptStore, UserPromptId};
use rope::Point;
use settings::{Settings, TerminalDockPosition};
use settings::Settings;
use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use text::{Anchor, ToOffset as _, ToPoint as _};
@ -35,11 +35,108 @@ use util::paths::PathStyle;
use util::rel_path::RelPath;
use util::truncate_and_remove_front;
use workspace::Workspace;
use workspace::dock::DockPosition;
use crate::AgentPanel;
use crate::mention_set::MentionSet;
#[derive(Clone)]
pub(crate) enum AgentContextSelection {
Editor(Vec<(Entity<Buffer>, Range<text::Anchor>)>),
Terminal(Vec<String>),
}
#[derive(Clone)]
pub(crate) enum AgentContextSource {
Editor(WeakEntity<Editor>),
TerminalView(WeakEntity<TerminalView>),
TerminalPanel,
}
impl AgentContextSource {
pub(crate) fn read_selection(
&self,
workspace: &Workspace,
include_current_line: bool,
cx: &mut App,
) -> Option<AgentContextSelection> {
match self {
Self::Editor(handle) => {
let editor = handle.upgrade()?;
let ranges = editor_selection_ranges(&editor, include_current_line, cx);
(!ranges.is_empty()).then_some(AgentContextSelection::Editor(ranges))
}
Self::TerminalView(handle) => {
let terminal_view = handle.upgrade()?;
terminal_view_selection(&terminal_view, cx)
.map(|text| AgentContextSelection::Terminal(vec![text]))
}
Self::TerminalPanel => {
let panel = workspace.panel::<TerminalPanel>(cx)?;
let selections = panel.read(cx).terminal_selections(cx);
(!selections.is_empty()).then_some(AgentContextSelection::Terminal(selections))
}
}
}
pub(crate) fn from_focused(workspace: &Workspace, window: &Window, cx: &App) -> Option<Self> {
if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
&& agent_panel.focus_handle(cx).contains_focused(window, cx)
{
return None;
}
if let Some(active_item) = workspace.active_item(cx) {
if let Some(editor) = active_item.act_as::<Editor>(cx) {
if editor.focus_handle(cx).is_focused(window) {
return Some(Self::Editor(editor.downgrade()));
}
} else if let Some(terminal_view) = active_item.act_as::<TerminalView>(cx)
&& terminal_view.focus_handle(cx).is_focused(window)
{
return Some(Self::TerminalView(terminal_view.downgrade()));
}
}
if let Some(panel) = workspace.panel::<TerminalPanel>(cx)
&& panel.focus_handle(cx).contains_focused(window, cx)
{
return Some(Self::TerminalPanel);
}
None
}
pub(crate) fn from_active(workspace: &Workspace, cx: &App) -> Option<Self> {
if let Some(active_item) = workspace.active_item(cx) {
if let Some(editor) = active_item.act_as::<Editor>(cx) {
return Some(Self::Editor(editor.downgrade()));
} else if let Some(terminal_view) = active_item.act_as::<TerminalView>(cx) {
return Some(Self::TerminalView(terminal_view.downgrade()));
}
}
if terminal_panel_dock_is_open(workspace, cx) {
return Some(Self::TerminalPanel);
}
None
}
pub(crate) fn exists(&self, workspace: &Workspace, cx: &App) -> bool {
match self {
Self::Editor(handle) => handle.upgrade().is_some(),
Self::TerminalView(handle) => handle.upgrade().is_some(),
Self::TerminalPanel => terminal_panel_dock_is_open(workspace, cx),
}
}
}
fn terminal_panel_dock_is_open(workspace: &Workspace, cx: &App) -> bool {
if workspace.panel::<TerminalPanel>(cx).is_none() {
return false;
}
let position = TerminalSettings::get_global(cx).dock.into();
workspace.dock_at_position(position).read(cx).is_open()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptContextEntry {
Mode(PromptContextType),
@ -267,14 +364,13 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
// inserted
confirm: Some(Arc::new(|_, _, _| true)),
}),
PromptContextEntry::Action(action) => Self::completion_for_action(
action,
source_range,
editor,
mention_set,
workspace,
cx,
),
PromptContextEntry::Action(action) => {
let selection = workspace.update(cx, |workspace, cx| {
AgentContextSource::from_active(workspace, cx)?
.read_selection(workspace, false, cx)
});
Self::completion_for_action(action, source_range, editor, mention_set, selection)
}
}
}
@ -542,136 +638,27 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
source_range: Range<Anchor>,
editor: WeakEntity<Editor>,
mention_set: WeakEntity<MentionSet>,
workspace: &Entity<Workspace>,
cx: &mut App,
selection: Option<AgentContextSelection>,
) -> Option<Completion> {
let (new_text, on_action) = match action {
PromptContextAction::AddSelections => {
// Collect non-empty editor selections
let editor_selections: Vec<_> = selection_ranges(workspace, cx)
.into_iter()
.filter(|(buffer, range)| {
let snapshot = buffer.read(cx).snapshot();
range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot)
})
.collect();
// Collect terminal selections from all terminal views if the terminal panel is visible
let terminal_selections: Vec<String> = terminal_selections(workspace, cx);
const EDITOR_PLACEHOLDER: &str = "selection ";
const TERMINAL_PLACEHOLDER: &str = "terminal ";
let selections = editor_selections
.into_iter()
.enumerate()
.map(|(ix, (buffer, range))| {
(
buffer,
range,
(EDITOR_PLACEHOLDER.len() * ix)
..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
)
})
.collect::<Vec<_>>();
let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len());
// Add terminal placeholders for each terminal selection
let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = terminal_selections
.into_iter()
.map(|text| {
let start = new_text.len();
new_text.push_str(TERMINAL_PLACEHOLDER);
(text, start..(new_text.len() - 1))
})
.collect();
let callback = Arc::new({
let source_range = source_range.clone();
move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
let editor = editor.clone();
let selections = selections.clone();
let mention_set = mention_set.clone();
let source_range = source_range.clone();
let terminal_ranges = terminal_ranges.clone();
window.defer(cx, move |window, cx| {
if let Some(editor) = editor.upgrade() {
// Insert editor selections
if !selections.is_empty() {
mention_set
.update(cx, |store, cx| {
store.confirm_mention_for_selection(
source_range.clone(),
selections,
editor.clone(),
window,
cx,
)
})
.ok();
}
// Insert terminal selections
for (terminal_text, terminal_range) in terminal_ranges {
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
let Some(start) =
snapshot.anchor_in_excerpt(source_range.start)
else {
return;
};
let offset = start.to_offset(&snapshot);
let line_count = terminal_text.lines().count() as u32;
let mention_uri = MentionUri::TerminalSelection { line_count };
let range = snapshot.anchor_after(offset + terminal_range.start)
..snapshot.anchor_after(offset + terminal_range.end);
let crease = crate::mention_set::crease_for_mention(
mention_uri.name().into(),
mention_uri.icon_path(cx),
None,
range,
editor.downgrade(),
);
let crease_id = editor.update(cx, |editor, cx| {
let crease_ids =
editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
crease_ids.first().copied().unwrap()
});
mention_set
.update(cx, |mention_set, _| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
gpui::Task::ready(Ok(
crate::mention_set::Mention::Text {
content: terminal_text,
tracked_buffers: vec![],
},
))
.shared(),
);
})
.ok();
}
}
});
false
}
});
(
new_text,
callback
as Arc<
dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync,
>,
)
}
PromptContextAction::AddSelections => match selection? {
AgentContextSelection::Editor(editor_selections) => {
completion_text_for_editor_selections(
source_range.clone(),
editor,
mention_set,
editor_selections,
)
}
AgentContextSelection::Terminal(terminal_selections) => {
completion_text_for_terminal_selections(
source_range.clone(),
editor,
mention_set,
terminal_selections,
)
}
},
};
Some(Completion {
@ -1166,19 +1153,12 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
}
let has_editor_selection = workspace
.read(cx)
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
.is_some_and(|editor| {
editor.update(cx, |editor, cx| {
editor.has_non_empty_selection(&editor.display_snapshot(cx))
})
});
let has_terminal_selection = !terminal_selections(workspace, cx).is_empty();
if has_editor_selection || has_terminal_selection {
let has_active_selection = workspace.update(cx, |workspace, cx| {
AgentContextSource::from_active(workspace, cx)
.and_then(|source| source.read_selection(workspace, false, cx))
.is_some()
});
if has_active_selection {
entries.push(PromptContextEntry::Action(
PromptContextAction::AddSelections,
));
@ -2168,81 +2148,219 @@ fn build_code_label_for_path(
label.build()
}
fn terminal_selections(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
let mut selections = Vec::new();
// Check if the active item is a terminal (in a panel or not)
if let Some(terminal_view) = workspace
fn terminal_view_selection(terminal_view: &Entity<TerminalView>, cx: &App) -> Option<String> {
terminal_view
.read(cx)
.active_item(cx)
.and_then(|item| item.act_as::<TerminalView>(cx))
{
if let Some(text) = terminal_view
.read(cx)
.terminal()
.read(cx)
.last_content
.selection_text
.clone()
.filter(|text| !text.is_empty())
{
selections.push(text);
}
}
if let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(cx) {
let position = match TerminalSettings::get_global(cx).dock {
TerminalDockPosition::Left => DockPosition::Left,
TerminalDockPosition::Bottom => DockPosition::Bottom,
TerminalDockPosition::Right => DockPosition::Right,
};
let dock_is_open = workspace
.read(cx)
.dock_at_position(position)
.read(cx)
.is_open();
if dock_is_open {
selections.extend(panel.read(cx).terminal_selections(cx));
}
}
selections
.terminal()
.read(cx)
.last_content
.selection_text
.clone()
.filter(|text| !text.is_empty())
}
fn selection_ranges(
workspace: &Entity<Workspace>,
fn editor_selection_ranges(
editor: &Entity<Editor>,
include_current_line: bool,
cx: &mut App,
) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
let Some(editor) = workspace
.read(cx)
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
else {
return Vec::new();
};
editor.update(cx, |editor, cx| {
let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
let buffer = editor.buffer().clone().read(cx);
let snapshot = buffer.snapshot(cx);
let multi_buffer = editor.buffer().read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
selections
.into_iter()
let non_empty_rows: collections::HashSet<u32> = selections
.iter()
.filter(|s| !s.is_empty())
.map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
.flat_map(|range| {
let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
if start_buffer != end_buffer {
return None;
.flat_map(|s| s.start.row..=s.end.row)
.collect();
let mut seen_current_line_rows = collections::HashSet::default();
let mut results = Vec::new();
for s in selections {
if s.is_empty() {
if !include_current_line
|| non_empty_rows.contains(&s.start.row)
|| !seen_current_line_rows.insert(s.start.row)
{
continue;
}
Some((start_buffer, start..end))
})
.collect::<Vec<_>>()
let Some((buffer, anchor)) = multi_buffer.text_anchor_for_position(s.start, cx)
else {
continue;
};
let buffer_snapshot = buffer.read(cx).snapshot();
let row = anchor.to_point(&buffer_snapshot).row;
let line_start = text::Point::new(row, 0);
let line_end = text::Point::new(row, buffer_snapshot.line_len(row));
let start = buffer_snapshot.anchor_after(line_start);
let end = buffer_snapshot.anchor_before(line_end);
if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) {
continue;
}
results.push((buffer, start..end));
} else {
let mb_start = multi_buffer_snapshot.anchor_after(s.start);
let mb_end = multi_buffer_snapshot.anchor_before(s.end);
let Some((start_buffer, start)) =
multi_buffer.text_anchor_for_position(mb_start, cx)
else {
continue;
};
let Some((end_buffer, end)) = multi_buffer.text_anchor_for_position(mb_end, cx)
else {
continue;
};
if start_buffer != end_buffer {
continue;
}
let buffer_snapshot = start_buffer.read(cx).snapshot();
if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) {
continue;
}
results.push((start_buffer, start..end));
}
}
results
})
}
type ConfirmCallback = Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync>;
fn completion_text_for_editor_selections(
source_range: Range<Anchor>,
editor: WeakEntity<Editor>,
mention_set: WeakEntity<MentionSet>,
editor_selections: Vec<(Entity<Buffer>, Range<text::Anchor>)>,
) -> (String, ConfirmCallback) {
const EDITOR_PLACEHOLDER: &str = "selection ";
let selections = editor_selections
.into_iter()
.enumerate()
.map(|(ix, (buffer, range))| {
(
buffer,
range,
(EDITOR_PLACEHOLDER.len() * ix)..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
)
})
.collect::<Vec<_>>();
let new_text = EDITOR_PLACEHOLDER.repeat(selections.len());
let callback: ConfirmCallback = Arc::new({
move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
let editor = editor.clone();
let selections = selections.clone();
let mention_set = mention_set.clone();
let source_range = source_range.clone();
window.defer(cx, move |window, cx| {
if let Some(editor) = editor.upgrade()
&& !selections.is_empty()
{
mention_set
.update(cx, |store, cx| {
store.confirm_mention_for_selection(
source_range.clone(),
selections,
editor.clone(),
window,
cx,
)
})
.ok();
}
});
false
}
});
(new_text, callback)
}
fn completion_text_for_terminal_selections(
source_range: Range<Anchor>,
editor: WeakEntity<Editor>,
mention_set: WeakEntity<MentionSet>,
terminal_selections: Vec<String>,
) -> (String, ConfirmCallback) {
const TERMINAL_PLACEHOLDER: &str = "terminal ";
let mut new_text = String::new();
let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = terminal_selections
.into_iter()
.map(|text| {
let start = new_text.len();
new_text.push_str(TERMINAL_PLACEHOLDER);
(text, start..(new_text.len() - 1))
})
.collect();
let callback: ConfirmCallback = Arc::new({
move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
let editor = editor.clone();
let mention_set = mention_set.clone();
let source_range = source_range.clone();
let terminal_ranges = terminal_ranges.clone();
window.defer(cx, move |window, cx| {
let Some(editor) = editor.upgrade() else {
return;
};
for (terminal_text, terminal_range) in terminal_ranges {
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
let Some(start) = snapshot.anchor_in_excerpt(source_range.start) else {
return;
};
let offset = start.to_offset(&snapshot);
let line_count = terminal_text.lines().count() as u32;
let mention_uri = MentionUri::TerminalSelection { line_count };
let range = snapshot.anchor_after(offset + terminal_range.start)
..snapshot.anchor_after(offset + terminal_range.end);
let crease = crate::mention_set::crease_for_mention(
mention_uri.name().into(),
mention_uri.icon_path(cx),
None,
range,
editor.downgrade(),
);
let Some(crease_id) = editor.update(cx, |editor, cx| {
let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
crease_ids.first().copied()
}) else {
log::error!("insert_creases returned no ids for terminal selection");
continue;
};
mention_set
.update(cx, |mention_set, _| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
Task::ready(Ok(crate::mention_set::Mention::Text {
content: terminal_text,
tracked_buffers: vec![],
}))
.shared(),
);
})
.ok();
}
});
false
}
});
(new_text, callback)
}
#[cfg(test)]
mod tests {
use super::*;
@ -2652,4 +2770,71 @@ mod tests {
"dir1/a.txt should be second"
);
}
#[gpui::test]
async fn test_source_read_selection_editor_whole_line(cx: &mut TestAppContext) {
use editor::Editor;
use project::Project;
use serde_json::json;
use text::ToOffset as _;
use util::path;
use workspace::{AppState, MultiWorkspace};
crate::conversation_view::tests::init_test(cx);
let app_state = cx.update(AppState::test);
app_state
.fs
.as_fake()
.insert_tree(path!("/root"), json!({ "a.txt": "" }))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let buffer = cx.new(|cx| language::Buffer::local("abc\ndef\nghi", cx));
let editor =
cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([text::Point::new(1, 1)..text::Point::new(1, 1)]);
});
});
let source = AgentContextSource::Editor(editor.downgrade());
workspace.update(cx, |workspace, cx| {
let selection = source
.read_selection(workspace, true, cx)
.expect("editor source with cursor on a line should yield a selection");
assert!(
matches!(selection, AgentContextSelection::Editor(_)),
"expected Editor variant"
);
if let AgentContextSelection::Editor(ranges) = selection {
assert_eq!(
ranges.len(),
1,
"expected exactly one range for whole-line fallback"
);
let (range_buffer, range) = &ranges[0];
let snapshot = range_buffer.read(cx).snapshot();
let start_offset = range.start.to_offset(&snapshot);
let end_offset = range.end.to_offset(&snapshot);
assert_eq!(
&snapshot.text()[start_offset..end_offset],
"def",
"whole-line fallback should capture the current row"
);
}
// With include_current_line = false and no non-empty selection, the
// fallback is suppressed and read_selection should return None.
assert!(source.read_selection(workspace, false, cx).is_none());
});
}
}

View file

@ -31,7 +31,7 @@ use futures::FutureExt as _;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TaskExt, TextStyle,
WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
list, point, pulsating_between,
};
@ -80,6 +80,7 @@ use crate::agent_connection_store::{
AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
};
use crate::agent_diff::AgentDiff;
use crate::completion_provider::AgentContextSelection;
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent};
use crate::profile_selector::{ProfileProvider, ProfileSelector};
@ -2707,10 +2708,10 @@ impl ConversationView {
&panel,
window,
move |this, _, event: &AgentPanelEvent, window, cx| match event {
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => {
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => {
dismiss_if_visible(this, window, cx);
}
AgentPanelEvent::RetainedThreadChanged
AgentPanelEvent::EntryChanged
| AgentPanelEvent::ThreadInteracted { .. } => {}
},
));
@ -2760,11 +2761,16 @@ impl ConversationView {
/// Inserts the selected text into the message editor or the message being
/// edited, if any.
pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn insert_selection(
&self,
selection: AgentContextSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(active_thread) = self.active_thread() {
active_thread.update(cx, |thread, cx| {
thread.active_editor(cx).update(cx, |editor, cx| {
editor.insert_selections(window, cx);
editor.insert_selections(selection, window, cx);
})
});
}
@ -2974,6 +2980,7 @@ pub(crate) mod tests {
use workspace::{Item, MultiWorkspace};
use crate::agent_panel;
use crate::completion_provider::AgentContextSource;
use crate::thread_metadata_store::ThreadMetadataStore;
use super::*;
@ -5903,7 +5910,14 @@ pub(crate) mod tests {
.and_then(|active| active.read(cx).editing_message),
Some(0)
);
view.insert_selections(window, cx);
let workspace = workspace.upgrade().unwrap();
let selection = workspace
.update(cx, |workspace, cx| {
AgentContextSource::from_active(workspace, cx)?
.read_selection(workspace, false, cx)
})
.unwrap();
view.insert_selection(selection, window, cx);
});
user_message_editor.read_with(cx, |editor, cx| {
@ -5966,7 +5980,14 @@ pub(crate) mod tests {
.and_then(|active| active.read(cx).editing_message),
None
);
view.insert_selections(window, cx);
let workspace = view.workspace.upgrade().unwrap();
let selection = workspace
.update(cx, |workspace, cx| {
AgentContextSource::from_active(workspace, cx)?
.read_selection(workspace, false, cx)
})
.unwrap();
view.insert_selection(selection, window, cx);
});
message_editor.read_with(cx, |editor, cx| {
@ -7099,6 +7120,7 @@ pub(crate) mod tests {
"Allow",
acp::PermissionOptionKind::AllowOnce,
)]),
acp_thread::AuthorizationKind::PermissionGrant,
cx,
)
.unwrap()

View file

@ -13,6 +13,7 @@ use feature_flags::AcpBetaFeatureFlag;
use crate::message_editor::SharedSessionCapabilities;
use gpui::List;
use gpui::TaskExt;
use heapless::Vec as ArrayVec;
use language_model::{LanguageModelEffortLevel, Speed};
use settings::{SidebarSide, update_settings_file};

View file

@ -35,8 +35,8 @@ use editor::{
use fs::Fs;
use futures::{FutureExt, channel::mpsc};
use gpui::{
App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal,
WeakEntity, Window, point,
App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, TaskExt,
UpdateGlobal, WeakEntity, Window, point,
};
use language::{Buffer, Point, Selection, TransactionId};
use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry};

View file

@ -170,6 +170,10 @@ impl MentionSet {
self.mentions.keys().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.mentions.is_empty()
}
pub fn mentions(&self) -> HashSet<MentionUri> {
self.mentions.values().map(|(uri, _)| uri.clone()).collect()
}
@ -178,6 +182,16 @@ impl MentionSet {
self.mentions.get(crease_id).map(|(uri, _)| uri.clone())
}
/// Returns the resolved mention for a crease, if any.
pub fn resolved_mention_for_crease(
&self,
crease_id: &CreaseId,
) -> Option<(MentionUri, Option<Mention>)> {
let (uri, task) = self.mentions.get(crease_id)?;
let mention = task.clone().now_or_never().and_then(|result| result.ok());
Some((uri.clone(), mention))
}
pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
self.mentions = mentions;
}

View file

@ -3,8 +3,8 @@ use crate::SendImmediately;
use crate::{
ChatWithFollow,
completion_provider::{
PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
PromptContextType, SlashCommandCompletion,
AgentContextSelection, PromptCompletionProvider, PromptCompletionProviderDelegate,
PromptContextAction, PromptContextType, SlashCommandCompletion,
},
mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
};
@ -15,14 +15,16 @@ use anyhow::{Result, anyhow};
use editor::{
Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
actions::{Copy, Paste},
actions::{Copy, Cut, Paste},
code_context_menus::CodeContextMenu,
display_map::{CreaseId, CreaseSnapshot},
scroll::Autoscroll,
};
use futures::{FutureExt as _, future::join_all};
use gpui::{
AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, TextStyle,
WeakEntity,
};
use language::{Buffer, language_settings::InlayHintKind};
use parking_lot::RwLock;
@ -33,7 +35,7 @@ use project::{
use prompt_store::PromptStore;
use rope::Point;
use settings::Settings;
use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc};
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
use theme_settings::ThemeSettings;
use ui::{ContextMenu, prelude::*};
use util::paths::PathStyle;
@ -676,7 +678,7 @@ impl MessageEditor {
}
pub fn is_empty(&self, cx: &App) -> bool {
self.editor.read(cx).is_empty(cx)
self.editor.read(cx).text(cx).trim().is_empty()
}
pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
@ -767,90 +769,46 @@ impl MessageEditor {
self.session_capabilities.read().supports_embedded_context();
cx.spawn(async move |_, cx| {
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
let result = editor.update(cx, |editor, cx| {
let mut contents = contents.await?;
Ok(editor.update(cx, |editor, cx| {
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
let (mut ix, _) = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.unwrap_or((0, '\0'));
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
editor.display_map.update(cx, |map, cx| {
let snapshot = map.snapshot(cx);
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
let Some((uri, mention)) = contents.get(&crease_id) else {
continue;
};
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
if crease_range.start.0 > ix {
let chunk = text[ix..crease_range.start.0].into();
chunks.push(chunk);
}
let chunk = match mention {
Mention::Text {
content,
tracked_buffers,
} => {
all_tracked_buffers.extend(tracked_buffers.iter().cloned());
if supports_embedded_context {
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(
content.clone(),
uri.to_uri().to_string(),
),
),
))
} else {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
))
}
}
Mention::Image(mention_image) => acp::ContentBlock::Image(
acp::ImageContent::new(
mention_image.data.clone(),
mention_image.format.mime_type(),
)
.uri(match uri {
MentionUri::File { .. } => Some(uri.to_uri().to_string()),
MentionUri::PastedImage { .. } => {
Some(uri.to_uri().to_string())
}
other => {
debug_panic!(
"unexpected mention uri for image: {:?}",
other
);
None
}
}),
),
Mention::Link => acp::ContentBlock::ResourceLink(
acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
),
};
chunks.push(chunk);
ix = crease_range.end.0;
}
if ix < text.len() {
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
});
anyhow::Ok((chunks, all_tracked_buffers))
})?;
Ok(result)
build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| {
contents
.remove(crease_id)
.map(|(uri, mention)| (uri, Some(mention)))
},
)
}))
})
}
/// Snapshots the editor's current draft into a list of `ContentBlock`s
/// without awaiting any pending mention resolution.
pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec<acp::ContentBlock> {
let editor = self.editor.read(cx);
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
let mention_set = self.mention_set.read(cx);
let supports_embedded_context =
self.session_capabilities.read().supports_embedded_context();
let (chunks, _tracked_buffers) = build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| mention_set.resolved_mention_for_crease(crease_id),
);
chunks
}
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
@ -1222,7 +1180,7 @@ impl MessageEditor {
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
let Some(text) = self.serialized_copy_text(cx) else {
let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else {
cx.propagate();
return;
};
@ -1231,6 +1189,24 @@ impl MessageEditor {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else {
cx.propagate();
return;
};
cx.stop_propagation();
self.editor.update(cx, |editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(ranges);
});
editor.insert("", window, cx);
});
});
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.editor.clone();
window.defer(cx, move |window, cx| {
@ -1407,7 +1383,12 @@ impl MessageEditor {
.detach_and_log_err(cx);
}
pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn insert_selections(
&mut self,
selection: AgentContextSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
let editor = self.editor.read(cx);
let editor_buffer = editor.buffer().read(cx);
let Some(buffer) = editor_buffer.as_singleton() else {
@ -1418,17 +1399,13 @@ impl MessageEditor {
let anchor = buffer.update(cx, |buffer, _cx| {
buffer.anchor_before(cursor_offset.0.min(buffer.len()))
});
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(completion) =
PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
PromptContextAction::AddSelections,
anchor..anchor,
self.editor.downgrade(),
self.mention_set.downgrade(),
&workspace,
cx,
Some(selection),
)
else {
return;
@ -1730,12 +1707,20 @@ impl MessageEditor {
});
}
fn serialized_copy_text(&self, cx: &mut App) -> Option<String> {
fn serialize_selection_with_mentions(
&self,
expand_empty_to_line: bool,
cx: &mut App,
) -> Option<(String, Vec<Range<MultiBufferOffset>>)> {
if self.mention_set.read(cx).is_empty() {
return None;
}
let display_snapshot = self
.editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
let editor = self.editor.read(cx);
if !editor.has_non_empty_selection(&display_snapshot) {
if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) {
return None;
}
@ -1756,48 +1741,55 @@ impl MessageEditor {
})
.collect::<Vec<_>>();
let line_mode = editor.selections.line_mode();
let max_point = snapshot.max_point();
let point_selections = editor.selections.all::<Point>(&display_snapshot);
let mut text = String::new();
let mut ranges = Vec::with_capacity(point_selections.len());
let mut has_mentions = false;
let mut is_first = true;
let mut prev_was_entire_line = false;
for mut selection in point_selections {
let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode;
if is_entire_line {
selection.start = Point::new(selection.start.row, 0);
if !selection.is_empty() && selection.end.column == 0 {
selection.end = min(max_point, selection.end);
} else {
selection.end = min(max_point, Point::new(selection.end.row + 1, 0));
}
}
let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
for selection in editor
.selections
.all::<MultiBufferOffset>(&display_snapshot)
{
if is_first {
is_first = false;
} else {
} else if !prev_was_entire_line {
text.push('\n');
}
prev_was_entire_line = is_entire_line;
let mut overlapping_mentions = mention_ranges
let mut cursor = range.start;
for (start, end, uri) in mention_ranges
.iter()
.filter(|(start, end, _)| *start < selection.end && selection.start < *end)
.peekable();
if overlapping_mentions.peek().is_none() {
text.extend(snapshot.text_for_range(selection.start..selection.end));
continue;
}
has_mentions = true;
let mut cursor = selection.start;
for (start, end, uri) in overlapping_mentions {
.filter(|(start, end, _)| *start < range.end && range.start < *end)
{
if cursor < *start {
text.extend(snapshot.text_for_range(cursor..*start));
}
write!(text, "{}", uri.as_link()).unwrap();
cursor = *end;
has_mentions = true;
}
if cursor < range.end {
text.extend(snapshot.text_for_range(cursor..range.end));
}
if cursor < selection.end {
text.extend(snapshot.text_for_range(cursor..selection.end));
}
ranges.push(range);
}
has_mentions.then_some(text)
has_mentions.then_some((text, ranges))
}
}
@ -1816,6 +1808,7 @@ impl Render for MessageEditor {
.on_action(cx.listener(Self::chat_with_follow))
.on_action(cx.listener(Self::cancel))
.capture_action(cx.listener(Self::copy))
.capture_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::paste_raw))
.capture_action(cx.listener(Self::paste))
.flex_1()
@ -1873,6 +1866,92 @@ impl Addon for MessageEditorAddon {
}
}
/// Walks the editor's creases in order, interleaving plain-text chunks from
/// `text` with mention blocks produced from `resolve`.
fn build_chunks_from_creases(
text: &str,
crease_snapshot: &CreaseSnapshot,
buffer_snapshot: &MultiBufferSnapshot,
supports_embedded_context: bool,
mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option<Mention>)>,
) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
let mut ix = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(text.len(), |(i, _)| i);
let mut chunks = Vec::new();
let mut tracked_buffers = Vec::new();
for (crease_id, crease) in crease_snapshot.creases() {
let Some((uri, mention)) = resolve(&crease_id) else {
continue;
};
let crease_range = crease.range().to_offset(buffer_snapshot);
if crease_range.start.0 > ix {
chunks.push(text[ix..crease_range.start.0].into());
}
chunks.push(mention_to_content_block(
&uri,
mention.as_ref(),
supports_embedded_context,
&mut tracked_buffers,
));
ix = crease_range.end.0;
}
if ix < text.len() {
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
(chunks, tracked_buffers)
}
fn mention_to_content_block(
uri: &MentionUri,
mention: Option<&Mention>,
supports_embedded_context: bool,
tracked_buffers: &mut Vec<Entity<Buffer>>,
) -> acp::ContentBlock {
match mention {
Some(Mention::Text {
content,
tracked_buffers: mention_tracked_buffers,
}) => {
tracked_buffers.extend(mention_tracked_buffers.iter().cloned());
if supports_embedded_context {
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()),
),
))
} else {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
))
}
}
Some(Mention::Image(mention_image)) => acp::ContentBlock::Image(
acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type())
.uri(match uri {
MentionUri::File { .. } | MentionUri::PastedImage { .. } => {
Some(uri.to_uri().to_string())
}
other => {
debug_panic!("unexpected mention uri for image: {:?}", other);
None
}
}),
),
_ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
)),
}
}
/// Parses markdown mention links in the format `[@name](uri)` from text.
/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
@ -1946,7 +2025,7 @@ mod tests {
use base64::Engine as _;
use editor::{
AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
actions::Paste,
actions::{Cut, Paste},
};
use fs::FakeFs;
@ -1966,7 +2045,7 @@ mod tests {
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{AppState, Item, MultiWorkspace};
use crate::completion_provider::PromptContextType;
use crate::completion_provider::{AgentContextSelection, PromptContextType};
use crate::{
conversation_view::tests::init_test,
mention_set::insert_crease_for_mention,
@ -3687,11 +3766,17 @@ mod tests {
})
});
// Now let's insert the selection in the Agent Panel's editor and
// confirm that, after the insertion, the cursor is now in the visible
// range.
let text_editor_selection = editor.update(&mut cx, |editor, cx| {
let multibuffer = editor.buffer().read(cx);
let buffer = multibuffer.as_singleton().unwrap();
let buffer_snapshot = buffer.read(cx).snapshot();
let start = buffer_snapshot.anchor_before(0);
let end = buffer_snapshot.anchor_after(5);
AgentContextSelection::Editor(vec![(buffer, start..end)])
});
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.insert_selections(window, cx);
message_editor.insert_selections(text_editor_selection, window, cx);
});
cx.run_until_parked();
@ -3978,7 +4063,8 @@ mod tests {
let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| {
message_editor
.serialized_copy_text(cx)
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
.expect("selection mentions should serialize")
});
let expected_text = format!(
@ -4043,7 +4129,9 @@ mod tests {
message_editor: Entity<MessageEditor>,
first_uri: MentionUri,
first_range: Range<usize>,
second_uri: MentionUri,
second_range: Range<usize>,
buffer_len: MultiBufferOffset,
}
async fn setup_selection_mention_fixture(
@ -4068,7 +4156,7 @@ mod tests {
line_range: 2..=3,
};
message_editor.update_in(&mut cx, |message_editor, window, cx| {
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.set_text(source_text, window, cx);
let snapshot = message_editor
@ -4123,6 +4211,8 @@ mod tests {
);
});
}
snapshot.len()
});
(
@ -4130,7 +4220,9 @@ mod tests {
message_editor,
first_uri,
first_range,
second_uri,
second_range,
buffer_len,
},
cx,
)
@ -4158,7 +4250,9 @@ mod tests {
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialized_copy_text(cx)
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, Some(fixture.first_uri.as_link().to_string()));
@ -4190,12 +4284,175 @@ mod tests {
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialized_copy_text(cx)
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, None);
}
#[gpui::test]
async fn test_draft_content_blocks_snapshot_preserves_selection_mentions(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let blocks = fixture.message_editor.update(&mut cx, |editor, cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
editor.draft_content_blocks_snapshot(cx)
});
// Each selection mention must round-trip as a `Resource` block carrying
// its URI and content, not as a `Text` block containing the fold
// placeholder string.
let resource_uris: Vec<&str> =
blocks
.iter()
.filter_map(|block| match block {
acp::ContentBlock::Resource(acp::EmbeddedResource {
resource:
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents { uri, .. },
),
..
}) => Some(uri.as_str()),
_ => None,
})
.collect();
assert_eq!(
resource_uris.len(),
2,
"snapshot should emit one Resource block per selection mention; got {blocks:#?}"
);
assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str()));
for block in &blocks {
if let acp::ContentBlock::Text(text) = block {
assert!(
!text.text.split_whitespace().any(|word| word == "selection"),
"text block must not contain bare fold placeholder: {:?}",
text.text
);
}
}
}
#[gpui::test]
async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let buffer_len = fixture.buffer_len;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
});
});
message_editor.cut(&Cut, window, cx);
});
let expected_text = format!(
"{} needs work\n{} looks fine",
fixture.first_uri.as_link(),
fixture.second_uri.as_link()
);
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(clipboard_text, expected_text);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "");
}
#[gpui::test]
async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4);
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([cursor_offset..cursor_offset]);
});
});
message_editor.cut(&Cut, window, cx);
});
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(
clipboard_text,
format!("{} needs work\n", fixture.first_uri.as_link())
);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "selection looks fine");
}
#[gpui::test]
async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let between_start = fixture.first_range.end;
let between_end = fixture.second_range.start - 1;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
]);
});
});
});
let result = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialize_selection_with_mentions(true, cx)
});
assert!(
result.is_none(),
"serialize_selection_with_mentions should return None so the default editor cut runs"
);
}
#[gpui::test]
async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
cx: &mut TestAppContext,

View file

@ -11,7 +11,7 @@ use futures::FutureExt;
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
WeakEntity,
TaskExt, WeakEntity,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;

View file

@ -95,6 +95,11 @@ impl ProfileSelector {
if let Some((next_profile_id, _)) = profiles.get_index(next_index) {
self.provider.set_profile(next_profile_id.clone(), cx);
telemetry::event!(
"Agent Profile Switched",
profile_id = next_profile_id.as_str(),
source = "cycle"
);
cx.notify();
}
}

View file

@ -9,7 +9,7 @@ use fs::Fs;
use futures::FutureExt as _;
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
Render, SharedString, Task, WeakEntity, Window,
Render, SharedString, Task, TaskExt, WeakEntity, Window,
};
use itertools::Itertools as _;
use notifications::status_toast::StatusToast;

View file

@ -20,7 +20,7 @@ use db::{
};
use fs::Fs;
use futures::{FutureExt, future::Shared};
use gpui::{AppContext as _, Entity, Global, Subscription, Task};
use gpui::{AppContext as _, Entity, Global, Subscription, Task, TaskExt};
pub use project::WorktreePaths;
use project::{AgentId, linked_worktree_short_name};
use remote::{RemoteConnectionOptions, same_remote_connection_identity};

View file

@ -12,7 +12,7 @@ use project::{
};
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use settings::Settings;
use util::ResultExt;
use util::{ResultExt, paths::PathStyle};
use workspace::{AppState, MultiWorkspace, Workspace};
use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore};
@ -77,9 +77,13 @@ fn archived_worktree_ref_name(id: i64) -> String {
/// This intentionally reads the *global* `git.worktree_directory` setting
/// rather than any project-local override, because Zed always uses the
/// global value when creating worktrees and the archive check must match.
fn worktrees_base_for_repo(main_repo_path: &Path, cx: &App) -> Option<PathBuf> {
fn worktrees_base_for_repo(
main_repo_path: &Path,
path_style: PathStyle,
cx: &App,
) -> Option<PathBuf> {
let setting = &ProjectSettings::get_global(cx).git.worktree_directory;
worktrees_directory_for_repo(main_repo_path, setting).log_err()
worktrees_directory_for_repo(main_repo_path, setting, path_style).log_err()
}
/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
@ -165,7 +169,7 @@ pub fn build_root_plan(
// Only archive worktrees that live inside the Zed-managed worktrees
// directory (configured via `git.worktree_directory`). Worktrees the
// user created outside that directory should be left untouched.
let worktrees_base = worktrees_base_for_repo(&main_repo_path, cx)?;
let worktrees_base = worktrees_base_for_repo(&main_repo_path, linked_snapshot.path_style, cx)?;
if !path.starts_with(&worktrees_base) {
return None;
}

View file

@ -19,7 +19,8 @@ use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
ListState, Render, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, list,
prelude::*, px,
};
use itertools::Itertools as _;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
@ -101,24 +102,30 @@ impl TimeBucket {
}
}
fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
let mut positions = Vec::new();
let mut query_chars = query.chars().peekable();
for (byte_idx, candidate_char) in text.char_indices() {
if let Some(&query_char) = query_chars.peek() {
if candidate_char.eq_ignore_ascii_case(&query_char) {
positions.push(byte_idx);
query_chars.next();
pub fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
let query_chars: Vec<char> = query.chars().collect();
if query_chars.is_empty() {
return Some(Vec::new());
}
let candidate_chars: Vec<(usize, char)> = candidate.char_indices().collect();
let window_count = candidate_chars.len().checked_sub(query_chars.len() - 1)?;
'outer: for window_start in 0..window_count {
for (qi, &query_char) in query_chars.iter().enumerate() {
let (_, cand_char) = candidate_chars[window_start + qi];
if !cand_char.eq_ignore_ascii_case(&query_char) {
continue 'outer;
}
} else {
break;
}
return Some(
(0..query_chars.len())
.map(|qi| candidate_chars[window_start + qi].0)
.collect(),
);
}
if query_chars.peek().is_none() {
Some(positions)
} else {
None
}
None
}
pub enum ThreadsArchiveViewEvent {

View file

@ -4,7 +4,8 @@ use acp_thread::MentionUri;
use agent_client_protocol::schema as acp;
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use gpui::{
Animation, AnimationExt, AnyView, Context, IntoElement, WeakEntity, Window, pulsating_between,
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
pulsating_between,
};
use prompt_store::PromptId;
use rope::Point;

View file

@ -16,7 +16,7 @@ pub use young_account_banner::YoungAccountBanner;
use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
use gpui::{AnyElement, Entity, IntoElement, ParentElement, TaskExt};
use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*};
#[derive(PartialEq)]
@ -156,11 +156,11 @@ impl ZedAiOnboarding {
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(
Label::new("Sign in to try Zed Pro for 14 days, no credit card required.")
Label::new("Sign in to try Zed Pro free for 14 days.")
.color(Color::Muted)
.mb_2(),
)
.child(PlanDefinitions.pro_plan())
.child(PlanDefinitions.sign_in_upsell())
.child(
Button::new("sign_in", "Try Zed Pro for Free")
.disabled(signing_in)

View file

@ -14,6 +14,13 @@ impl PlanDefinitions {
.child(ListBulletItem::new("Unlimited use of external agents"))
}
pub fn sign_in_upsell(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("Unlimited edit predictions"))
.child(ListBulletItem::new("$20 of tokens in Zed agent"))
.child(ListBulletItem::new("No credit card required"))
}
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("$20 of tokens in Zed agent"))

View file

@ -3,8 +3,8 @@ use client::Client;
use db::kvp::KeyValueStore;
use futures_lite::StreamExt;
use gpui::{
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, Window,
actions,
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, TaskExt,
Window, actions,
};
use http_client::{HttpClient, HttpClientWithUrl};
use paths::remote_servers_dir;
@ -81,6 +81,11 @@ fn linux_rsync_install_hint() -> &'static str {
|| distribution_id == "almalinux"
}) {
Some("Install it with: sudo dnf install rsync")
} else if distribution_ids
.iter()
.any(|distribution_id| distribution_id == "nixos")
{
Some("Install pkgs.rsync from nixpkgs")
} else {
None
};

View file

@ -6,7 +6,8 @@ use db::kvp::Dismissable;
use editor::{Editor, MultiBuffer};
use fs::Fs;
use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TaskExt, Window, actions,
prelude::*,
};
use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
use notifications::status_toast::StatusToast;

View file

@ -32,8 +32,6 @@ use thiserror::Error;
pub use crate::models::*;
pub const CONTEXT_1M_BETA_HEADER: &str = "context-1m-2025-08-07";
pub async fn stream_completion(
client: bedrock::Client,
request: Request,
@ -70,13 +68,6 @@ pub async fn stream_completion(
_ => {}
}
if request.allow_extended_context {
additional_fields.insert(
"anthropic_beta".to_string(),
Document::Array(vec![Document::String(CONTEXT_1M_BETA_HEADER.to_string())]),
);
}
if !additional_fields.is_empty() {
response = response.additional_model_request_fields(Document::Object(additional_fields));
}
@ -211,7 +202,6 @@ pub struct Request {
pub temperature: Option<f32>,
pub top_k: Option<u32>,
pub top_p: Option<f32>,
pub allow_extended_context: bool,
}
#[derive(Debug, Serialize, Deserialize)]

View file

@ -384,19 +384,15 @@ impl Model {
}
pub fn max_token_count(&self) -> u64 {
self.max_tokens()
}
pub fn max_tokens(&self) -> u64 {
match self {
Self::ClaudeHaiku4_5
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => 200_000,
| Self::ClaudeSonnet4_6 => 1_000_000,
Self::ClaudeOpus4_1 => 200_000,
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000,
@ -526,18 +522,6 @@ impl Model {
}
}
pub fn supports_extended_context(&self) -> bool {
matches!(
self,
Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
)
}
pub fn supports_caching(&self) -> bool {
match self {
Self::ClaudeHaiku4_5
@ -1040,11 +1024,11 @@ mod tests {
}
#[test]
fn test_max_tokens() {
assert_eq!(Model::ClaudeSonnet4_5.max_tokens(), 200_000);
assert_eq!(Model::ClaudeOpus4_6.max_tokens(), 200_000);
assert_eq!(Model::Llama4Scout17B.max_tokens(), 128_000);
assert_eq!(Model::NovaPremier.max_tokens(), 1_000_000);
fn test_max_token_count() {
assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000);
assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000);
assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000);
assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000);
}
#[test]

View file

@ -9,7 +9,7 @@ use collections::HashSet;
use futures::{Future, FutureExt, channel::oneshot, future::Shared};
use gpui::{
AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task,
WeakEntity, Window,
TaskExt, WeakEntity, Window,
};
use postage::watch;
use project::Project;

View file

@ -14,7 +14,7 @@ use fs::Fs;
use futures::StreamExt;
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FutureExt as _,
ScreenCaptureSource, ScreenCaptureStream, Task, Timeout, WeakEntity,
ScreenCaptureSource, ScreenCaptureStream, Task, TaskExt, Timeout, WeakEntity,
};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
@ -935,6 +935,11 @@ impl Room {
for sid in participant.video_tracks.keys() {
cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() });
}
if !participant.video_tracks.is_empty() {
cx.emit(Event::RemoteVideoTracksChanged {
participant_id: participant.peer_id,
});
}
false
}
});

View file

@ -26,7 +26,7 @@ use futures::{
future::BoxFuture,
stream::BoxStream,
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
use gpui::{App, AsyncApp, Entity, Global, Task, TaskExt, WeakEntity, actions};
use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env};
use parking_lot::{Mutex, RwLock};
use postage::watch;

View file

@ -4,6 +4,7 @@ use cloud_api_types::websocket_protocol::MessageToClient;
use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
TaskExt,
};
use std::sync::Arc;

View file

@ -15,7 +15,8 @@ use derive_more::Deref;
use feature_flags::FeatureFlagAppExt;
use futures::{Future, StreamExt, channel::mpsc};
use gpui::{
App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, WeakEntity,
App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, TaskExt,
WeakEntity,
};
use http_client::http::{HeaderMap, HeaderValue};
use postage::{sink::Sink, watch};

View file

@ -1,4 +1,5 @@
mod extension;
pub mod internal_api;
mod known_or_unknown;
mod plan;
mod timestamp;

View file

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub legacy_user_id: i32,
pub github_login: String,
pub github_user_id: i32,
pub name: Option<String>,
pub admin: bool,
pub connected_once: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUsersByLegacyIdBody {
pub legacy_user_ids: Vec<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUsersByLegacyIdResponse {
pub users: Vec<User>,
}

View file

@ -2,8 +2,8 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
# DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc"
DATABASE_MAX_CONNECTIONS = 5
HTTP_PORT = 8080
API_TOKEN = "secret"
ZED_ENVIRONMENT = "development"
ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret"
LIVEKIT_SERVER = "http://localhost:7880"
LIVEKIT_KEY = "devkey"
LIVEKIT_SECRET = "secret"

View file

@ -87,16 +87,16 @@ spec:
key: url
- name: DATABASE_MAX_CONNECTIONS
value: "${DATABASE_MAX_CONNECTIONS}"
- name: API_TOKEN
valueFrom:
secretKeyRef:
name: api
key: token
- name: ZED_CLIENT_CHECKSUM_SEED
valueFrom:
secretKeyRef:
name: zed-client
key: checksum-seed
- name: ZED_CLOUD_INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: zed-cloud
key: internal-api-key
- name: LIVEKIT_SERVER
valueFrom:
secretKeyRef:

View file

@ -21,8 +21,8 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"
CREATE TABLE "contacts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id_a" INTEGER REFERENCES users (id) NOT NULL,
"user_id_b" INTEGER REFERENCES users (id) NOT NULL,
"user_id_a" INTEGER NOT NULL,
"user_id_b" INTEGER NOT NULL,
"a_to_b" BOOLEAN NOT NULL,
"should_notify" BOOLEAN NOT NULL,
"accepted" BOOLEAN NOT NULL
@ -44,7 +44,7 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
CREATE TABLE "projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE,
"host_user_id" INTEGER REFERENCES users (id),
"host_user_id" INTEGER,
"host_connection_id" INTEGER,
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"unregistered" BOOLEAN NOT NULL DEFAULT FALSE,
@ -208,14 +208,14 @@ CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and
CREATE TABLE "room_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id),
"user_id" INTEGER NOT NULL REFERENCES users (id),
"user_id" INTEGER NOT NULL,
"answering_connection_id" INTEGER,
"answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"answering_connection_lost" BOOLEAN NOT NULL,
"location_kind" INTEGER,
"location_project_id" INTEGER,
"initial_project_id" INTEGER,
"calling_user_id" INTEGER NOT NULL REFERENCES users (id),
"calling_user_id" INTEGER NOT NULL,
"calling_connection_id" INTEGER NOT NULL,
"calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
"participant_index" INTEGER,
@ -279,7 +279,7 @@ CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_pa
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES users (id),
"user_id" INTEGER NOT NULL,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE
@ -290,7 +290,7 @@ CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_pa
CREATE TABLE "channel_members" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"role" VARCHAR NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now
@ -332,7 +332,7 @@ CREATE TABLE "channel_buffer_collaborators" (
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"connection_lost" BOOLEAN NOT NULL DEFAULT false,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL
);
@ -351,7 +351,7 @@ CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection
);
CREATE TABLE "observed_buffer_edits" (
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL,
"lamport_timestamp" INTEGER NOT NULL,
@ -371,7 +371,7 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" (
CREATE TABLE "notifications" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
"recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"recipient_id" INTEGER NOT NULL,
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
"entity_id" INTEGER,
"content" TEXT,
@ -382,7 +382,7 @@ CREATE TABLE "notifications" (
CREATE INDEX "index_notifications_on_recipient_id_is_read_kind_entity_id" ON "notifications" ("recipient_id", "is_read", "kind", "entity_id");
CREATE TABLE contributors (
user_id INTEGER REFERENCES users (id),
user_id INTEGER,
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
);
@ -437,7 +437,7 @@ CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id");
CREATE TABLE IF NOT EXISTS "shared_threads" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"title" VARCHAR(512) NOT NULL,
"data" BLOB NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -11,8 +11,7 @@ use std::sync::Arc;
/// Validates the authorization header and adds an Extension<Principal> to the request.
/// Authorization: <user-id> <token>
/// <token> can be an access_token attached to that user, or an access token of an admin
/// or (in development) the string ADMIN:<config.api_token>.
/// <token> is the access_token attached to that user.
/// Authorization: "dev-server-token" <token>
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
let mut auth_header = req

View file

@ -684,6 +684,26 @@ impl Database {
.await
}
/// Returns the channel memberships for the users with the specified IDs.
#[cfg(feature = "test-support")]
pub async fn get_channel_memberships_for_user_ids(
&self,
channel: &Channel,
ids: Vec<UserId>,
) -> Result<Vec<channel_member::Model>> {
self.transaction(|tx| async {
let tx = tx;
let members = channel_member::Entity::find()
.filter(channel_member::Column::ChannelId.eq(channel.id))
.filter(channel_member::Column::UserId.is_in(ids.iter().copied()))
.all(&*tx)
.await?;
Ok(members)
})
.await
}
/// Returns the details for the specified channel member.
pub async fn get_channel_participant_details(
&self,

View file

@ -25,6 +25,8 @@ impl From<Model> for crate::entities::User {
crate::entities::User {
id: user.id,
github_login: user.github_login,
github_user_id: user.github_user_id,
name: user.name,
admin: user.admin,
connected_once: user.connected_once,
}

View file

@ -4,6 +4,8 @@ use crate::db::UserId;
pub struct User {
pub id: UserId,
pub github_login: String,
pub github_user_id: i32,
pub name: Option<String>,
pub admin: bool,
pub connected_once: bool,
}

View file

@ -6,6 +6,7 @@ pub mod env;
pub mod executor;
pub mod rpc;
pub mod seed;
pub mod services;
use anyhow::Context as _;
use aws_config::{BehaviorVersion, Region};
@ -19,6 +20,10 @@ use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
use crate::services::{
CloudUserService, DatabaseUserService, TransitionalUserService, UserService,
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
@ -121,7 +126,6 @@ pub struct Config {
pub database_url: String,
pub seed_path: Option<PathBuf>,
pub database_max_connections: u32,
pub api_token: String,
pub livekit_server: Option<String>,
pub livekit_key: Option<String>,
pub livekit_secret: Option<String>,
@ -137,6 +141,7 @@ pub struct Config {
pub kinesis_access_key: Option<String>,
pub kinesis_secret_key: Option<String>,
pub zed_environment: Arc<str>,
pub zed_cloud_internal_api_key: String,
pub zed_client_checksum_seed: Option<String>,
}
@ -168,13 +173,13 @@ impl Config {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
api_token: "".into(),
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
@ -216,6 +221,7 @@ pub struct AppState {
pub blob_store_client: Option<aws_sdk_s3::Client>,
pub executor: Executor,
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
pub user_service: Arc<dyn UserService>,
pub config: Config,
}
@ -250,7 +256,7 @@ impl AppState {
let db = Arc::new(db);
let this = Self {
db: db.clone(),
http_client: Some(http_client),
http_client: Some(http_client.clone()),
livekit_client,
blob_store_client: build_blob_store_client(&config).await.log_err(),
executor,
@ -259,6 +265,19 @@ impl AppState {
} else {
None
},
user_service: {
let database_user_service = DatabaseUserService::new(db);
let cloud_user_service = CloudUserService::new(
http_client,
config.zed_cloud_url().to_string(),
config.zed_cloud_internal_api_key.clone(),
);
Arc::new(TransitionalUserService::new(
cloud_user_service,
database_user_service,
))
},
config,
};
Ok(Arc::new(this))

View file

@ -2541,10 +2541,11 @@ async fn get_users(
.map(UserId::from_proto)
.collect();
let users = session
.db()
.await
.app_state
.user_service
.get_users_by_ids(user_ids)
.await?
.await?;
let users = users
.into_iter()
.map(|user| proto::User {
id: user.id.to_proto(),
@ -2567,13 +2568,19 @@ async fn fuzzy_search_users(
let users = match query.len() {
0 => vec![],
1 | 2 => session
.db()
.await
.app_state
.user_service
.get_user_by_github_login(&query)
.await?
.into_iter()
.collect(),
_ => session.db().await.fuzzy_search_users(&query, 10).await?,
_ => {
session
.app_state
.user_service
.fuzzy_search_users(&query, 10)
.await?
}
};
let users = users
.into_iter()
@ -3163,13 +3170,11 @@ async fn get_channel_members(
let channel = db.get_channel(channel_id, session.user_id()).await?;
let (members, users) = db
.get_channel_participant_details(&channel, &request.query, limit)
let (members, users) = session
.app_state
.user_service
.search_channel_members(&channel, &request.query, limit as u32)
.await?;
let members = members
.into_iter()
.map(proto::ChannelMember::from)
.collect();
let users = users.into_iter().map(proto::User::from).collect();
response.send(proto::GetChannelMembersResponse { members, users })?;
@ -4081,3 +4086,17 @@ where
}
}
}
impl From<User> for proto::User {
fn from(user: User) -> Self {
Self {
id: user.id.to_proto(),
avatar_url: format!(
"https://avatars.githubusercontent.com/u/{}?s=128&v=4",
user.github_user_id
),
github_login: user.github_login,
name: user.name,
}
}
}

View file

@ -0,0 +1,3 @@
mod user_service;
pub use user_service::*;

View file

@ -0,0 +1,390 @@
use std::sync::Arc;
use anyhow::{Context as _, anyhow};
use async_trait::async_trait;
use cloud_api_types::internal_api::{
self, LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse,
};
use rpc::proto;
use crate::Result;
use crate::db::{Channel, Database, UserId};
use crate::entities::User;
#[cfg(feature = "test-support")]
pub use self::fake_user_service::*;
#[async_trait]
pub trait UserService: Send + Sync + 'static {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
// NOTE: This method is only tangentially related to users, but we're putting it on the `UserService` to avoid
// introducing a separate service.
//
// We're also using the `proto::ChannelMember` representation in the return type, as we don't yet have a domain
// representation of a channel member (and doesn't seem necessary to introduce one, at this point).
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)>;
#[cfg(feature = "test-support")]
fn as_fake(&self) -> Arc<FakeUserService> {
panic!("called as_fake on a real `UserService`");
}
}
/// A [`UserService`] implementation for transitioning from reading from the database to reading from Cloud.
pub struct TransitionalUserService {
cloud_user_service: CloudUserService,
database_user_service: DatabaseUserService,
}
impl TransitionalUserService {
pub fn new(
cloud_user_service: CloudUserService,
database_user_service: DatabaseUserService,
) -> Self {
Self {
cloud_user_service,
database_user_service,
}
}
}
#[async_trait]
impl UserService for TransitionalUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
self.cloud_user_service.get_users_by_ids(ids).await
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
self.database_user_service
.get_user_by_github_login(github_login)
.await
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
self.database_user_service
.fuzzy_search_users(query, limit)
.await
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
self.database_user_service
.search_channel_members(channel, query, limit)
.await
}
}
/// A [`UserService`] implementation backed by Cloud.
pub struct CloudUserService {
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
}
impl CloudUserService {
pub fn new(
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
) -> Self {
Self {
http_client,
zed_cloud_url,
internal_api_key,
}
}
}
#[async_trait]
impl UserService for CloudUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let response = self
.http_client
.post(format!(
"{}/internal/users/look_up_by_legacy_id",
&self.zed_cloud_url
))
.header("Content-Type", "application/json")
.header(
"Authorization",
format!("Bearer {}", &self.internal_api_key),
)
.json(&LookUpUsersByLegacyIdBody {
legacy_user_ids: ids.into_iter().map(|id| id.0).collect(),
})
.send()
.await
.context("failed to get users by legacy IDs")?;
match response.error_for_status() {
Ok(response) => {
let response_body: LookUpUsersByLegacyIdResponse = response
.json()
.await
.context("failed to parse response body")?;
Ok(response_body.users.into_iter().map(User::from).collect())
}
Err(_err) => Err(anyhow!("failed to get users by legacy IDs"))?,
}
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let _ = github_login;
unimplemented!("not yet implemented in Cloud")
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let _ = query;
let _ = limit;
unimplemented!("not yet implemented in Cloud")
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let _ = channel;
let _ = query;
let _ = limit;
unimplemented!("not yet implemented in Cloud")
}
}
impl From<internal_api::User> for User {
fn from(user: internal_api::User) -> Self {
Self {
id: UserId(user.legacy_user_id),
github_login: user.github_login,
github_user_id: user.github_user_id,
name: user.name,
admin: user.admin,
connected_once: user.connected_once,
}
}
}
/// A [`UserService`] implementation backed by the database.
pub struct DatabaseUserService {
database: Arc<Database>,
}
impl DatabaseUserService {
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
}
#[async_trait]
impl UserService for DatabaseUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let users = self.database.get_users_by_ids(ids).await?;
Ok(users.into_iter().map(User::from).collect())
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let user = self.database.get_user_by_github_login(github_login).await?;
Ok(user.map(User::from))
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let users = self.database.fuzzy_search_users(query, limit).await?;
Ok(users.into_iter().map(User::from).collect())
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let (members, users) = self
.database
.get_channel_participant_details(channel, query, limit as u64)
.await?;
Ok((
members
.into_iter()
.map(proto::ChannelMember::from)
.collect(),
users.into_iter().map(User::from).collect(),
))
}
}
#[cfg(feature = "test-support")]
mod fake_user_service {
use std::sync::Weak;
use collections::HashMap;
use tokio::sync::Mutex;
use super::*;
#[derive(Debug)]
pub struct NewUserParams {
pub github_login: String,
pub github_user_id: i32,
}
pub struct FakeUserService {
this: Weak<Self>,
state: Arc<Mutex<FakeUserServiceState>>,
database: Arc<Database>,
}
struct FakeUserServiceState {
next_user_id: UserId,
users: HashMap<UserId, User>,
}
impl Default for FakeUserServiceState {
fn default() -> Self {
Self {
next_user_id: UserId(1),
users: HashMap::default(),
}
}
}
impl FakeUserService {
pub fn new(database: Arc<Database>) -> Arc<Self> {
Arc::new_cyclic(|this| Self {
this: this.clone(),
state: Arc::new(Mutex::default()),
database,
})
}
pub async fn create_user(
&self,
email_address: &str,
name: Option<&str>,
admin: bool,
params: NewUserParams,
) -> UserId {
let mut state = self.state.lock().await;
let user_id = state.next_user_id;
let _ = email_address;
state.users.insert(
user_id,
User {
id: user_id,
github_login: params.github_login,
github_user_id: params.github_user_id,
name: name.map(|name| name.to_string()),
admin,
connected_once: false,
},
);
state.next_user_id = UserId(state.next_user_id.0 + 1);
user_id
}
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state.users.get(&id).cloned();
Ok(user)
}
}
#[async_trait]
impl UserService for FakeUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| ids.contains(&user.id))
.cloned()
.collect();
Ok(users)
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state
.users
.values()
.find(|user| user.github_login == github_login)
.cloned();
Ok(user)
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let _ = query;
let _ = limit;
unimplemented!("not currently exercised by any tests")
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| user.github_login.contains(query))
.take(limit as usize)
.cloned()
.collect::<Vec<_>>();
let members = self
.database
.get_channel_memberships_for_user_ids(
channel,
users.iter().map(|user| user.id).collect(),
)
.await?;
Ok((
members
.into_iter()
.map(proto::ChannelMember::from)
.collect(),
users,
))
}
#[cfg(feature = "test-support")]
fn as_fake(&self) -> Arc<FakeUserService> {
self.this.upgrade().unwrap()
}
}
}

View file

@ -1,18 +1,20 @@
use crate::TestServer;
use call::ActiveCall;
use client::ChannelId;
use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource};
use project::Project;
use serde_json::json;
use util::path;
use workspace::Workspace;
use rpc::proto::PeerId;
use workspace::{AutoWatch, SharedScreen, Workspace};
use super::TestClient;
struct AutoWatchTestSetup {
client_a: TestClient,
_client_b: TestClient,
_client_c: TestClient,
project_a: Entity<Project>,
client_b: TestClient,
client_c: TestClient,
channel_id: ChannelId,
user_a_project: Entity<Project>,
user_b_project: Entity<Project>,
}
async fn setup_auto_watch_test(
@ -20,35 +22,67 @@ async fn setup_auto_watch_test(
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await
}
async fn setup_auto_watch_late_joiner_test(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await
}
async fn setup_auto_watch_test_with_initial_participants(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
join_user_c: bool,
) -> AutoWatchTestSetup {
let client_a = server.create_client(user_a, "user_a").await;
let client_b = server.create_client(user_b, "user_b").await;
let client_c = server.create_client(user_c, "user_c").await;
server
.create_room(&mut [
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, user_a),
(&client_b, user_b),
(&client_c, user_c),
])
&mut [(&client_b, user_b), (&client_c, user_c)],
)
.await;
let user_a_project = client_a.build_empty_local_project(false, user_a);
let user_b_project = client_b.build_empty_local_project(false, user_b);
let active_call_a = user_a.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/a"), json!({ "file.txt": "content" }))
.await;
let (project_a, _worktree_id) = client_a.build_local_project(path!("/a"), user_a).await;
active_call_a
.update(user_a, |call, cx| call.set_location(Some(&project_a), cx))
.update(user_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
let active_call_b = user_b.read(ActiveCall::global);
active_call_b
.update(user_b, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
if join_user_c {
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
}
AutoWatchTestSetup {
client_a,
_client_b: client_b,
_client_c: client_c,
project_a,
client_b,
client_c,
channel_id,
user_a_project,
user_b_project,
}
}
@ -61,7 +95,9 @@ async fn test_auto_watch_opens_existing_share_on_toggle(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
executor.run_until_parked();
start_screen_share(user_b).await;
@ -73,7 +109,11 @@ async fn test_auto_watch_opens_existing_share_on_toggle(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -86,7 +126,9 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -96,7 +138,11 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -109,7 +155,9 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -119,7 +167,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
start_screen_share(user_c).await;
@ -129,7 +181,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_c's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
@ -142,7 +198,9 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
start_screen_share(user_a).await;
executor.run_until_parked();
@ -155,16 +213,11 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing(
});
executor.run_until_parked();
// Ensure that no screen share is found in user a's tab bar
workspace_a.update(user_a, |workspace, cx| {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.tab_content_text(0, cx).contains("screen"));
assert!(
!has_shared_screen_tab,
"should not open anyone's screen share when toggling on while sharing"
assert_no_screen_share_tabs_exist(
workspace,
"should not open anyone's screen share when toggling on while sharing",
cx,
);
});
}
@ -178,7 +231,9 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -193,7 +248,11 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -206,7 +265,9 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -215,7 +276,11 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
@ -223,19 +288,165 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
});
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let (workspace_b, user_b) = setup
.client_b
.build_workspace(&setup.user_b_project, user_b);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
workspace_b.update_in(user_b, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
active_call_c
.update(user_c, |call, cx| call.hang_up(cx))
.await
.unwrap();
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user A should stop seeing user C's screen after user C hangs up",
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user B should stop seeing user C's screen after user C hangs up",
cx,
);
});
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_is_disabled_when_following_collaborator(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let user_b_peer_id = setup.client_b.peer_id().unwrap();
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.follow(user_b_peer_id, window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, _cx| {
assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off);
});
}
#[track_caller]
fn assert_active_matches_title(workspace: &Workspace, expected_title: &str, cx: &App) {
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.downcast::<SharedScreen>().is_some());
assert!(!has_shared_screen_tab, "{message}");
}
#[track_caller]
fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) {
let active_item = workspace.active_item(cx).expect("no active item");
assert_eq!(
active_item.tab_content_text(0, cx),
expected_title,
"expected active item to be '{}'",
expected_title
);
let shared_screen = active_item
.downcast::<SharedScreen>()
.expect("expected active item to be a shared screen");
assert_eq!(shared_screen.read(cx).peer_id, peer_id);
}
async fn start_screen_share(cx: &mut TestAppContext) {
@ -260,6 +471,7 @@ async fn start_screen_share(cx: &mut TestAppContext) {
.unwrap();
}
#[track_caller]
fn stop_screen_share(cx: &mut TestAppContext) {
let active_call = cx.read(ActiveCall::global);
active_call

View file

@ -281,7 +281,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
// User B signs the zed CLA.
let user_b = server
.app_state
.db
.user_service
.get_user_by_github_login("user_b")
.await
.unwrap()

View file

@ -1399,7 +1399,12 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
"Should have fetched one code lens action, but got: {resulting_lens_actions:?}"
);
assert_eq!(
resulting_lens_actions.first().unwrap().lsp_action.title(),
resulting_lens_actions
.values()
.next()
.unwrap()
.lsp_action
.title(),
"LSP Command 1",
"Only the final code lens action should be in the data"
)

View file

@ -2381,3 +2381,106 @@ async fn test_following_while_deactivated(cx_a: &mut TestAppContext, cx_b: &mut
assert_eq!(editor.tab_content_text(0, cx), "2.js");
});
}
#[gpui::test(iterations = 10)]
async fn test_following_with_multibuffer_excerpts_at_unobserved_lamport(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let executor = cx_a.executor();
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
cx_a.update(editor::init);
cx_b.update(editor::init);
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/a"), json!({ "1.txt": sample_text(20, 5, 'a') }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("1.txt")), cx)
})
.await
.unwrap();
// B must already have the buffer open at a low Lamport so that A's
// subsequent edits create anchors B hasn't observed.
let _buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("1.txt")), cx)
})
.await
.unwrap();
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.follow(client_a.peer_id().unwrap(), window, cx)
});
executor.run_until_parked();
buffer_a.update(cx_a, |buf, cx| {
for i in 0..30 {
let len = buf.len();
buf.edit([(len..len, format!("\nappended line {i}"))], None, cx);
}
});
let multibuffer_a = cx_a.new(|cx| {
let mut mb = MultiBuffer::new(Capability::ReadWrite);
let max_row = buffer_a.read(cx).max_point().row;
mb.set_excerpts_for_path(
PathKey::for_buffer(&buffer_a, cx),
buffer_a.clone(),
[Point::row_range(max_row.saturating_sub(5)..max_row)],
1,
cx,
);
mb
});
workspace_a.update_in(cx_a, |workspace, window, cx| {
let editor = cx
.new(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), window, cx));
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
});
executor.run_until_parked();
let active_text = |workspace: &Entity<Workspace>, cx: &mut VisualTestContext| {
workspace.update(cx, |workspace, cx| {
workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
.update(cx, |editor, cx| editor.text(cx))
})
};
assert_eq!(
active_text(&workspace_a, cx_a),
active_text(&workspace_b, cx_b)
);
}

View file

@ -7,7 +7,7 @@ use collections::{BTreeMap, HashMap};
use editor::Bias;
use fs::{FakeFs, Fs as _};
use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
use gpui::{BackgroundExecutor, Entity, TestAppContext};
use gpui::{BackgroundExecutor, Entity, TaskExt, TestAppContext};
use language::{
FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, PointUtf16, range_to_lsp,
};

View file

@ -7,9 +7,10 @@ use client::{
proto::PeerId,
};
use clock::FakeSystemClock;
use collab::services::{FakeUserService, NewUserParams};
use collab::{
AppState, Config,
db::{NewUserParams, UserId},
db::UserId,
executor::Executor,
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
};
@ -179,14 +180,19 @@ impl TestServer {
let clock = Arc::new(FakeSystemClock::new());
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
let user_id = if let Ok(Some(user)) = self
.app_state
.user_service
.get_user_by_github_login(name)
.await
{
user.id
} else {
let github_user_id = self.next_github_user_id;
self.next_github_user_id += 1;
self.app_state
.db
.user_service
.as_fake()
.create_user(
&format!("{name}@example.com"),
None,
@ -197,8 +203,6 @@ impl TestServer {
},
)
.await
.expect("creating user failed")
.user_id
};
let http = FakeHttpClient::create({
@ -244,7 +248,7 @@ impl TestServer {
let client_name = name.to_string();
let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
let server = self.server.clone();
let db = self.app_state.db.clone();
let user_service = self.app_state.user_service.clone();
let connection_killers = self.connection_killers.clone();
let forbid_connections = self.forbid_connections.clone();
@ -268,7 +272,7 @@ impl TestServer {
);
let server = server.clone();
let db = db.clone();
let user_service = user_service.clone();
let connection_killers = connection_killers.clone();
let forbid_connections = forbid_connections.clone();
let client_name = client_name.clone();
@ -281,7 +285,8 @@ impl TestServer {
let (client_conn, server_conn, killed) =
Connection::in_memory(cx.background_executor().clone());
let (connection_id_tx, connection_id_rx) = oneshot::channel();
let user = db
let user = user_service
.as_fake()
.get_user_by_id(user_id)
.await
.map_err(|e| {
@ -294,7 +299,7 @@ impl TestServer {
cx.background_spawn(server.handle_connection(
server_conn,
client_name,
Principal::User(user.into()),
Principal::User(user),
ZedVersion(semver::Version::new(1, 0, 0)),
Some("test".to_string()),
None,
@ -576,17 +581,18 @@ impl TestServer {
blob_store_client: None,
executor,
kinesis_client: None,
user_service: FakeUserService::new(test_db.db().clone()),
config: Config {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
api_token: "".into(),
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,

View file

@ -2913,6 +2913,13 @@ impl CollabPanel {
if show_auto_watch || show_copy {
Some(
h_flex()
.when_some(channel_link, |this, channel_link| {
this.child(
CopyButton::new("copy-channel-link", channel_link)
.visible_on_hover("section-header")
.tooltip_label("Copy Channel Link"),
)
})
.when(has_auto_watch_flag, |this| {
this.child(
IconButton::new(
@ -2952,13 +2959,6 @@ impl CollabPanel {
)),
)
})
.when_some(channel_link, |this, channel_link| {
this.child(
CopyButton::new("copy-channel-link", channel_link)
.visible_on_hover("section-header")
.tooltip_label("Copy Channel Link"),
)
})
.into_any_element(),
)
} else {
@ -3830,6 +3830,12 @@ impl Panel for CollabPanel {
fn activation_priority(&self) -> u32 {
5
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.collaboration_panel.get_or_insert_default().button = Some(false);
}))
}
}
impl Focusable for CollabPanel {

View file

@ -6,7 +6,8 @@ use client::{
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement,
Render, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, deferred, div,
Render, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, anchored, deferred,
div,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;

View file

@ -1,7 +1,7 @@
use crate::notification_window_options;
use call::{ActiveCall, IncomingCall};
use futures::StreamExt;
use gpui::{App, WindowHandle, prelude::*};
use gpui::{App, TaskExt, WindowHandle, prelude::*};
use std::sync::{Arc, Weak};
use ui::{CollabNotification, prelude::*};

View file

@ -2,7 +2,7 @@ use crate::notification_window_options;
use call::{ActiveCall, room};
use client::User;
use collections::HashMap;
use gpui::{App, Size};
use gpui::{App, Size, TaskExt};
use std::sync::{Arc, Weak};
use ui::{CollabNotification, prelude::*};

View file

@ -16,7 +16,7 @@ use command_palette_hooks::{
use fuzzy_nucleo::{StringMatch, StringMatchCandidate};
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
ParentElement, Render, Styled, Task, WeakEntity, Window,
ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window,
};
use persistence::CommandPaletteDB;
use picker::Direction;

View file

@ -17,7 +17,6 @@ test-support = ["gpui/test-support"]
[dependencies]
anyhow.workspace = true
async-channel.workspace = true
async-process.workspace = true
async-trait.workspace = true
base64.workspace = true
collections.workspace = true

View file

@ -131,6 +131,7 @@ struct Notification<'a, T> {
jsonrpc: &'static str,
#[serde(borrow)]
method: &'a str,
#[serde(skip_serializing_if = "is_null_value")]
params: T,
}

View file

@ -1,15 +1,16 @@
use std::path::PathBuf;
use std::pin::Pin;
use anyhow::{Context as _, Result};
use async_process::Child;
use anyhow::Result;
use async_trait::async_trait;
use futures::io::{BufReader, BufWriter};
use futures::{
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
};
use gpui::AsyncApp;
use util::TryFutureExt as _;
use util::process::Child;
use util::shell::Shell;
use util::shell_builder::ShellBuilder;
@ -31,22 +32,20 @@ impl StdioTransport {
) -> Result<Self> {
let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
let mut command =
builder.build_smol_command(Some(binary.executable.display().to_string()), &binary.args);
builder.build_std_command(Some(binary.executable.display().to_string()), &binary.args);
command
.envs(binary.env.unwrap_or_default())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
command.envs(binary.env.unwrap_or_default());
if let Some(working_directory) = working_directory {
command.current_dir(working_directory);
}
let mut server = command
.spawn()
.with_context(|| format!("failed to spawn command {command:?})",))?;
let mut server = Child::spawn(
command,
std::process::Stdio::piped(),
std::process::Stdio::piped(),
std::process::Stdio::piped(),
)?;
let stdin = server.stdin.take().unwrap();
let stdout = server.stdout.take().unwrap();

View file

@ -10,7 +10,7 @@ use edit_prediction_types::{
EditPrediction, EditPredictionDelegate, EditPredictionDiscardReason, EditPredictionIconSet,
interpolate_edits,
};
use gpui::{App, Context, Entity, Task};
use gpui::{App, Context, Entity, Task, TaskExt};
use icons::IconName;
use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToPointUtf16};
use std::{ops::Range, sync::Arc, time::Duration};

View file

@ -9,6 +9,7 @@ use anyhow::{Result, anyhow};
use collections::HashSet;
use fs::Fs;
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use gpui::TaskExt;
use gpui::WeakEntity;
use gpui::{App, AsyncApp, Global, prelude::*};
use http_client::HttpRequestExt;

View file

@ -6,7 +6,7 @@ use copilot::{
use gpui::{
App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled,
Subscription, Window, WindowBounds, WindowOptions, div, point,
Subscription, TaskExt, Window, WindowBounds, WindowOptions, div, point,
};
use project::project_settings::ProjectSettings;
use settings::Settings as _;

View file

@ -11,7 +11,8 @@ use futures::{
};
use gpui::{
App, AppContext, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div,
ParentElement, Render, SharedString, Styled, Subscription, TaskExt, WeakEntity, Window,
actions, div,
};
use project::{
Project,

View file

@ -1,7 +1,7 @@
use dap::{DapRegistry, DebugRequest};
use futures::channel::oneshot;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task};
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, TaskExt};
use gpui::{Subscription, WeakEntity};
use picker::{Picker, PickerDelegate};
use project::Project;

View file

@ -19,7 +19,7 @@ use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_
use gpui::{
Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity,
EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point,
Subscription, Task, WeakEntity, anchored, deferred,
Subscription, Task, TaskExt, WeakEntity, anchored, deferred,
};
use itertools::Itertools as _;
@ -1606,6 +1606,12 @@ impl Panel for DebugPanel {
7
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.debugger.get_or_insert_default().button = Some(false);
}))
}
fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {}
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {

View file

@ -2,7 +2,7 @@ use std::any::TypeId;
use debugger_panel::DebugPanel;
use editor::{Editor, MultiBufferOffsetUtf16};
use gpui::{Action, App, DispatchPhase, EntityInputHandler, actions};
use gpui::{Action, App, DispatchPhase, EntityInputHandler, TaskExt, actions};
use new_process_modal::{NewProcessModal, NewProcessMode};
use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::ThreadStatus};
use schemars::JsonSchema;

View file

@ -16,7 +16,7 @@ use editor::Editor;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, Render, Subscription, Task, WeakEntity, actions,
KeyContext, Render, Subscription, Task, TaskExt, WeakEntity, actions,
};
use itertools::Itertools as _;
use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};

View file

@ -33,7 +33,7 @@ use dap::{
use futures::{SinkExt, channel::mpsc};
use gpui::{
Action as _, AnyView, AppContext, Axis, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
NoAction, Pixels, Point, Subscription, Task, WeakEntity,
NoAction, Pixels, Point, Subscription, Task, TaskExt, WeakEntity,
};
use language::Buffer;
use loaded_source_list::LoadedSourceList;

View file

@ -8,7 +8,7 @@ use dap::adapters::DebugAdapterName;
use db::kvp::KeyValueStore;
use gpui::{
Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState,
Subscription, Task, WeakEntity, list,
Subscription, Task, TaskExt, WeakEntity, list,
};
use util::{
debug_panic,

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