Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-22 12:30:51 +01:00 committed by GitHub
commit fe635735b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
246 changed files with 17261 additions and 5267 deletions

View file

@ -5,7 +5,7 @@ env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1'
CARGO_INCREMENTAL: '0'
ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7
ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9
on:
workflow_call:
inputs:

View file

@ -5,7 +5,7 @@ env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1'
CARGO_INCREMENTAL: '0'
ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7
ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9
RUSTUP_TOOLCHAIN: stable
CARGO_BUILD_TARGET: wasm32-wasip2
on:

104
Cargo.lock generated
View file

@ -224,9 +224,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1084cabbc2b00d353bad7e54750b0ef0f0bba9204c5884240c83a628704db86c"
checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
@ -259,9 +259,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol-schema"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984583e634f3f4d479b585aaa76de4a633255dcdf2be6489c6a8486f758af04"
checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e"
dependencies = [
"anyhow",
"derive_more",
@ -407,6 +407,7 @@ dependencies = [
"language_models",
"languages",
"log",
"lru",
"lsp",
"markdown",
"menu",
@ -438,6 +439,7 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
"skill_creator",
"streaming_diff",
"task",
"telemetry",
@ -1293,22 +1295,20 @@ dependencies = [
name = "auto_update_ui"
version = "0.1.0"
dependencies = [
"agent_settings",
"agent_skills",
"anyhow",
"auto_update",
"client",
"db",
"editor",
"fs",
"gpui",
"markdown_preview",
"notifications",
"project",
"prompt_store",
"release_channel",
"semver",
"serde",
"serde_json",
"settings",
"smol",
"telemetry",
"ui",
@ -3007,7 +3007,6 @@ dependencies = [
"cloud_llm_client",
"collections",
"credentials_provider",
"db",
"derive_more",
"feature_flags",
"fs",
@ -5964,6 +5963,8 @@ dependencies = [
"settings",
"shellexpand",
"terminal_view",
"theme",
"theme_settings",
"util",
"watch",
]
@ -6166,7 +6167,6 @@ name = "extensions_ui"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"cloud_api_types",
"collections",
"db",
@ -7461,6 +7461,7 @@ dependencies = [
"settings",
"smallvec",
"strum 0.27.2",
"sysinfo 0.37.2",
"task",
"telemetry",
"theme",
@ -9649,6 +9650,7 @@ dependencies = [
"futures 0.3.32",
"gpui_shared_string",
"http_client",
"log",
"partial-json-fixer",
"schemars 1.0.4",
"serde",
@ -11004,8 +11006,8 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
[[package]]
name = "naga"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
@ -11178,6 +11180,7 @@ dependencies = [
"async-std",
"async-tar",
"async-trait",
"chrono",
"futures 0.3.32",
"http_client",
"log",
@ -13684,7 +13687,6 @@ dependencies = [
"chrono",
"collections",
"db",
"feature_flags",
"fs",
"futures 0.3.32",
"fuzzy",
@ -16231,6 +16233,7 @@ version = "0.1.0"
dependencies = [
"agent",
"agent_settings",
"agent_skills",
"anyhow",
"audio",
"codestral",
@ -16530,6 +16533,30 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "skill_creator"
version = "0.1.0"
dependencies = [
"agent_skills",
"anyhow",
"editor",
"fs",
"gpui",
"language",
"menu",
"platform_title_bar",
"release_channel",
"serde_json",
"serde_yaml_ng",
"settings",
"theme_settings",
"ui",
"ui_input",
"util",
"workspace",
"worktree",
]
[[package]]
name = "skrifa"
version = "0.37.0"
@ -18246,7 +18273,6 @@ dependencies = [
"client",
"cloud_api_types",
"db",
"feature_flags",
"fs",
"git_ui",
"gpui",
@ -18768,9 +18794,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.26.8"
version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538"
checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3"
dependencies = [
"cc",
"regex",
@ -19171,7 +19197,9 @@ dependencies = [
"gpui_util",
"icons",
"itertools 0.14.0",
"log",
"menu",
"num-format",
"schemars 1.0.4",
"serde",
"smallvec",
@ -20054,9 +20082,9 @@ dependencies = [
[[package]]
name = "wasmtime-c-api-impl"
version = "36.0.6"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c62ea3fa30e6b0cf61116b3035121b8f515c60ac118ebfdab2ee56d028ed1e"
checksum = "e5e71e971a27df819171b79597c0f1826fc7cf2c168111c64dbc5505a1ffbda7"
dependencies = [
"anyhow",
"log",
@ -20103,9 +20131,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-c-api-macros"
version = "36.0.6"
version = "36.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8c61294155a6d23c202f08cf7a2f9392a866edd50517508208818be626ce9f"
checksum = "20b9553165039d365931a998d9b60278cc968ba9d81531cecde8ffc3effa1fe3"
dependencies = [
"proc-macro2",
"quote",
@ -20559,8 +20587,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]]
name = "wgpu"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"arrayvec",
"bitflags 2.10.0",
@ -20588,8 +20616,8 @@ dependencies = [
[[package]]
name = "wgpu-core"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
@ -20620,32 +20648,32 @@ dependencies = [
[[package]]
name = "wgpu-core-deps-apple"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-hal"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"android_system_properties",
"arrayvec",
@ -20692,12 +20720,13 @@ dependencies = [
"wgpu-types",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-result 0.4.1",
]
[[package]]
name = "wgpu-naga-bridge"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"naga",
"wgpu-types",
@ -20705,8 +20734,8 @@ dependencies = [
[[package]]
name = "wgpu-types"
version = "29.0.0"
source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575"
version = "29.0.3"
source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88"
dependencies = [
"bitflags 2.10.0",
"bytemuck",
@ -21990,6 +22019,7 @@ dependencies = [
"collections",
"component",
"db",
"dirs",
"fs",
"futures 0.3.32",
"futures-lite 1.13.0",
@ -22449,7 +22479,7 @@ dependencies = [
[[package]]
name = "zed"
version = "1.4.0"
version = "1.5.0"
dependencies = [
"acp_thread",
"acp_tools",

View file

@ -172,6 +172,7 @@ members = [
"crates/rope",
"crates/rpc",
"crates/rules_library",
"crates/skill_creator",
"crates/scheduler",
"crates/schema_generator",
"crates/search",
@ -432,6 +433,7 @@ rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" }
skill_creator = { path = "crates/skill_creator" }
scheduler = { path = "crates/scheduler" }
search = { path = "crates/search" }
session = { path = "crates/session" }
@ -500,7 +502,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
# External crates
#
agent-client-protocol = { version = "=0.12.0", features = ["unstable"] }
agent-client-protocol = { version = "=0.12.1", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
any_vec = "0.14"
@ -620,6 +622,7 @@ linkify = "0.10.0"
libwebrtc = "0.3.26"
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
lru = "0.16"
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" }
mach2 = "0.5"
markup5ever_rcdom = "0.3.0"
@ -762,7 +765,7 @@ toml_edit = { version = "0.22", default-features = false, features = [
"serde",
] }
tower-http = "0.4.4"
tree-sitter = { version = "0.26.8", features = ["wasm"] }
tree-sitter = { version = "0.26.9", features = ["wasm"] }
tree-sitter-bash = "0.25.1"
tree-sitter-c = "0.24.1"
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
@ -812,7 +815,7 @@ which = "6.0.0"
wasm-bindgen = "0.2.120"
web-time = "1.1.0"
webrtc-sys = "0.3.23"
wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" }
wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" }
windows-core = "0.61"
yaml-rust2 = "0.8"
yawc = "0.2.5"

View file

@ -1,4 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.3" d="M9 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V9C1.5 9.82843 2.17157 10.5 3 10.5H9C9.82843 10.5 10.5 9.82843 10.5 9V3C10.5 2.17157 9.82843 1.5 9 1.5Z" stroke="#C6CAD0" stroke-width="0.9"/>
<path d="M4.32058 8.53711C3.99873 8.53711 3.79672 8.34879 3.79672 8.05092C3.79672 7.96532 3.82069 7.84891 3.8652 7.72222L5.21422 4.04838C5.35802 3.64778 5.60112 3.46289 5.99144 3.46289C6.39546 3.46289 6.63514 3.64093 6.78236 4.04495L8.13823 7.72222C8.18616 7.85575 8.20328 7.9482 8.20328 8.05092C8.20328 8.3351 7.98758 8.53711 7.68627 8.53711C7.39524 8.53711 7.24117 8.40358 7.14872 8.08173L6.8885 7.30108H5.10465L4.84444 8.07146C4.74857 8.40015 4.59449 8.53711 4.32058 8.53711ZM5.33063 6.49989H6.64883L5.99487 4.47636H5.9709L5.33063 6.49989Z" fill="#C6CAD0"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.76074 11.3828C5.3316 11.3828 5.06226 11.1317 5.06226 10.7346C5.06226 10.6204 5.09422 10.4652 5.15356 10.2963L6.95226 5.39784C7.14399 4.86371 7.46812 4.61719 7.98855 4.61719C8.52724 4.61719 8.84682 4.85457 9.04311 5.39327L10.8509 10.2963C10.9148 10.4743 10.9377 10.5976 10.9377 10.7346C10.9377 11.1135 10.6501 11.3828 10.2483 11.3828C9.86028 11.3828 9.65486 11.2048 9.53159 10.7756L9.18463 9.73477H6.80616L6.45922 10.7619C6.33139 11.2002 6.12595 11.3828 5.76074 11.3828ZM7.10747 8.66652H8.86507L7.99312 5.96848H7.96116L7.10747 8.66652Z" fill="#C6CAD0"/>
<path opacity="0.3" d="M12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4C14 2.89543 13.1046 2 12 2Z" stroke="#C6CAD0" stroke-width="1.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 868 B

After

Width:  |  Height:  |  Size: 851 B

View file

@ -1,15 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.146 2H4.85398C3.55391 2 2.5 3.05391 2.5 4.35398V11.646C2.5 12.9461 3.55391 14 4.85398 14H12.146C13.4461 14 14.5 12.9461 14.5 11.646V4.35398C14.5 3.05391 13.4461 2 12.146 2Z" stroke="black" stroke-width="1.11504"/>
<path opacity="0.487119" d="M10.5177 3.60177H6.21681C5.8698 3.60177 5.58849 3.88308 5.58849 4.23009V4.23894C5.58849 4.58595 5.8698 4.86726 6.21681 4.86726H10.5177C10.8647 4.86726 11.146 4.58595 11.146 4.23894V4.23009C11.146 3.88308 10.8647 3.60177 10.5177 3.60177Z" fill="black"/>
<path opacity="0.845099" d="M8.83628 3.60177H4.53539C4.18838 3.60177 3.90707 3.88308 3.90707 4.23009V4.23894C3.90707 4.58595 4.18838 4.86726 4.53539 4.86726H8.83628C9.18329 4.86726 9.4646 4.58595 9.4646 4.23894V4.23009C9.4646 3.88308 9.18329 3.60177 8.83628 3.60177Z" fill="black"/>
<path opacity="0.487119" d="M12.7389 5.10619H8.43806C8.09105 5.10619 7.80974 5.3875 7.80974 5.73451V5.74336C7.80974 6.09037 8.09105 6.37168 8.43806 6.37168H12.7389C13.086 6.37168 13.3673 6.09037 13.3673 5.74336V5.73451C13.3673 5.3875 13.086 5.10619 12.7389 5.10619Z" fill="black"/>
<path opacity="0.845099" d="M11.0575 5.10619H6.75664C6.40963 5.10619 6.12832 5.3875 6.12832 5.73451V5.74336C6.12832 6.09037 6.40963 6.37168 6.75664 6.37168H11.0575C11.4045 6.37168 11.6858 6.09037 11.6858 5.74336V5.73451C11.6858 5.3875 11.4045 5.10619 11.0575 5.10619Z" fill="black"/>
<path opacity="0.487119" d="M11.5619 6.59292H7.26106C6.91405 6.59292 6.63274 6.87423 6.63274 7.22124V7.23009C6.63274 7.5771 6.91405 7.85841 7.26106 7.85841H11.5619C11.909 7.85841 12.1903 7.5771 12.1903 7.23009V7.22124C12.1903 6.87423 11.909 6.59292 11.5619 6.59292Z" fill="black"/>
<path opacity="0.845099" d="M9.86284 6.59292H5.56195C5.21494 6.59292 4.93363 6.87423 4.93363 7.22124V7.23009C4.93363 7.5771 5.21494 7.85841 5.56195 7.85841H9.86284C10.2098 7.85841 10.4912 7.5771 10.4912 7.23009V7.22124C10.4912 6.87423 10.2098 6.59292 9.86284 6.59292Z" fill="black"/>
<path opacity="0.487119" d="M10.1903 8.10619H5.88937C5.54236 8.10619 5.26105 8.3875 5.26105 8.73451V8.74336C5.26105 9.09037 5.54236 9.37168 5.88937 9.37168H10.1903C10.5373 9.37168 10.8186 9.09037 10.8186 8.74336V8.73451C10.8186 8.3875 10.5373 8.10619 10.1903 8.10619Z" fill="black"/>
<path opacity="0.845099" d="M8.50886 8.10619H4.20797C3.86096 8.10619 3.57965 8.3875 3.57965 8.73451V8.74336C3.57965 9.09037 3.86096 9.37168 4.20797 9.37168H8.50886C8.85587 9.37168 9.13717 9.09037 9.13717 8.74336V8.73451C9.13717 8.3875 8.85587 8.10619 8.50886 8.10619Z" fill="black"/>
<path opacity="0.487119" d="M10.9513 9.59292H7.37611C7.0291 9.59292 6.74779 9.87423 6.74779 10.2212V10.2301C6.74779 10.5771 7.0291 10.8584 7.37611 10.8584H10.9513C11.2983 10.8584 11.5796 10.5771 11.5796 10.2301V10.2212C11.5796 9.87423 11.2983 9.59292 10.9513 9.59292Z" fill="black"/>
<path opacity="0.845099" d="M9.23452 9.59292H5.65929C5.31228 9.59292 5.03098 9.87423 5.03098 10.2212V10.2301C5.03098 10.5771 5.31228 10.8584 5.65929 10.8584H9.23452C9.58153 10.8584 9.86283 10.5771 9.86283 10.2301V10.2212C9.86283 9.87423 9.58153 9.59292 9.23452 9.59292Z" fill="black"/>
<path opacity="0.487119" d="M12.6062 11.0973H9.82744C9.48043 11.0973 9.19912 11.3787 9.19912 11.7257V11.7345C9.19912 12.0815 9.48043 12.3628 9.82744 12.3628H12.6062C12.9532 12.3628 13.2345 12.0815 13.2345 11.7345V11.7257C13.2345 11.3787 12.9532 11.0973 12.6062 11.0973Z" fill="black"/>
<path opacity="0.845099" d="M10.7389 11.0973H7.96017C7.61316 11.0973 7.33186 11.3787 7.33186 11.7257V11.7345C7.33186 12.0815 7.61316 12.3628 7.96017 12.3628H10.7389C11.0859 12.3628 11.3673 12.0815 11.3673 11.7345V11.7257C11.3673 11.3787 11.0859 11.0973 10.7389 11.0973Z" fill="black"/>
<path d="M11.646 2H4.35398C3.05391 2 2 3.05391 2 4.35398V11.646C2 12.9461 3.05391 14 4.35398 14H11.646C12.9461 14 14 12.9461 14 11.646V4.35398C14 3.05391 12.9461 2 11.646 2Z" stroke="#C6CAD0" stroke-width="1.11504"/>
<path opacity="0.487119" d="M10.0177 3.60178H5.71681C5.3698 3.60178 5.08849 3.88309 5.08849 4.2301V4.23895C5.08849 4.58596 5.3698 4.86727 5.71681 4.86727H10.0177C10.3647 4.86727 10.646 4.58596 10.646 4.23895V4.2301C10.646 3.88309 10.3647 3.60178 10.0177 3.60178Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M8.33628 3.60178H4.03539C3.68838 3.60178 3.40707 3.88309 3.40707 4.2301V4.23895C3.40707 4.58596 3.68838 4.86727 4.03539 4.86727H8.33628C8.68329 4.86727 8.9646 4.58596 8.9646 4.23895V4.2301C8.9646 3.88309 8.68329 3.60178 8.33628 3.60178Z" fill="#C6CAD0"/>
<path opacity="0.487119" d="M12.2389 5.1062H7.93806C7.59105 5.1062 7.30974 5.38751 7.30974 5.73452V5.74337C7.30974 6.09038 7.59105 6.37169 7.93806 6.37169H12.2389C12.586 6.37169 12.8673 6.09038 12.8673 5.74337V5.73452C12.8673 5.38751 12.586 5.1062 12.2389 5.1062Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M10.5575 5.1062H6.25665C5.90964 5.1062 5.62833 5.38751 5.62833 5.73452V5.74337C5.62833 6.09038 5.90964 6.37169 6.25665 6.37169H10.5575C10.9045 6.37169 11.1858 6.09038 11.1858 5.74337V5.73452C11.1858 5.38751 10.9045 5.1062 10.5575 5.1062Z" fill="#C6CAD0"/>
<path opacity="0.487119" d="M11.0619 6.59293H6.76106C6.41405 6.59293 6.13274 6.87424 6.13274 7.22125V7.2301C6.13274 7.57711 6.41405 7.85842 6.76106 7.85842H11.0619C11.409 7.85842 11.6903 7.57711 11.6903 7.2301V7.22125C11.6903 6.87424 11.409 6.59293 11.0619 6.59293Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M9.36283 6.59293H5.06194C4.71493 6.59293 4.43362 6.87424 4.43362 7.22125V7.2301C4.43362 7.57711 4.71493 7.85842 5.06194 7.85842H9.36283C9.70979 7.85842 9.99119 7.57711 9.99119 7.2301V7.22125C9.99119 6.87424 9.70979 6.59293 9.36283 6.59293Z" fill="#C6CAD0"/>
<path opacity="0.487119" d="M9.6903 8.1062H5.38937C5.04236 8.1062 4.76105 8.38751 4.76105 8.73452V8.74337C4.76105 9.09038 5.04236 9.37169 5.38937 9.37169H9.6903C10.0373 9.37169 10.3186 9.09038 10.3186 8.74337V8.73452C10.3186 8.38751 10.0373 8.1062 9.6903 8.1062Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M8.00886 8.1062H3.70797C3.36096 8.1062 3.07965 8.38751 3.07965 8.73452V8.74337C3.07965 9.09038 3.36096 9.37169 3.70797 9.37169H8.00886C8.35587 9.37169 8.63717 9.09038 8.63717 8.74337V8.73452C8.63717 8.38751 8.35587 8.1062 8.00886 8.1062Z" fill="#C6CAD0"/>
<path opacity="0.487119" d="M10.4513 9.59293H6.87611C6.5291 9.59293 6.24779 9.87424 6.24779 10.2212V10.2301C6.24779 10.5771 6.5291 10.8584 6.87611 10.8584H10.4513C10.7983 10.8584 11.0796 10.5771 11.0796 10.2301V10.2212C11.0796 9.87424 10.7983 9.59293 10.4513 9.59293Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M8.73452 9.59293H5.15929C4.81228 9.59293 4.53098 9.87424 4.53098 10.2212V10.2301C4.53098 10.5771 4.81228 10.8584 5.15929 10.8584H8.73452C9.08153 10.8584 9.36283 10.5771 9.36283 10.2301V10.2212C9.36283 9.87424 9.08153 9.59293 8.73452 9.59293Z" fill="#C6CAD0"/>
<path opacity="0.487119" d="M12.1062 11.0973H9.32745C8.98044 11.0973 8.69913 11.3787 8.69913 11.7257V11.7345C8.69913 12.0815 8.98044 12.3628 9.32745 12.3628H12.1062C12.4532 12.3628 12.7345 12.0815 12.7345 11.7345V11.7257C12.7345 11.3787 12.4532 11.0973 12.1062 11.0973Z" fill="#C6CAD0"/>
<path opacity="0.845099" d="M10.2389 11.0973H7.46017C7.11316 11.0973 6.83186 11.3787 6.83186 11.7257V11.7345C6.83186 12.0815 7.11316 12.3628 7.46017 12.3628H10.2389C10.5859 12.3628 10.8673 12.0815 10.8673 11.7345V11.7257C10.8673 11.3787 10.5859 11.0973 10.2389 11.0973Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5768 6.73011C14.8987 5.77678 14.7879 4.73245 14.2731 3.86531C13.4989 2.53528 11.9427 1.85102 10.4227 2.17303C9.74656 1.42139 8.7751 0.993944 7.75664 1.00006C6.20301 0.996569 4.82452 1.98358 4.34655 3.44224C3.34849 3.64393 2.48699 4.26038 1.98286 5.13408C1.20294 6.46061 1.38074 8.13277 2.4227 9.27029C2.1008 10.2236 2.21164 11.268 2.72642 12.1351C3.50057 13.4651 5.05686 14.1494 6.57679 13.8274C7.25251 14.579 8.22441 15.0064 9.24287 14.9999C10.7974 15.0038 12.1763 14.0159 12.6543 12.556C13.6524 12.3543 14.5139 11.7379 15.018 10.8641C15.797 9.5376 15.6188 7.86676 14.5773 6.72924L14.5768 6.73011ZM9.24376 14.0851C8.62169 14.0859 8.01912 13.8711 7.5416 13.4778C7.56332 13.4664 7.60101 13.4459 7.6254 13.431L10.4507 11.821C10.5952 11.7401 10.6839 11.5882 10.683 11.4242V7.49401L11.877 8.17433C11.8899 8.18045 11.8983 8.1927 11.9001 8.2067V11.4614C11.8983 12.9086 10.7105 14.082 9.24376 14.0851ZM3.53116 11.6775C3.21946 11.1464 3.10729 10.5237 3.21414 9.91955C3.23498 9.9318 3.27178 9.95411 3.29794 9.96898L6.1232 11.5791C6.26642 11.6617 6.44377 11.6617 6.58743 11.5791L10.0365 9.61373V10.9744C10.0374 10.9884 10.0308 11.002 10.0197 11.0107L7.16383 12.6378C5.89175 13.3606 4.26674 12.9309 3.53116 11.6775ZM2.7876 5.59215C3.09797 5.06014 3.58792 4.65326 4.17141 4.44195C4.17141 4.46601 4.17008 4.50845 4.17008 4.5382V7.75869C4.1692 7.92232 4.25787 8.07414 4.40198 8.15508L7.85108 10.1199L6.65704 10.8002C6.64507 10.8081 6.62999 10.8094 6.61669 10.8037L3.76039 9.17535C2.49098 8.44995 2.05601 6.84692 2.7876 5.59215ZM12.598 7.84488L9.14887 5.8796L10.3429 5.19971C10.3549 5.19183 10.37 5.19052 10.3833 5.19621L13.2396 6.8233C14.5112 7.54826 14.947 9.15347 14.2124 10.4082C13.9015 10.9394 13.412 11.3463 12.829 11.5581V8.24127C12.8303 8.07764 12.7417 7.92626 12.598 7.84488ZM13.7863 6.07998C13.7654 6.06729 13.7286 6.04541 13.7025 6.03054L10.8772 4.42051C10.734 4.33782 10.5566 4.33782 10.413 4.42051L6.96386 6.3858V5.02514C6.96298 5.01114 6.96963 4.99758 6.98071 4.98883L9.83657 3.36305C11.1086 2.63898 12.735 3.06992 13.4683 4.32557C13.7783 4.85583 13.8914 5.47665 13.7863 6.07998ZM6.31475 8.50509L5.12026 7.82476C5.1074 7.81863 5.09898 7.80638 5.09721 7.79238V4.53776C5.09809 3.08873 6.28947 1.91446 7.75797 1.91533C8.37916 1.91533 8.98039 2.13059 9.45792 2.52259C9.43619 2.53397 9.39894 2.55453 9.37412 2.56941L6.54885 4.17944C6.40431 4.26038 6.31563 4.41176 6.31652 4.57582L6.31475 8.50509ZM6.96342 7.12518L8.49976 6.24973L10.0361 7.12475V8.87521L8.49976 9.75023L6.96342 8.87521V7.12518Z" fill="black"/>
<path d="M14.0768 6.73011C14.3987 5.77678 14.2879 4.73245 13.7731 3.86531C12.9989 2.53528 11.4427 1.85102 9.9227 2.17303C9.24656 1.42139 8.2751 0.993949 7.25664 1.00006C5.70301 0.996574 4.32452 1.98358 3.84655 3.44224C2.84849 3.64393 1.98699 4.26038 1.48286 5.13408C0.702939 6.46061 0.880738 8.13278 1.9227 9.2703C1.6008 10.2236 1.71164 11.268 2.22642 12.1351C3.00057 13.4651 4.55686 14.1494 6.07679 13.8274C6.75251 14.579 7.72441 15.0064 8.74287 14.9999C10.2974 15.0038 11.6763 14.0159 12.1543 12.556C13.1524 12.3543 14.0139 11.7379 14.518 10.8641C15.297 9.53761 15.1188 7.86676 14.0773 6.72924L14.0768 6.73011ZM8.74376 14.0851C8.12169 14.0859 7.51912 13.8711 7.0416 13.4778C7.06332 13.4664 7.10101 13.4459 7.1254 13.431L9.9507 11.821C10.0952 11.7401 10.1839 11.5882 10.183 11.4242V7.49401L11.377 8.17433C11.3899 8.18045 11.3983 8.19271 11.4001 8.20671V11.4614C11.3983 12.9086 10.2105 14.082 8.74376 14.0851ZM3.03116 11.6775C2.71946 11.1464 2.60729 10.5237 2.71414 9.91955C2.73498 9.9318 2.77178 9.95411 2.79794 9.96898L5.6232 11.5791C5.76642 11.6617 5.94377 11.6617 6.08743 11.5791L9.5365 9.61374V10.9744C9.5374 10.9884 9.5308 11.002 9.5197 11.0107L6.66383 12.6378C5.39175 13.3606 3.76674 12.9309 3.03116 11.6775ZM2.2876 5.59215C2.59797 5.06014 3.08792 4.65326 3.67141 4.44195C3.67141 4.46601 3.67008 4.50845 3.67008 4.5382V7.75869C3.6692 7.92232 3.75787 8.07414 3.90198 8.15508L7.35108 10.1199L6.15704 10.8002C6.14507 10.8081 6.12999 10.8094 6.11669 10.8037L3.26039 9.17535C1.99098 8.44995 1.55601 6.84693 2.2876 5.59215ZM12.098 7.84488L8.64887 5.8796L9.8429 5.19971C9.8549 5.19183 9.87 5.19052 9.8833 5.19621L12.7396 6.8233C14.0112 7.54826 14.447 9.15348 13.7124 10.4082C13.4015 10.9394 12.912 11.3463 12.329 11.5581V8.24127C12.3303 8.07764 12.2417 7.92626 12.098 7.84488ZM13.2863 6.07998C13.2654 6.06729 13.2286 6.04541 13.2025 6.03054L10.3772 4.42051C10.234 4.33782 10.0566 4.33782 9.913 4.42051L6.46386 6.3858V5.02514C6.46298 5.01114 6.46963 4.99758 6.48071 4.98883L9.33657 3.36305C10.6086 2.63898 12.235 3.06992 12.9683 4.32557C13.2783 4.85583 13.3914 5.47665 13.2863 6.07998ZM5.81475 8.50509L4.62026 7.82476C4.6074 7.81863 4.59898 7.80638 4.59721 7.79238V4.53776C4.59809 3.08873 5.78947 1.91446 7.25797 1.91533C7.87916 1.91533 8.48039 2.13059 8.95792 2.52259C8.93619 2.53397 8.89894 2.55453 8.87412 2.56941L6.04885 4.17944C5.90431 4.26038 5.81563 4.41176 5.81652 4.57582L5.81475 8.50509ZM6.46342 7.12518L7.99976 6.24973L9.5361 7.12475V8.87521L7.99976 9.75023L6.46342 8.87521V7.12518Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8451 5.50949L13.1109 15H15.2342L15.5 2.05527L12.8451 5.50949ZM15.499 1H12.2574L7.17206 7.61904L8.79335 9.72761L15.499 1ZM1.5 14.999H4.73963L6.36092 12.8905L4.73963 10.7809L1.5 14.999ZM1.5 5.50851L8.79335 14.999H12.034L4.74061 5.50949L1.5 5.50851Z" fill="black"/>
<path d="M12.3451 5.50949L12.6109 15H14.7342L15 2.05527L12.3451 5.50949ZM14.999 1H11.7574L6.67206 7.61904L8.29335 9.72761L14.999 1ZM1 14.999H4.23963L5.86092 12.8905L4.23963 10.7809L1 14.999ZM1 5.50851L8.29335 14.999H11.534L4.24061 5.50949L1 5.50851Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 371 B

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.625 2.75C3.4179 2.75 3.25 2.9179 3.25 3.125V11.375H2.5V3.125C2.5 2.50368 3.00368 2 3.625 2H13.6723C14.1735 2 14.4244 2.6059 14.0701 2.96025L7.88189 9.14843H9.625V8.375H10.375V9.33593C10.375 9.6466 10.1232 9.8984 9.8125 9.8984H7.13189L5.84282 11.1875H11.6875V6.5H12.4375V11.1875C12.4375 11.6017 12.1017 11.9375 11.6875 11.9375H5.09282L3.78032 13.25H13.375C13.5821 13.25 13.75 13.0821 13.75 12.875V4.625H14.5V12.875C14.5 13.4963 13.9963 14 13.375 14H3.32767C2.82653 14 2.57557 13.3941 2.92992 13.0397L9.09468 6.875H7.375V7.625H6.625V6.6875C6.625 6.37684 6.87684 6.125 7.1875 6.125H9.84468L11.1571 4.8125H5.3125V9.5H4.5625V4.8125C4.5625 4.39829 4.89829 4.0625 5.3125 4.0625H11.9071L13.2197 2.75H3.625Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.125 2.75C2.9179 2.75 2.75 2.9179 2.75 3.125V11.375H2V3.125C2 2.50368 2.50368 2 3.125 2H13.1723C13.6735 2 13.9244 2.6059 13.5701 2.96025L7.38189 9.14843H9.125V8.375H9.875V9.33593C9.875 9.6466 9.6232 9.8984 9.3125 9.8984H6.63189L5.34282 11.1875H11.1875V6.5H11.9375V11.1875C11.9375 11.6017 11.6017 11.9375 11.1875 11.9375H4.59282L3.28032 13.25H12.875C13.0821 13.25 13.25 13.0821 13.25 12.875V4.625H14V12.875C14 13.4963 13.4963 14 12.875 14H2.82767C2.32653 14 2.07557 13.3941 2.42992 13.0397L8.59468 6.875H6.875V7.625H6.125V6.6875C6.125 6.37684 6.37684 6.125 6.6875 6.125H9.34468L10.6571 4.8125H4.8125V9.5H4.0625V4.8125C4.0625 4.39829 4.39829 4.0625 4.8125 4.0625H11.4071L12.7197 2.75H3.125Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 870 B

After

Width:  |  Height:  |  Size: 861 B

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="black"/>
<path d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79087 10.2091 4 8 4C5.79087 4 4 5.79087 4 8C4 10.2091 5.79087 12 8 12Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 239 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2806 4.66818L8.26042 1.76982C8.09921 1.67673 7.9003 1.67673 7.73909 1.76982L2.71918 4.66818C2.58367 4.74642 2.5 4.89112 2.5 5.04785V10.8924C2.5 11.0489 2.58367 11.1938 2.71918 11.2721L7.73934 14.1704C7.90054 14.2635 8.09946 14.2635 8.26066 14.1704L13.2808 11.2721C13.4163 11.1938 13.5 11.0491 13.5 10.8924V5.04785C13.5 4.89136 13.4163 4.74642 13.2808 4.66818H13.2806ZM12.9653 5.28212L8.11901 13.676C8.08626 13.7326 7.99977 13.7095 7.99977 13.6439V8.14771C7.99977 8.03788 7.94107 7.9363 7.84586 7.88115L3.08613 5.13317C3.02957 5.10041 3.05266 5.0139 3.11818 5.0139H12.8106C12.9483 5.0139 13.0343 5.1631 12.9655 5.28236H12.9653V5.28212Z" fill="#C4CAD4"/>
<path d="M13.2806 4.69807L8.26042 1.79971C8.09921 1.70662 7.9003 1.70662 7.73909 1.79971L2.71918 4.69807C2.58367 4.77631 2.5 4.92101 2.5 5.07774V10.9223C2.5 11.0788 2.58367 11.2237 2.71918 11.302L7.73934 14.2003C7.90054 14.2934 8.09946 14.2934 8.26066 14.2003L13.2808 11.302C13.4163 11.2237 13.5 11.079 13.5 10.9223V5.07774C13.5 4.92125 13.4161 4.77631 13.2806 4.69807ZM12.9653 5.31201L8.11901 13.7059C8.08626 13.7625 7.99977 13.7394 7.99977 13.6738V8.1776C7.99977 8.06777 7.94107 7.96619 7.84586 7.91104L3.08613 5.16306C3.02957 5.1303 3.05266 5.04379 3.11818 5.04379H12.8106C12.9483 5.04379 13.0341 5.19275 12.9653 5.31201Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 769 B

After

Width:  |  Height:  |  Size: 746 B

View file

@ -1,10 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2716_663)">
<path d="M8.47552 2.45453C11.5167 2.45457 13.9814 4.94501 13.9814 8.01623C13.9814 11.0875 11.5167 13.578 8.47552 13.5781C5.43427 13.5781 2.96948 11.0875 2.96948 8.01623C2.9695 4.94498 5.43429 2.45453 8.47552 2.45453ZM10.8795 4.70348C10.7605 4.16887 10.1328 3.85468 9.53627 3.96342C8.97622 4.06552 7.62871 4.45681 7.62057 4.45916C9.29414 4.44469 9.57429 4.4726 9.69939 4.64751C9.77324 4.7508 9.66576 4.89248 9.21944 4.96538C8.73515 5.04447 7.73014 5.13958 7.72343 5.14022C6.75441 5.19776 6.07177 5.20168 5.86705 5.63512C5.73334 5.91827 6.00968 6.16857 6.13082 6.32527C6.64271 6.89455 7.38215 7.20158 7.85809 7.42767C8.03716 7.51274 8.56257 7.67345 8.56257 7.67345C7.01855 7.58853 5.90474 8.06267 5.2514 8.60855C4.51246 9.29204 4.83937 10.1067 6.35327 10.6084C7.24742 10.9047 7.69094 11.0439 9.02473 10.9238C9.81031 10.8815 9.9342 10.9068 9.94203 10.9712C9.95275 11.062 9.06932 11.2874 8.82812 11.357C8.21455 11.534 6.60645 11.8913 6.59758 11.8932C6.60115 11.8935 7.06249 11.9257 7.65531 11.8735C7.89632 11.8522 8.81142 11.7624 9.49557 11.6123C9.49557 11.6123 10.3297 11.4338 10.7759 11.2693C11.2429 11.0973 11.497 10.9512 11.6113 10.7443C11.6063 10.7019 11.6465 10.5516 11.4313 10.4613C10.8807 10.2304 10.2423 10.2721 8.9789 10.2453C7.57789 10.1972 7.11184 9.9626 6.86356 9.77373C6.62548 9.58212 6.74518 9.05204 7.76528 8.5851C8.27917 8.33646 10.2935 7.87759 10.2935 7.87759C9.61511 7.54227 8.35014 6.95284 8.09005 6.82552C7.86199 6.71388 7.49701 6.54572 7.4179 6.34233C7.32824 6.14709 7.6297 5.97888 7.79813 5.9307C8.34057 5.77424 9.10635 5.67701 9.8033 5.66609C10.1536 5.66061 10.2105 5.63806 10.2105 5.63806C10.6939 5.55787 11.0121 5.22722 10.8795 4.70348Z" fill="black"/>
<mask id="mask0_4385_22072" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="2" width="12" height="12">
<path d="M14 2H2V14H14V2Z" fill="white"/>
</mask>
<g mask="url(#mask0_4385_22072)">
<path d="M7.97552 2.45453C11.0167 2.45457 13.4814 4.94501 13.4814 8.01623C13.4814 11.0875 11.0167 13.578 7.97552 13.5781C4.93427 13.5781 2.46948 11.0875 2.46948 8.01623C2.4695 4.94498 4.93429 2.45453 7.97552 2.45453ZM10.3795 4.70348C10.2605 4.16887 9.6328 3.85468 9.03627 3.96342C8.47622 4.06552 7.12871 4.45681 7.12057 4.45916C8.79414 4.44469 9.07429 4.4726 9.19939 4.64751C9.27324 4.7508 9.16576 4.89248 8.71944 4.96538C8.23515 5.04447 7.23014 5.13958 7.22343 5.14022C6.25441 5.19776 5.57177 5.20168 5.36705 5.63512C5.23334 5.91827 5.50968 6.16857 5.63082 6.32527C6.14271 6.89455 6.88215 7.20158 7.35809 7.42767C7.53716 7.51274 8.06257 7.67345 8.06257 7.67345C6.51855 7.58853 5.40474 8.06267 4.7514 8.60855C4.01246 9.29204 4.33937 10.1067 5.85327 10.6084C6.74742 10.9047 7.19094 11.0439 8.52473 10.9238C9.31031 10.8815 9.4342 10.9068 9.44203 10.9712C9.45275 11.062 8.56932 11.2874 8.32812 11.357C7.71455 11.534 6.10645 11.8913 6.09758 11.8932C6.10115 11.8935 6.56249 11.9257 7.15531 11.8735C7.39632 11.8522 8.31142 11.7624 8.99557 11.6123C8.99557 11.6123 9.8297 11.4338 10.2759 11.2693C10.7429 11.0973 10.997 10.9512 11.1113 10.7443C11.1063 10.7019 11.1465 10.5516 10.9313 10.4613C10.3807 10.2304 9.7423 10.2721 8.4789 10.2453C7.07789 10.1972 6.61184 9.9626 6.36356 9.77373C6.12548 9.58212 6.24518 9.05204 7.26528 8.5851C7.77917 8.33646 9.7935 7.87759 9.7935 7.87759C9.11511 7.54227 7.85014 6.95284 7.59005 6.82552C7.36199 6.71388 6.99701 6.54572 6.9179 6.34233C6.82824 6.14709 7.1297 5.97888 7.29813 5.9307C7.84057 5.77424 8.60635 5.67701 9.3033 5.66609C9.6536 5.66061 9.7105 5.63806 9.7105 5.63806C10.1939 5.55787 10.5121 5.22722 10.3795 4.70348Z" fill="#C6CAD0"/>
</g>
<defs>
<clipPath id="clip0_2716_663">
<rect width="12" height="12" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.6725 13.9985C3.36161 13.9982 3.06354 13.8746 2.84371 13.6548C2.62388 13.435 2.50026 13.1369 2.5 12.826V7.494C2.5 6.8325 2.7675 6.185 3.2365 5.7165L6.219 2.736C6.45192 2.50247 6.72867 2.31724 7.03335 2.19094C7.33804 2.06464 7.66467 1.99975 7.9945 2H13.3275C13.6384 2.00027 13.9365 2.12388 14.1563 2.34371C14.3761 2.56354 14.4997 2.86162 14.5 3.1725V8.5045C14.4983 9.17074 14.2336 9.80936 13.7635 10.2815L10.781 13.264C10.5477 13.4976 10.2706 13.6829 9.96561 13.8092C9.66059 13.9355 9.33364 14.0003 9.0035 14V13.9985H3.6725ZM8.157 10.5715H5.243V11.257H8.157V10.5715ZM4.4815 5.257H11.243V12.0165L13.3715 9.888C13.7373 9.52036 13.9433 9.02316 13.9445 8.5045V3.1725C13.9445 2.8335 13.6685 2.5555 13.3275 2.5555H7.9945C7.73753 2.55499 7.483 2.6053 7.24556 2.70356C7.00813 2.80181 6.79246 2.94606 6.611 3.128L4.4815 5.257ZM4.3855 5.353L3.628 6.11C3.26258 6.47809 3.0569 6.97533 3.0555 7.494V12.826C3.0555 13.165 3.3315 13.443 3.6725 13.443H9.0055C9.26249 13.4434 9.51701 13.3929 9.75445 13.2946C9.99188 13.1963 10.2075 13.052 10.389 12.87L11.145 12.1145H4.3855V5.353Z" fill="black"/>
<path d="M3.1725 13.9985C2.86161 13.9982 2.56354 13.8746 2.34371 13.6548C2.12388 13.435 2.00026 13.1369 2 12.826V7.494C2 6.8325 2.2675 6.185 2.7365 5.7165L5.719 2.736C5.95192 2.50247 6.22867 2.31724 6.53335 2.19094C6.83804 2.06464 7.16467 1.99975 7.4945 2H12.8275C13.1384 2.00027 13.4365 2.12388 13.6563 2.34371C13.8761 2.56354 13.9997 2.86162 14 3.1725V8.5045C13.9983 9.17074 13.7336 9.80936 13.2635 10.2815L10.281 13.264C10.0477 13.4976 9.7706 13.6829 9.46561 13.8092C9.16059 13.9355 8.83364 14.0003 8.5035 14V13.9985H3.1725ZM7.657 10.5715H4.743V11.257H7.657V10.5715ZM3.9815 5.257H10.743V12.0165L12.8715 9.888C13.2373 9.52036 13.4433 9.02316 13.4445 8.5045V3.1725C13.4445 2.8335 13.1685 2.5555 12.8275 2.5555H7.4945C7.23753 2.55499 6.983 2.6053 6.74556 2.70356C6.50813 2.80181 6.29246 2.94606 6.111 3.128L3.9815 5.257ZM3.8855 5.353L3.128 6.11C2.76258 6.47809 2.5569 6.97533 2.5555 7.494V12.826C2.5555 13.165 2.8315 13.443 3.1725 13.443H8.5055C8.76249 13.4434 9.01701 13.3929 9.25445 13.2946C9.49188 13.1963 9.7075 13.052 9.889 12.87L10.645 12.1145H3.8855V5.353Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0945 8.01611C13.0945 7.87619 12.9911 7.79551 12.8642 7.8356L4.13456 10.6038C4.00742 10.6441 3.90427 10.7904 3.90427 10.9301V13.7593C3.90427 13.8992 4.00742 13.9801 4.13456 13.9398L12.8642 11.1719C12.9911 11.1315 13.0945 10.9852 13.0945 10.8453V8.01611Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.90427 7.92597C3.90427 8.06588 4.00742 8.21218 4.13456 8.25252L12.8655 11.0209C12.9926 11.0613 13.0958 10.9803 13.0958 10.8407V8.01124C13.0958 7.87158 12.9926 7.72529 12.8655 7.68494L4.13456 4.91652C4.00742 4.87618 3.90427 4.95686 3.90427 5.09677V7.92597Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0945 2.20248C13.0945 2.06256 12.9911 1.98163 12.8642 2.02197L4.13456 4.78988C4.00742 4.83022 3.90427 4.97652 3.90427 5.11644V7.94563C3.90427 8.08554 4.00742 8.16622 4.13456 8.12614L12.8642 5.35797C12.9911 5.31763 13.0945 5.17133 13.0945 5.03167V2.20248Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5945 8.03524C12.5945 7.89532 12.4911 7.81464 12.3642 7.85473L3.63453 10.6229C3.50739 10.6632 3.40424 10.8095 3.40424 10.9492V13.7784C3.40424 13.9183 3.50739 13.9992 3.63453 13.9589L12.3642 11.191C12.4911 11.1506 12.5945 11.0043 12.5945 10.8644V8.03524Z" fill="#C6CAD0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.40424 7.9451C3.40424 8.08501 3.50739 8.23131 3.63453 8.27165L12.3655 11.04C12.4926 11.0804 12.5958 10.9994 12.5958 10.8598V8.03037C12.5958 7.89071 12.4926 7.74442 12.3655 7.70407L3.63453 4.93565C3.50739 4.89531 3.40424 4.97599 3.40424 5.1159V7.9451Z" fill="#C6CAD0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5945 2.22161C12.5945 2.08169 12.4911 2.00076 12.3642 2.0411L3.63453 4.80901C3.50739 4.84935 3.40424 4.99565 3.40424 5.13557V7.96476C3.40424 8.10467 3.50739 8.18535 3.63453 8.14527L12.3642 5.3771C12.4911 5.33676 12.5945 5.19046 12.5945 5.0508V2.22161Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0094 13.9181C11.1984 13.9917 11.4139 13.987 11.6047 13.8952L14.0753 12.7064C14.3349 12.5814 14.5 12.3187 14.5 12.0305V3.9696C14.5 3.68136 14.3349 3.41862 14.0753 3.2937L11.6047 2.10485C11.3543 1.98438 11.0614 2.01389 10.8416 2.17363C10.8102 2.19645 10.7803 2.22193 10.7523 2.25001L6.02261 6.56498L3.96246 5.00115C3.77068 4.85558 3.50244 4.86751 3.32432 5.02953L2.66356 5.63059C2.44569 5.82877 2.44544 6.17152 2.66302 6.37004L4.44965 8.00001L2.66302 9.62998C2.44544 9.82849 2.44569 10.1713 2.66356 10.3694L3.32432 10.9705C3.50244 11.1325 3.77068 11.1444 3.96246 10.9989L6.02261 9.43504L10.7523 13.75C10.8271 13.8249 10.915 13.8812 11.0094 13.9181ZM11.5018 5.27587L7.91309 8.00001L11.5018 10.7241V5.27587Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5094 13.9181C10.6984 13.9917 10.9139 13.987 11.1047 13.8952L13.5753 12.7064C13.8349 12.5814 14 12.3187 14 12.0305V3.9696C14 3.68136 13.8349 3.41862 13.5753 3.2937L11.1047 2.10485C10.8543 1.98438 10.5614 2.01389 10.3416 2.17363C10.3102 2.19645 10.2803 2.22193 10.2523 2.25001L5.52261 6.56498L3.46246 5.00115C3.27068 4.85558 3.00244 4.86751 2.82432 5.02953L2.16356 5.63059C1.94569 5.82877 1.94544 6.17152 2.16302 6.37004L3.94965 8.00001L2.16302 9.62998C1.94544 9.82849 1.94569 10.1713 2.16356 10.3694L2.82432 10.9705C3.00244 11.1325 3.27068 11.1444 3.46246 10.9989L5.52261 9.43504L10.2523 13.75C10.3271 13.8249 10.415 13.8812 10.5094 13.9181ZM11.0018 5.27587L7.41309 8.00001L11.0018 10.7241V5.27587Z" fill="#C6CAD0"/>
</svg>

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 872 B

View file

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57391 3.54203V5.2877L5.96882 5.81793C6.73561 6.10752 7.38411 6.35224 7.41674 6.36039C7.46161 6.37263 7.46977 5.96476 7.46977 4.08858V1.80044H6.02184H4.57391V3.54203Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
<path d="M8.53021 4.08451C8.53021 5.34074 8.54245 6.36856 8.56284 6.36856C8.57916 6.36856 9.22359 6.12384 9.99853 5.8261L11.4057 5.28771L11.4179 3.54205L11.4261 1.80046H9.97814H8.53021V4.08451Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
<path d="M4.57391 7.1435C4.57391 7.54728 4.58615 7.87766 4.60654 7.87766C4.66772 7.87766 6.49089 7.16797 6.49089 7.1435C6.49089 7.11902 4.66772 6.40934 4.60654 6.40934C4.58615 6.40934 4.57391 6.73971 4.57391 7.1435Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
<path d="M10.4186 6.76826C9.91696 6.95996 9.50909 7.12718 9.50909 7.1435C9.50909 7.16797 11.3323 7.87766 11.3975 7.87766C11.4138 7.87766 11.4261 7.54728 11.4261 7.1435C11.4261 6.58064 11.4138 6.40934 11.3771 6.41341C11.3486 6.41749 10.9162 6.57656 10.4186 6.76826Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
<path d="M5.9729 8.46498L4.57391 8.99929V11.5974V14.1996H5.32439C6.00553 14.1996 6.07894 14.1914 6.09933 14.1262C6.11157 14.0895 6.42563 13.0616 6.79679 11.8421L7.46977 9.63148V8.77496C7.46977 8.11422 7.45753 7.91844 7.42082 7.92252C7.39227 7.9266 6.73969 8.16724 5.9729 8.46498Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
<path d="M8.53021 8.77904V9.63964L9.1828 11.785C9.5458 12.9678 9.85986 13.9957 9.88025 14.065L9.92512 14.1996H10.6756H11.4261L11.4179 11.5974L11.4057 8.99929L9.99853 8.45682C9.22359 8.16316 8.57916 7.91844 8.56284 7.91844C8.54245 7.91844 8.53021 8.30591 8.53021 8.77904Z" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="0.483833"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

5
assets/icons/share.svg Normal file
View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.99689 2.05652L7.99929 9.98113" stroke="#C6CAD0" stroke-width="1.17299" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.1699 5.22637L8.00001 2.05652L4.83015 5.22637" stroke="#C6CAD0" stroke-width="1.17299" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.24522 8L3.24522 12.2653C3.24522 12.7104 3.37046 13.1372 3.59338 13.4519C3.81631 13.7667 4.11866 13.9435 4.43392 13.9435H11.5661C11.8813 13.9435 12.1837 13.7667 12.4066 13.4519C12.6295 13.1372 12.7548 12.7104 12.7548 12.2653V8" stroke="#C6CAD0" stroke-width="1.17299" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

View file

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.748 5.74794L14 7.99998L11.748 10.252" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 7.99997L3 7.99997" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3L8 6" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10L8 13" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.19924 8.90081L2.9472 6.64876L5.19924 4.39673" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.9472 6.64877H7.67649C8.00178 6.64877 8.32391 6.71285 8.6245 6.83734C8.92501 6.96184 9.19813 7.14431 9.42817 7.37434C9.6582 7.60437 9.84067 7.87746 9.96512 8.17802C10.0897 8.47856 10.1537 8.8007 10.1537 9.12601C10.1537 9.45131 10.0897 9.77345 9.96512 10.074C9.84067 10.3745 9.6582 10.6477 9.42817 10.8777C9.19813 11.1077 8.92501 11.2902 8.6245 11.4147C8.32391 11.5392 8.00178 11.6033 7.67649 11.6033H6.10005" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.0528 3.33331V12.6666" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View file

@ -1558,4 +1558,20 @@
"shift-tab": "git_graph::FocusPreviousTabStop",
},
},
{
"context": "SkillCreator",
"bindings": {
"ctrl-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
{
"context": "SkillCreator > Editor",
"bindings": {
"ctrl-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
]

View file

@ -1651,4 +1651,22 @@
"shift-tab": "git_graph::FocusPreviousTabStop",
},
},
{
"context": "SkillCreator",
"use_key_equivalents": true,
"bindings": {
"cmd-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
{
"context": "SkillCreator > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
]

View file

@ -1577,4 +1577,22 @@
"shift-tab": "git_graph::FocusPreviousTabStop",
},
},
{
"context": "SkillCreator",
"use_key_equivalents": true,
"bindings": {
"ctrl-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
{
"context": "SkillCreator > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-w": "workspace::CloseWindow",
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
]

View file

@ -943,7 +943,7 @@
"space w j": "workspace::ActivatePaneDown",
"space w k": "workspace::ActivatePaneUp",
"space w l": "workspace::ActivatePaneRight",
"space w q": "pane::CloseActiveItem",
"space w q": "pane::CloseActiveItem",
},
},
{
@ -1056,8 +1056,8 @@
"ctrl-d": "git_graph::ScrollDown",
"ctrl-u": "git_graph::ScrollUp",
"shift-g": "menu::SelectLast",
"g g": "menu::SelectFirst"
}
"g g": "menu::SelectFirst",
},
},
{
"context": "GitPanel && ChangesList && !GitBranchSelector",
@ -1205,4 +1205,18 @@
"enter": "editor::Newline",
},
},
{
"context": "SkillCreator",
"bindings": {
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
{
"context": "SkillCreator > Editor",
"bindings": {
"tab": "skill_creator::FocusNextField",
"shift-tab": "skill_creator::FocusPreviousField",
},
},
]

View file

@ -71,6 +71,8 @@
"agent_ui_font_size": null,
// The default font size for user messages in the agent panel.
"agent_buffer_font_size": 12,
// The default font size for the commit editor in the git panel and commit modal.
"git_commit_buffer_font_size": 12,
// How much to fade out unused code.
"unnecessary_code_fade": 0.3,
// Active pane styling settings.
@ -1133,6 +1135,7 @@
"spawn_agent": true,
"terminal": true,
"update_plan": true,
"update_title": true,
"search_web": true,
},
},
@ -1153,6 +1156,7 @@
"skill": true,
"spawn_agent": true,
"update_plan": true,
"update_title": true,
"search_web": true,
},
},

View file

@ -13,7 +13,7 @@ path = "src/acp_thread.rs"
doctest = false
[features]
test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot", "dep:image"]
test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
[dependencies]
action_log.workspace = true
@ -35,7 +35,7 @@ language_model.workspace = true
log.workspace = true
markdown.workspace = true
parking_lot = { workspace = true, optional = true }
image = { workspace = true, optional = true }
image.workspace = true
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true

View file

@ -648,9 +648,16 @@ impl Display for ToolCallStatus {
#[derive(Debug, PartialEq, Clone)]
pub enum ContentBlock {
Empty,
Markdown { markdown: Entity<Markdown> },
ResourceLink { resource_link: acp::ResourceLink },
Image { image: Arc<gpui::Image> },
Markdown {
markdown: Entity<Markdown>,
},
ResourceLink {
resource_link: acp::ResourceLink,
},
Image {
image: Arc<gpui::Image>,
dimensions: Option<gpui::Size<u32>>,
},
}
impl ContentBlock {
@ -692,8 +699,8 @@ impl ContentBlock {
};
}
(ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => {
if let Some(image) = Self::decode_image(image_content) {
*self = ContentBlock::Image { image };
if let Some((image, dimensions)) = Self::decode_image(image_content) {
*self = ContentBlock::Image { image, dimensions };
} else {
let new_content = Self::image_md(image_content);
*self = Self::create_markdown_block(new_content, language_registry, cx);
@ -721,14 +728,36 @@ impl ContentBlock {
}
}
fn decode_image(image_content: &acp::ImageContent) -> Option<Arc<gpui::Image>> {
fn decode_image(
image_content: &acp::ImageContent,
) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(image_content.data.as_bytes())
.ok()?;
let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?;
Some(Arc::new(gpui::Image::from_bytes(format, bytes)))
let dimensions = Self::image_dimensions(&bytes, format);
Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions))
}
fn image_dimensions(bytes: &[u8], format: gpui::ImageFormat) -> Option<gpui::Size<u32>> {
let format = match format {
gpui::ImageFormat::Png => image::ImageFormat::Png,
gpui::ImageFormat::Jpeg => image::ImageFormat::Jpeg,
gpui::ImageFormat::Webp => image::ImageFormat::WebP,
gpui::ImageFormat::Gif => image::ImageFormat::Gif,
gpui::ImageFormat::Svg => return None,
gpui::ImageFormat::Bmp => image::ImageFormat::Bmp,
gpui::ImageFormat::Tiff => image::ImageFormat::Tiff,
gpui::ImageFormat::Ico => image::ImageFormat::Ico,
gpui::ImageFormat::Pnm => image::ImageFormat::Pnm,
};
image::ImageReader::with_format(std::io::Cursor::new(bytes), format)
.into_dimensions()
.ok()
.map(|(width, height)| gpui::Size { width, height })
}
fn create_markdown_block(
@ -808,9 +837,9 @@ impl ContentBlock {
}
}
pub fn image(&self) -> Option<&Arc<gpui::Image>> {
pub fn image(&self) -> Option<(&Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
match self {
ContentBlock::Image { image } => Some(image),
ContentBlock::Image { image, dimensions } => Some((image, *dimensions)),
_ => None,
}
}
@ -895,7 +924,7 @@ impl ToolCallContent {
}
}
pub fn image(&self) -> Option<&Arc<gpui::Image>> {
pub fn image(&self) -> Option<(&Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
match self {
Self::ContentBlock(content) => content.image(),
_ => None,

View file

@ -115,6 +115,11 @@ pub trait AgentConnection {
self.supports_load_session() || self.supports_resume_session()
}
/// Whether this agent supports additional session directories.
fn supports_session_additional_directories(&self, _cx: &App) -> bool {
false
}
fn auth_methods(&self) -> &[acp::AuthMethod];
fn terminal_auth_task(
@ -702,6 +707,7 @@ mod test_support {
permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
supports_load_session: bool,
supports_session_additional_directories: bool,
agent_id: AgentId,
telemetry_id: SharedString,
}
@ -724,6 +730,7 @@ mod test_support {
permission_requests: HashMap::default(),
sessions: Arc::default(),
supports_load_session: false,
supports_session_additional_directories: false,
agent_id: AgentId::new("stub"),
telemetry_id: "stub".into(),
}
@ -746,6 +753,14 @@ mod test_support {
self
}
pub fn with_supports_session_additional_directories(
mut self,
supports_session_additional_directories: bool,
) -> Self {
self.supports_session_additional_directories = supports_session_additional_directories;
self
}
pub fn with_agent_id(mut self, agent_id: AgentId) -> Self {
self.agent_id = agent_id;
self
@ -863,6 +878,10 @@ mod test_support {
self.supports_load_session
}
fn supports_session_additional_directories(&self, _cx: &App) -> bool {
self.supports_session_additional_directories
}
fn load_session(
self: Rc<Self>,
session_id: acp::SessionId,

View file

@ -51,6 +51,8 @@ pub enum MentionUri {
#[serde(default, skip_serializing_if = "Option::is_none")]
abs_path: Option<PathBuf>,
line_range: RangeInclusive<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
column: Option<u32>,
},
Fetch {
url: Url,
@ -105,6 +107,17 @@ impl MentionUri {
Ok(start_line..=end_line)
}
let parse_column =
|input: Option<String>| -> Option<u32> { input?.parse::<u32>().ok()?.checked_sub(1) };
let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> {
for (key, _) in url.query_pairs() {
if !allowed.contains(&key.as_ref()) {
bail!("invalid query parameter")
}
}
Ok(())
};
let parse_absolute_path = |input: &str| -> Result<Self> {
let (path_input, fragment) = input
.split_once('#')
@ -114,6 +127,7 @@ impl MentionUri {
return Ok(MentionUri::Selection {
abs_path: Some(path_input.into()),
line_range: fragment,
column: None,
});
}
@ -123,10 +137,12 @@ impl MentionUri {
let line = row
.checked_sub(1)
.context("Line numbers should be 1-based")?;
// TODO: Preserve column info too.
Ok(MentionUri::Selection {
abs_path: Some(abs_path),
line_range: line..=line,
column: path_with_position
.column
.map(|column| column.saturating_sub(1)),
})
} else {
Ok(MentionUri::File { abs_path })
@ -156,8 +172,10 @@ impl MentionUri {
let path = normalized.as_ref();
if let Some(fragment) = url.fragment() {
validate_query_params(&url, &["symbol", "column"])?;
let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
if let Some(name) = single_query_param(&url, "symbol")? {
let column = parse_column(query_param(&url, "column"));
if let Some(name) = query_param(&url, "symbol") {
Ok(Self::Symbol {
name,
abs_path: path.into(),
@ -167,6 +185,7 @@ impl MentionUri {
Ok(Self::Selection {
abs_path: Some(path.into()),
line_range,
column,
})
}
} else if input.ends_with("/") {
@ -216,9 +235,11 @@ impl MentionUri {
.fragment()
.context("Missing fragment for untitled buffer selection")?;
let line_range = parse_line_range(fragment)?;
validate_query_params(&url, &["column"])?;
Ok(Self::Selection {
abs_path: None,
line_range,
column: parse_column(query_param(&url, "column")),
})
} else if let Some(name) = path.strip_prefix("/agent/symbol/") {
let fragment = url
@ -245,13 +266,15 @@ impl MentionUri {
abs_path: path.into(),
})
} else if path.starts_with("/agent/selection") {
validate_query_params(&url, &["path", "column"])?;
let fragment = url.fragment().context("Missing fragment for selection")?;
let line_range = parse_line_range(fragment)?;
let path =
single_query_param(&url, "path")?.context("Missing path for selection")?;
let column = parse_column(query_param(&url, "column"));
let path = query_param(&url, "path").context("Missing path for selection")?;
Ok(Self::Selection {
abs_path: Some(path.into()),
line_range,
column,
})
} else if path.starts_with("/agent/terminal-selection") {
let line_count = single_query_param(&url, "lines")?
@ -342,13 +365,33 @@ impl MentionUri {
..
} => selection_name(path.as_deref(), line_range),
MentionUri::Fetch { url } => url.to_string(),
MentionUri::Skill { name, .. } => name.clone(),
}
}
/// Returns a label for this mention at the given disambiguation `detail`
/// level. `detail == 0` is the base name returned by [`Self::name`]; higher
/// levels include progressively more context (e.g. additional parent path
/// components for files, or the source for skills) until a fixed point is
/// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`].
pub fn disambiguated_name(&self, detail: usize) -> String {
if detail == 0 {
return self.name();
}
match self {
MentionUri::Skill { name, source, .. } => {
if source.is_empty() {
// Must match `SkillSource::display_label()` in agent_skills.
format!("{} (global)", name)
} else {
format!("{} ({})", name, source)
}
}
MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => {
project::path_suffix(abs_path, detail)
}
_ => self.name(),
}
}
@ -440,6 +483,7 @@ impl MentionUri {
abs_path,
name,
line_range,
..
} => {
let mut url = Url::parse("file:///").unwrap();
url.set_path(&abs_path.to_string_lossy());
@ -454,6 +498,7 @@ impl MentionUri {
MentionUri::Selection {
abs_path,
line_range,
column,
} => {
let mut url = if let Some(path) = abs_path {
let mut url = Url::parse("file:///").unwrap();
@ -464,6 +509,10 @@ impl MentionUri {
url.set_path("/agent/untitled-buffer");
url
};
if let Some(column) = column {
url.query_pairs_mut()
.append_pair("column", &(column + 1).to_string());
}
url.set_fragment(Some(&format!(
"L{}:{}",
line_range.start() + 1,
@ -544,6 +593,11 @@ fn default_include_errors() -> bool {
true
}
fn query_param(url: &Url, name: &'static str) -> Option<String> {
url.query_pairs()
.find_map(|(key, value)| (key == name).then(|| value.to_string()))
}
fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
let pairs = url.query_pairs().collect::<Vec<_>>();
match pairs.as_slice() {
@ -678,6 +732,7 @@ mod tests {
abs_path: path,
name,
line_range,
..
} => {
assert_eq!(path, Path::new(path!("/path/to/file.rs")));
assert_eq!(name, "MySymbol");
@ -697,6 +752,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &4);
@ -728,6 +784,7 @@ mod tests {
MentionUri::Selection {
abs_path: None,
line_range,
..
} => {
assert_eq!(line_range.start(), &0);
assert_eq!(line_range.end(), &9);
@ -875,6 +932,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -884,6 +942,29 @@ mod tests {
}
}
#[test]
fn test_parse_absolute_file_path_with_row_and_column() {
let file_path = "/path/to/file.rs:42:5";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
column,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
assert_eq!(column, &Some(4));
let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix)
.expect("selection URI with column should parse");
assert_eq!(parsed_again, parsed.clone());
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_absolute_file_path_with_fragment_line() {
let file_path = "/path/to/file.rs#L42";
@ -892,6 +973,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -921,6 +1003,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -941,6 +1024,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -973,6 +1057,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -990,6 +1075,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -1011,6 +1097,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &1871);
@ -1028,6 +1115,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);
@ -1043,6 +1131,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);
@ -1070,4 +1159,68 @@ mod tests {
let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
assert_eq!(parsed_single.name(), "Terminal (1 line)");
}
#[test]
fn test_disambiguated_name() {
// Two files with the same name — should disambiguate with parent dir
let file_a = MentionUri::File {
abs_path: PathBuf::from(path!("/project/src/README.md")),
};
let file_b = MentionUri::File {
abs_path: PathBuf::from(path!("/project/docs/README.md")),
};
assert_eq!(file_a.name(), "README.md");
assert_eq!(file_b.name(), "README.md");
assert_eq!(file_a.disambiguated_name(0), "README.md");
assert_eq!(file_a.disambiguated_name(1), "src/README.md");
assert_eq!(file_b.disambiguated_name(1), "docs/README.md");
// Files that still collide at one parent should grow further.
let deep_a = MentionUri::File {
abs_path: PathBuf::from(path!("/a/src/foo.rs")),
};
let deep_b = MentionUri::File {
abs_path: PathBuf::from(path!("/b/src/foo.rs")),
};
assert_eq!(deep_a.disambiguated_name(1), "src/foo.rs");
assert_eq!(deep_b.disambiguated_name(1), "src/foo.rs");
assert_eq!(deep_a.disambiguated_name(2), "a/src/foo.rs");
assert_eq!(deep_b.disambiguated_name(2), "b/src/foo.rs");
// Two skills with the same name — should disambiguate with source
let global_skill = MentionUri::Skill {
name: "create-skill".into(),
source: "".into(),
skill_file_path: PathBuf::from("/global/create-skill/SKILL.md"),
};
let project_skill = MentionUri::Skill {
name: "create-skill".into(),
source: "my-project".into(),
skill_file_path: PathBuf::from("/project/create-skill/SKILL.md"),
};
assert_eq!(global_skill.name(), "create-skill");
assert_eq!(global_skill.disambiguated_name(0), "create-skill");
assert_eq!(global_skill.disambiguated_name(1), "create-skill (global)");
assert_eq!(
project_skill.disambiguated_name(1),
"create-skill (my-project)"
);
// A type without special disambiguation (Thread) — detail has no effect
// (the value is a fixed point so the disambiguation loop terminates).
let thread = MentionUri::Thread {
id: acp::SessionId::new("123"),
name: "My Thread".into(),
};
assert_eq!(thread.disambiguated_name(0), "My Thread");
assert_eq!(thread.disambiguated_name(1), "My Thread");
assert_eq!(thread.disambiguated_name(5), "My Thread");
// Edge case: file at filesystem root has no parent to show
let root_file = MentionUri::File {
abs_path: PathBuf::from(path!("/README.md")),
};
assert_eq!(root_file.disambiguated_name(1), "README.md");
assert_eq!(root_file.disambiguated_name(5), "README.md");
}
}

View file

@ -508,6 +508,7 @@ impl AcpTools {
} else {
CopyButtonVisibility::Hidden
},
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
},
),

View file

@ -31,13 +31,14 @@ use acp_thread::{
};
use agent_client_protocol::schema as acp;
use agent_skills::{
MAX_SKILL_DESCRIPTIONS_SIZE, Skill, SkillLoadError, SkillScopeId, SkillSource, SkillSummary,
global_skills_dir, load_skills_from_directory, project_skills_relative_path,
MAX_SKILL_DESCRIPTIONS_SIZE, ProjectSkillGroup, Skill, SkillIndex, SkillLoadError,
SkillScopeId, SkillSource, SkillSummary, builtin_skills, global_skills_dir,
load_skills_from_directory, project_skills_relative_path,
};
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use collections::{HashMap, HashSet, IndexMap};
use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
@ -104,7 +105,7 @@ impl From<&Skill> for NativeAvailableSkill {
Self {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.scope_prefix().to_string().into(),
source: skill.source.display_label().to_string().into(),
skill_file_path: skill.skill_file_path.clone(),
}
}
@ -369,6 +370,10 @@ impl NativeAgent {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
}
if !cx.has_global::<SkillIndex>() {
cx.set_global(SkillIndex::default());
}
Self {
sessions: HashMap::default(),
pending_sessions: HashMap::default(),
@ -387,11 +392,11 @@ impl NativeAgent {
/// Kicks off a one-time scan of the global skills directory if one
/// isn't already in progress and a watch isn't already active.
///
/// Idempotent and cheap: returns immediately if the user lacks the
/// skills feature flag, or if a scan or watch is already running.
/// The expected callers are user-interaction events from the agent
/// panel (input focus, slash autocomplete, conversation submit);
/// firing this from any of them is equivalent and safe to repeat.
/// Idempotent and cheap: returns immediately if a scan or watch is
/// already running. The expected callers are user-interaction events
/// from the agent panel (input focus, slash autocomplete, conversation
/// submit); firing this from any of them is equivalent and safe to
/// repeat.
///
/// The scan itself runs detached on the foreground executor. If
/// `~/.agents/skills/` exists it transitions state to
@ -400,9 +405,6 @@ impl NativeAgent {
/// next trigger retries (covering the case where the user creates
/// the directory after the first scan).
pub fn ensure_skills_scan_started(&mut self, cx: &mut Context<Self>) {
if !cx.has_flag::<SkillsFeatureFlag>() {
return;
}
if !matches!(self.skills_state, SkillsState::Idle) {
return;
}
@ -593,12 +595,10 @@ impl NativeAgent {
// after the thread is constructed are still visible to the
// model — without this, the catalog and tool would drift out
// of sync until the session was reopened.
if cx.has_flag::<SkillsFeatureFlag>() {
thread.add_tool(SkillTool::new(
skills_resolver_for_project(weak.clone(), project_id),
self.fs.clone(),
));
}
thread.add_tool(SkillTool::new(
skills_resolver_for_project(weak.clone(), project_id),
self.fs.clone(),
));
});
let subscriptions = vec![
@ -796,6 +796,7 @@ impl NativeAgent {
// the available commands) can change without affecting the
// skill error list.
this.update_available_commands_for_project(project_id, cx);
this.publish_skill_index(cx);
})?;
}
@ -816,12 +817,8 @@ impl NativeAgent {
})
.collect::<Vec<_>>();
// Skills are gated behind the "skills" feature flag. Without it we
// skip all on-disk lookups so users see no behavior change.
let skills_enabled = cx.has_flag::<SkillsFeatureFlag>();
// Load global skills
let global_skills_task = if skills_enabled {
let global_skills_task = {
let global_skills_dir = global_skills_dir();
let global_skills_fs = fs.clone();
cx.background_spawn(async move {
@ -832,8 +829,6 @@ impl NativeAgent {
)
.await
})
} else {
Task::ready(Vec::new())
};
// Load project-local skills, but only from worktrees the user has
@ -846,7 +841,7 @@ impl NativeAgent {
// worktrees pick up their skills without restarting.
let trusted_worktrees = TrustedWorktrees::try_get_global(cx);
let worktree_store = project.read(cx).worktree_store();
let project_skills_task = if skills_enabled {
let project_skills_task = {
let project_skills_futures: Vec<
futures::future::BoxFuture<'static, Vec<Result<Skill, SkillLoadError>>>,
> = worktrees
@ -891,8 +886,6 @@ impl NativeAgent {
})
.collect();
cx.background_spawn(async move { future::join_all(project_skills_futures).await })
} else {
Task::ready(Vec::new())
};
let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() {
prompt_store.read_with(cx, |prompt_store, cx| {
@ -1101,7 +1094,7 @@ impl NativeAgent {
&mut self,
project: Entity<Project>,
event: &project::Event,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
let project_id = project.entity_id();
let Some(state) = self.projects.get_mut(&project_id) else {
@ -1112,16 +1105,14 @@ impl NativeAgent {
state.project_context_needs_refresh.send(()).ok();
}
project::Event::WorktreeUpdatedEntries(_, items) => {
let skills_enabled = cx.has_flag::<SkillsFeatureFlag>();
if items.iter().any(|(path, _, _)| {
let path_ref = path.as_ref();
RULES_FILE_REL_PATHS
.iter()
.any(|rules_path| path_ref == rules_path.as_ref())
|| (skills_enabled
&& SKILLS_PREFIX
.as_ref()
.is_some_and(|prefix| path_ref.starts_with(prefix)))
|| SKILLS_PREFIX
.as_ref()
.is_some_and(|prefix| path_ref.starts_with(prefix))
}) {
state.project_context_needs_refresh.send(()).ok();
}
@ -1213,6 +1204,50 @@ impl NativeAgent {
}
}
fn publish_skill_index(&self, cx: &mut Context<Self>) {
let mut global_skills = Vec::new();
let mut project_groups: Vec<ProjectSkillGroup> = Vec::new();
let mut seen_global = false;
for state in self.projects.values() {
for skill in state.skills.iter() {
match &skill.source {
SkillSource::BuiltIn => {}
SkillSource::Global => {
if !seen_global {
global_skills.push(skill.clone());
}
}
SkillSource::ProjectLocal {
worktree_id,
worktree_root_name,
} => {
if let Some(group) = project_groups
.iter_mut()
.find(|g| g.worktree_id == *worktree_id)
{
group.skills.push(skill.clone());
} else {
project_groups.push(ProjectSkillGroup {
worktree_id: *worktree_id,
worktree_root_name: SharedString::from(worktree_root_name.clone()),
skills: vec![skill.clone()],
});
}
}
}
}
if !global_skills.is_empty() {
seen_global = true;
}
}
cx.set_global(SkillIndex {
global_skills,
project_skills: project_groups,
});
}
fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context<Self>) {
let available_commands =
Self::build_available_commands_for_project(self.projects.get(&project_id), cx);
@ -1450,6 +1485,7 @@ impl NativeAgent {
let has_remaining = self.sessions.values().any(|s| s.project_id == project_id);
if !has_remaining {
self.projects.remove(&project_id);
self.publish_skill_index(cx);
}
session.pending_save
@ -1644,14 +1680,18 @@ impl NativeAgent {
// Read the body on demand here — bodies live on disk between
// materializations to keep memory cost O(total frontmatter)
// rather than O(total file size).
let body = agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
.await
.with_context(|| {
format!(
"Failed to read skill body from {}",
skill.skill_file_path.display()
)
})?;
let body = if let Some(embedded) = skill.embedded_body {
embedded.to_string()
} else {
agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
.await
.with_context(|| {
format!(
"Failed to read skill body from {}",
skill.skill_file_path.display()
)
})?
};
let envelope = crate::tools::render_skill_envelope(&skill, &body);
let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope));
@ -1726,6 +1766,16 @@ impl NativeAgentConnection {
.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
}
pub fn refresh_skills_for_project(&self, project: Entity<Project>, cx: &mut App) {
self.0.update(cx, |agent, cx| {
let project_id = agent.get_or_create_project_state(&project, cx);
agent.ensure_skills_scan_started(cx);
if let Some(state) = agent.projects.get_mut(&project_id) {
state.project_context_needs_refresh.send(()).ok();
}
});
}
pub fn available_skills(
&self,
session_id: &acp::SessionId,
@ -2245,9 +2295,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
// we don't clone the entire skill list on every prompt
// (including prompts like `/help` that aren't skills at
// all). The resolution rule matches the override-applied
// view: prefer a project-local with the matching name,
// falling back to a global, so the slash command picks the
// same entry the model sees in its catalog.
// view: among skills with the matching name, pick the one
// with the highest source precedence, so the slash command
// picks the same entry the model sees in its catalog.
// Ties (e.g. two project-local skills from different
// worktrees) resolve to the first in iteration order to
// match `apply_skill_overrides`.
if parsed_command.explicit_server_id.is_none()
&& parsed_command.skill_scope.is_none()
&& !project_state.skills.is_empty()
@ -2256,15 +2309,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let resolved = project_state
.skills
.iter()
.find(|skill| {
skill.name == prompt_name
&& matches!(skill.source, SkillSource::ProjectLocal { .. })
})
.or_else(|| {
project_state
.skills
.iter()
.find(|skill| skill.name == prompt_name)
.filter(|skill| skill.name == prompt_name)
.reduce(|best, candidate| {
if candidate.source.precedence() > best.source.precedence() {
candidate
} else {
best
}
});
if let Some(skill) = resolved {
let skill = skill.clone();
@ -2960,7 +3011,9 @@ fn combine_skills(
global: Vec<Result<Skill, SkillLoadError>>,
project: impl Iterator<Item = Result<Skill, SkillLoadError>>,
) -> (Vec<Skill>, Vec<SkillLoadError>) {
let mut skills = Vec::new();
// Built-in skills go first (lowest priority) so that global and
// project-local skills with the same name shadow them.
let mut skills = builtin_skills();
let mut errors = Vec::new();
for result in global.into_iter().chain(project) {
match result {
@ -2979,17 +3032,16 @@ fn log_skill_conflicts(skills: &[Skill]) {
let mut by_name: HashMap<&str, &Skill> = HashMap::default();
for skill in skills {
match by_name.get(skill.name.as_str()) {
Some(existing) => match (&existing.source, &skill.source) {
(SkillSource::Global, SkillSource::ProjectLocal { .. }) => {
Some(existing) => {
if skill.source.precedence() > existing.source.precedence() {
log::warn!(
"Project skill '{}' at '{}' overrides global skill at '{}' for the model; both appear in the slash-command popup with their source",
"Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source",
skill.name,
skill.skill_file_path.display(),
existing.skill_file_path.display(),
);
by_name.insert(skill.name.as_str(), skill);
}
_ => {
} else {
log::warn!(
"Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source",
skill.name,
@ -2997,7 +3049,7 @@ fn log_skill_conflicts(skills: &[Skill]) {
existing.skill_file_path.display(),
);
}
},
}
None => {
by_name.insert(skill.name.as_str(), skill);
}
@ -3024,9 +3076,7 @@ fn apply_skill_overrides(skills: &[Skill]) -> Vec<Skill> {
for skill in skills {
match indices.get(skill.name.as_str()).copied() {
Some(idx) => {
if matches!(result[idx].source, SkillSource::Global)
&& matches!(skill.source, SkillSource::ProjectLocal { .. })
{
if skill.source.precedence() > result[idx].source.precedence() {
result[idx] = skill.clone();
}
}
@ -3064,6 +3114,7 @@ mod internal_tests {
directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")),
skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")),
disable_model_invocation: false,
embedded_body: None,
}
}
@ -3078,9 +3129,30 @@ mod internal_tests {
directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")),
skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")),
disable_model_invocation: false,
embedded_body: None,
}
}
fn make_builtin_skill(name: &str, description: &str) -> Skill {
Skill {
name: name.to_string(),
description: description.to_string(),
source: SkillSource::BuiltIn,
directory_path: PathBuf::from(format!("/builtin/{name}")),
skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")),
disable_model_invocation: false,
embedded_body: Some("built-in body"),
}
}
/// Filter to only user-defined (non-built-in) skills for test assertions.
fn user_skills(skills: &[Skill]) -> Vec<&Skill> {
skills
.iter()
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
.collect()
}
#[test]
fn test_combine_skills_keeps_every_entry_for_autocomplete() {
// The autocomplete popup needs both same-named entries so the
@ -3092,9 +3164,10 @@ mod internal_tests {
let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter());
assert!(errors.is_empty());
assert_eq!(skills.len(), 2);
assert!(matches!(skills[0].source, SkillSource::Global));
assert!(matches!(skills[1].source, SkillSource::ProjectLocal { .. }));
let user = user_skills(&skills);
assert_eq!(user.len(), 2);
assert!(matches!(user[0].source, SkillSource::Global));
assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. }));
}
#[test]
@ -3130,6 +3203,51 @@ mod internal_tests {
assert_eq!(resolved[0].description, "First");
}
#[test]
fn test_apply_skill_overrides_global_wins_over_builtin() {
// A global skill with the same name as a built-in must shadow
// the built-in in the model-facing projection, regardless of
// iteration order.
let built_in = make_builtin_skill("create-skill", "Built-in version");
let global = make_global_skill("create-skill", "User override");
let resolved = apply_skill_overrides(&[built_in, global]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].description, "User override");
assert!(matches!(resolved[0].source, SkillSource::Global));
}
#[test]
fn test_apply_skill_overrides_project_wins_over_builtin() {
let built_in = make_builtin_skill("create-skill", "Built-in version");
let project = make_project_skill("create-skill", "Project override", "my-project");
let resolved = apply_skill_overrides(&[built_in, project]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].description, "Project override");
assert!(matches!(
resolved[0].source,
SkillSource::ProjectLocal { .. }
));
}
#[test]
fn test_apply_skill_overrides_project_wins_over_builtin_and_global() {
// All three sources present — the project-local must win and
// both lower-precedence entries must be dropped from the
// model-facing projection.
let built_in = make_builtin_skill("create-skill", "Built-in");
let global = make_global_skill("create-skill", "Global");
let project = make_project_skill("create-skill", "Project", "my-project");
let resolved = apply_skill_overrides(&[built_in, global, project]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].description, "Project");
}
#[test]
fn test_apply_skill_overrides_preserves_unique_skills() {
let global_a = make_global_skill("alpha", "a");
@ -3201,6 +3319,7 @@ mod internal_tests {
directory_path: PathBuf::from(format!("/skills/{name}")),
skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
disable_model_invocation: false,
embedded_body: None,
});
}
@ -3275,6 +3394,7 @@ mod internal_tests {
directory_path: PathBuf::from("/skills/skill-01-first"),
skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"),
disable_model_invocation: false,
embedded_body: None,
};
let second = Skill {
name: "skill-02-overflows".to_string(),
@ -3283,6 +3403,7 @@ mod internal_tests {
directory_path: PathBuf::from("/skills/skill-02-overflows"),
skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"),
disable_model_invocation: false,
embedded_body: None,
};
let third = Skill {
name: "skill-03-would-fit".to_string(),
@ -3291,6 +3412,7 @@ mod internal_tests {
directory_path: PathBuf::from("/skills/skill-03-would-fit"),
skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"),
disable_model_invocation: false,
embedded_body: None,
};
// Sanity-check the test setup: the third skill is small enough
@ -3346,6 +3468,7 @@ mod internal_tests {
directory_path: PathBuf::from("/skills/hidden-huge"),
skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"),
disable_model_invocation: true,
embedded_body: None,
};
let visible = Skill {
name: "visible".to_string(),
@ -3354,6 +3477,7 @@ mod internal_tests {
directory_path: PathBuf::from("/skills/visible"),
skill_file_path: PathBuf::from("/skills/visible/SKILL.md"),
disable_model_invocation: false,
embedded_body: None,
};
let (kept, errors) = select_catalog_skills(&[hidden, visible]);
@ -3454,9 +3578,6 @@ mod internal_tests {
#[gpui::test]
async fn test_global_skills_load_and_reload(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
});
let fs = FakeFs::new(cx.executor());
let skills_dir = global_skills_dir();
let initial_skill_dir = skills_dir.join("my-skill");
@ -3496,9 +3617,10 @@ mod internal_tests {
// The pre-existing skill should be loaded into the project state.
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project.entity_id()).unwrap();
assert_eq!(state.skills.len(), 1);
assert_eq!(state.skills[0].name, "my-skill");
assert_eq!(state.skills[0].description, "First version");
let user = user_skills(&state.skills);
assert_eq!(user.len(), 1);
assert_eq!(user[0].name, "my-skill");
assert_eq!(user[0].description, "First version");
});
// Modify the SKILL.md and verify the project context refreshes.
@ -3512,17 +3634,15 @@ mod internal_tests {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project.entity_id()).unwrap();
assert_eq!(state.skills.len(), 1);
assert_eq!(state.skills[0].description, "Second version");
let user = user_skills(&state.skills);
assert_eq!(user.len(), 1);
assert_eq!(user[0].description, "Second version");
});
}
#[gpui::test]
async fn test_global_skills_dir_created_after_startup(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
});
let fs = FakeFs::new(cx.executor());
let skills_dir = global_skills_dir();
@ -3559,8 +3679,8 @@ mod internal_tests {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project.entity_id()).unwrap();
assert!(
state.skills.is_empty(),
"expected no skills before the global skills dir exists, got {:?}",
user_skills(&state.skills).is_empty(),
"expected no user skills before the global skills dir exists, got {:?}",
state.skills
);
});
@ -3585,9 +3705,10 @@ mod internal_tests {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project.entity_id()).unwrap();
assert_eq!(state.skills.len(), 1);
assert_eq!(state.skills[0].name, "late-skill");
assert_eq!(state.skills[0].description, "Created after startup");
let user = user_skills(&state.skills);
assert_eq!(user.len(), 1);
assert_eq!(user[0].name, "late-skill");
assert_eq!(user[0].description, "Created after startup");
});
}
@ -3603,9 +3724,6 @@ mod internal_tests {
#[gpui::test]
async fn test_skills_added_after_session_visible_to_skill_tool(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
});
let fs = FakeFs::new(cx.executor());
let skills_dir = global_skills_dir();
@ -3638,8 +3756,8 @@ mod internal_tests {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project_id).unwrap();
assert!(
state.skills.is_empty(),
"expected no skills before the global skills dir exists, got {:?}",
user_skills(&state.skills).is_empty(),
"expected no user skills before the global skills dir exists, got {:?}",
state.skills
);
});
@ -3656,7 +3774,12 @@ mod internal_tests {
// empty list — NOT the snapshot that `Thread::new` would have
// captured.
cx.update(|cx| {
assert!(resolve(cx).is_empty());
let all = resolve(cx);
let user: Vec<_> = all
.iter()
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
.collect();
assert!(user.is_empty());
});
// Now create a SKILL.md AFTER the session was registered. With
@ -3681,15 +3804,20 @@ mod internal_tests {
// `state.skills` reflects the new skill (the watcher ran).
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project_id).unwrap();
assert_eq!(state.skills.len(), 1);
assert_eq!(state.skills[0].name, "my-skill");
let user = user_skills(&state.skills);
assert_eq!(user.len(), 1);
assert_eq!(user[0].name, "my-skill");
});
// The resolver the `SkillTool` uses must see it too. This is the
// crux of the regression test: the tool's view of skills is
// resolved at invocation time, not at thread-construction time.
cx.update(|cx| {
let snapshot = resolve(cx);
let all = resolve(cx);
let snapshot: Vec<_> = all
.iter()
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
.collect();
assert_eq!(
snapshot.len(),
1,
@ -3737,9 +3865,6 @@ mod internal_tests {
#[gpui::test]
async fn test_subagent_skills_lookup_matches_parent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
});
let fs = FakeFs::new(cx.executor());
let skills_dir = global_skills_dir();
let skill_dir = skills_dir.join("shared-skill");
@ -3777,7 +3902,11 @@ mod internal_tests {
let parent_resolve =
cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
cx.update(|cx| {
let parent_skills = parent_resolve(cx);
let all = parent_resolve(cx);
let parent_skills: Vec<_> = all
.iter()
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
.collect();
assert_eq!(parent_skills.len(), 1);
assert_eq!(parent_skills[0].name, "shared-skill");
});
@ -3823,7 +3952,11 @@ mod internal_tests {
let subagent_resolve = cx
.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id));
cx.update(|cx| {
let subagent_skills = subagent_resolve(cx);
let all = subagent_resolve(cx);
let subagent_skills: Vec<_> = all
.iter()
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
.collect();
assert_eq!(subagent_skills.len(), 1);
assert_eq!(subagent_skills[0].name, "shared-skill");
});
@ -3832,9 +3965,6 @@ mod internal_tests {
#[gpui::test]
async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
});
let fs = FakeFs::new(cx.executor());
let skills_dir = global_skills_dir();
@ -3919,7 +4049,14 @@ mod internal_tests {
.iter()
.map(|s| s.name.as_str())
.collect();
assert_eq!(catalog, vec!["visible-skill"]);
assert!(
catalog.contains(&"visible-skill"),
"visible skill missing from catalog: {catalog:?}"
);
assert!(
!catalog.contains(&"deploy"),
"deploy should be excluded from catalog: {catalog:?}"
);
});
}
@ -3930,7 +4067,6 @@ mod internal_tests {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
// The trust global isn't created by `init_test`. We need it
// for `Project::test_with_worktree_trust` to actually wire up
// trust tracking and for our subscription in
@ -3986,7 +4122,7 @@ mod internal_tests {
agent.read_with(cx, |agent, cx| {
let state = agent.projects.get(&project_id).unwrap();
assert!(
state.skills.is_empty(),
user_skills(&state.skills).is_empty(),
"untrusted worktree skills should not load: {:?}",
state
.skills
@ -4019,7 +4155,8 @@ mod internal_tests {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project_id).unwrap();
let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect();
let user = user_skills(&state.skills);
let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["my-skill"]);
});

View file

@ -83,7 +83,7 @@ mod tests {
let project = prompt_store::ProjectContext::default();
let template = SystemPromptTemplate {
project: &project,
available_tools: vec!["echo".into(), "update_plan".into()],
available_tools: vec!["echo".into(), "update_plan".into(), "update_title".into()],
model_name: Some("test-model".to_string()),
date: "2026-01-01".to_string(),
user_agents_md: None,
@ -94,6 +94,7 @@ mod tests {
assert!(rendered.contains("Today's Date: 2026-01-01"));
assert!(rendered.contains("## Fixing Diagnostics"));
assert!(rendered.contains("## Planning"));
assert!(rendered.contains("## Session Title"));
assert!(rendered.contains("test-model"));
}

View file

@ -52,6 +52,17 @@ Use a plan when:
- The user asked you to do more than one thing in a single prompt.
- You discover additional steps while working and intend to complete them before yielding to the user.
{{/if}}
{{#if (contains available_tools 'update_title') }}
## Session Title
- Use the `update_title` tool to set the title shown to the user for the current session.
- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one.
- Update the title again whenever the goal changes materially.
- Titles are very important to communicate to the user what you are working on. A session should always have a title.
- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes.
- Do not mention that you changed the title unless it is directly relevant to the user.
{{/if}}
## Searching and Reading

View file

@ -74,6 +74,17 @@ Use a plan when:
- The user asked you to do more than one thing in a single prompt.
- You discover additional steps while working and intend to complete them before yielding to the user.
{{/if}}
{{#if (contains available_tools 'update_title') }}
## Session Title
- Use the `update_title` tool to set the title shown to the user for the current session.
- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one.
- Update the title again whenever the goal changes materially.
- Titles are very important to communicate to the user what you are working on. A session should always have a title.
- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes.
- Do not mention that you changed the title unless it is directly relevant to the user.
{{/if}}
## Searching and Reading

View file

@ -26,10 +26,10 @@ use gpui::{
use indoc::indoc;
use language_model::{
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult,
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason,
TokenUsage,
LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent,
Role, StopReason, TokenUsage,
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
};
use pretty_assertions::assert_eq;
@ -1656,6 +1656,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
assert_eq!(tool_call_params.name, "screenshot");
let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
tool_call_response
.send(context_server::types::CallToolResponse {
content: vec![
@ -1663,7 +1664,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
text: "Some text".into(),
},
context_server::types::ToolResponseContent::Image {
data: "aGVsbG8=".into(),
data: image_data.into(),
mime_type: "image/png".into(),
},
context_server::types::ToolResponseContent::Text {
@ -1691,13 +1692,25 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
})
.expect("expected a tool result");
assert_eq!(tool_result.tool_use_id, "tool_1".into());
assert_eq!(tool_result.content.len(), 2);
assert_eq!(tool_result.content.len(), 3);
assert_eq!(
tool_result.content[0],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
);
let expected_image =
language_model::LanguageModelImage::from_base64_image(image_data, "image/png")
.expect("image conversion should not error")
.expect("image conversion should succeed");
assert_eq!(
tool_result.content[0],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
);
assert_eq!(
tool_result.content[1],
language_model::LanguageModelToolResultContent::Image(expected_image)
);
assert_eq!(
tool_result.content[2],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text"))
);
fake_model.end_last_completion_stream();
@ -3794,6 +3807,155 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_update_title_tool_sets_thread_title(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let summary_model = Arc::new(FakeLanguageModel::default());
cx.update(|cx| {
cx.update_flags(true, vec!["update-title-tool".to_string()]);
});
thread.update(cx, |thread, cx| {
thread.add_tool(UpdateTitleTool::new(cx.weak_entity()));
thread.set_summarization_model(Some(summary_model.clone()), cx);
});
let mut events = thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Explore title tooling"], cx)
})
.unwrap();
cx.run_until_parked();
let input = json!({
"title": "Session title tool"
});
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "title_1".into(),
name: UpdateTitleTool::NAME.into(),
raw_input: input.to_string(),
input,
is_input_complete: true,
thought_signature: None,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
let tool_call = expect_tool_call(&mut events).await;
assert_eq!(
tool_call,
acp::ToolCall::new("title_1", "Update title: Session title tool")
.kind(acp::ToolKind::Think)
.raw_input(json!({
"title": "Session title tool"
}))
.meta(acp::Meta::from_iter([(
"tool_name".into(),
"update_title".into()
)]))
);
let update = expect_tool_call_update_fields(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate::new(
"title_1",
acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress)
)
);
let update = expect_tool_call_update_fields(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate::new(
"title_1",
acp::ToolCallUpdateFields::new()
.status(acp::ToolCallStatus::Completed)
.raw_output("Session title updated")
)
);
thread.read_with(cx, |thread, _| {
assert_eq!(thread.title(), Some("Session title tool".into()));
});
assert_eq!(summary_model.pending_completions(), Vec::new());
}
#[gpui::test]
async fn test_update_title_availability_suppresses_summary_title_generation(
cx: &mut TestAppContext,
) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let summary_model = Arc::new(FakeLanguageModel::default());
cx.update(|cx| {
cx.update_flags(true, vec!["update-title-tool".to_string()]);
});
thread.update(cx, |thread, cx| {
thread.add_tool(UpdateTitleTool::new(cx.weak_entity()));
thread.set_summarization_model(Some(summary_model.clone()), cx);
});
let send = thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Explore title tooling"], cx)
})
.unwrap();
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Done");
fake_model.end_last_completion_stream();
send.collect::<Vec<_>>().await;
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.title(), None);
});
assert_eq!(summary_model.pending_completions(), Vec::new());
}
#[gpui::test]
async fn test_update_title_flag_without_available_tool_falls_back_to_summary_title_generation(
cx: &mut TestAppContext,
) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
let summary_model = Arc::new(FakeLanguageModel::default());
cx.update(|cx| {
cx.update_flags(true, vec!["update-title-tool".to_string()]);
});
thread.update(cx, |thread, cx| {
thread.set_summarization_model(Some(summary_model.clone()), cx);
});
let send = thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Explore title tooling"], cx)
})
.unwrap();
cx.run_until_parked();
fake_model.send_last_completion_stream_text_chunk("Done");
fake_model.end_last_completion_stream();
cx.run_until_parked();
assert_eq!(summary_model.pending_completions().len(), 1);
summary_model.send_last_completion_stream_text_chunk("Fallback title");
summary_model.end_last_completion_stream();
send.collect::<Vec<_>>().await;
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.title(), Some("Fallback title".into()));
});
}
#[gpui::test]
async fn test_send_no_retry_on_success(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
@ -4307,6 +4469,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
StreamingFailingEchoTool::NAME: true,
TerminalTool::NAME: true,
UpdatePlanTool::NAME: true,
UpdateTitleTool::NAME: true,
}
}
}

View file

@ -4,12 +4,14 @@ use crate::{
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision,
UpdatePlanTool, UserAgentsMd, WebSearchTool, WriteFileTool, decide_permission_from_settings,
UpdatePlanTool, UpdateTitleTool, UserAgentsMd, WebSearchTool, WriteFileTool,
decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
use feature_flags::{
FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag,
UpdateTitleToolFeatureFlag,
};
use agent_client_protocol::schema as acp;
@ -364,11 +366,7 @@ impl UserMessage {
.ok();
}
MentionUri::Skill { name, source, .. } => {
let label = if source.is_empty() {
format!("{} (global)", name)
} else {
format!("{} ({})", name, source)
};
let label = format!("{} ({})", name, source);
write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok();
}
}
@ -1219,10 +1217,10 @@ impl Thread {
stream: &ThreadEventStream,
cx: &mut Context<Self>,
) {
// Extract saved output and status first, so they're available even if tool is not found
let output = tool_result
.as_ref()
.and_then(|result| result.output.clone());
let replay_content = tool_result.and_then(Self::tool_result_content_for_replay);
let status = tool_result
.as_ref()
.map_or(acp::ToolCallStatus::Failed, |result| {
@ -1251,21 +1249,25 @@ impl Thread {
// but still display the saved result if available.
// We need to send both ToolCall and ToolCallUpdate events because the UI
// only converts raw_output to displayable content in update_fields, not from_acp.
let title = Self::title_for_replayed_tool_use(tool_use);
stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCall(
acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string())
acp::ToolCall::new(tool_use.id.to_string(), title.clone())
.status(status)
.raw_input(tool_use.input.clone()),
)))
.ok();
stream.update_tool_call_fields(
&tool_use.id,
acp::ToolCallUpdateFields::new()
.status(status)
.raw_output(output),
None,
);
let mut fields = acp::ToolCallUpdateFields::new()
.status(status)
.raw_output(output);
if tool_use.name.as_ref() == UpdateTitleTool::NAME {
fields = fields.title(title);
}
if let Some(content) = replay_content {
fields = fields.content(content);
}
stream.update_tool_call_fields(&tool_use.id, fields, None);
return;
};
@ -1279,6 +1281,14 @@ impl Thread {
tool_use.input.clone(),
);
if let Some(content) = replay_content {
stream.update_tool_call_fields(
&tool_use.id,
acp::ToolCallUpdateFields::new().content(content),
None,
);
}
if let Some(output) = output.clone() {
// For replay, we use a dummy cancellation receiver since the tool already completed
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
@ -1301,6 +1311,55 @@ impl Thread {
);
}
fn title_for_replayed_tool_use(tool_use: &LanguageModelToolUse) -> String {
if tool_use.name.as_ref() == UpdateTitleTool::NAME {
let input = serde_json::from_value(tool_use.input.clone())
.map_err(|_| serde_json::Value::String(tool_use.raw_input.clone()));
UpdateTitleTool::title_for_input(input).to_string()
} else {
tool_use.name.to_string()
}
}
fn tool_result_content_for_replay(
tool_result: &LanguageModelToolResult,
) -> Option<Vec<acp::ToolCallContent>> {
let has_image = tool_result
.content
.iter()
.any(|part| matches!(part, LanguageModelToolResultContent::Image(_)));
if !has_image && tool_result.output.is_some() {
return None;
}
let content = tool_result
.content
.iter()
.filter_map(|part| match part {
LanguageModelToolResultContent::Text(text) => {
if text.is_empty() {
None
} else {
Some(acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Text(acp::TextContent::new(text.to_string())),
)))
}
}
LanguageModelToolResultContent::Image(image) => Some(
acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
acp::ImageContent::new(image.source.clone(), "image/png"),
))),
),
})
.collect::<Vec<_>>();
if content.is_empty() {
None
} else {
Some(content)
}
}
pub fn from_db(
id: acp::SessionId,
db_thread: DbThread,
@ -1627,6 +1686,9 @@ impl Thread {
if cx.has_flag::<UpdatePlanToolFeatureFlag>() {
self.add_tool(UpdatePlanTool);
}
if cx.has_flag::<UpdateTitleToolFeatureFlag>() {
self.add_tool(UpdateTitleTool::new(cx.weak_entity()));
}
self.add_tool(ReadFileTool::new(
self.project.clone(),
self.action_log.clone(),
@ -2140,7 +2202,7 @@ impl Thread {
this.update(cx, |this, cx| {
this.flush_pending_message(cx);
if this.title.is_none() && this.pending_title_generation.is_none() {
if this.title.is_none() {
this.generate_title(cx);
}
})?;
@ -2669,6 +2731,20 @@ impl Thread {
self.title_generation_failed
}
pub fn can_generate_title(&self, cx: &App) -> bool {
self.pending_title_generation.is_none()
&& self.summarization_model.is_some()
&& !self.update_title_tool_available(cx)
}
fn update_title_tool_available(&self, cx: &App) -> bool {
if let Some(running_turn) = self.running_turn.as_ref() {
running_turn.tools.contains_key(UpdateTitleTool::NAME)
} else {
self.enabled_tools(cx).contains_key(UpdateTitleTool::NAME)
}
}
pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
if let Some(summary) = self.summary.as_ref() {
return Task::ready(Some(summary.clone())).shared();
@ -2730,6 +2806,10 @@ impl Thread {
}
pub fn generate_title(&mut self, cx: &mut Context<Self>) {
if !self.can_generate_title(cx) {
return;
}
self.title_generation_failed = false;
let Some(model) = self.summarization_model.clone() else {
return;
@ -4458,6 +4538,259 @@ mod tests {
})
}
struct ReplayImageTool;
impl AgentTool for ReplayImageTool {
type Input = ();
type Output = String;
const NAME: &'static str = "registered_image_tool";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
_input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
"Registered Image Tool".into()
}
fn run(
self: Arc<Self>,
_input: ToolInput<Self::Input>,
_event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<Self::Output, Self::Output>> {
Task::ready(Ok(String::new()))
}
}
#[gpui::test]
async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) {
let (thread, _event_stream) = setup_thread_for_test(cx).await;
let registered_tool_use_id = LanguageModelToolUseId::from("registered_tool_id");
let missing_tool_use_id = LanguageModelToolUseId::from("missing_tool_id");
let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
let image = LanguageModelImage {
source: image_data.into(),
};
let mut replay_events = cx.update(|cx| {
thread.update(cx, |thread, cx| {
thread.add_tool(ReplayImageTool);
let registered_tool_use = LanguageModelToolUse {
id: registered_tool_use_id.clone(),
name: ReplayImageTool::NAME.into(),
raw_input: "null".to_string(),
input: json!(null),
is_input_complete: true,
thought_signature: None,
};
let missing_tool_use = LanguageModelToolUse {
id: missing_tool_use_id.clone(),
name: "missing_image_tool".into(),
raw_input: "{}".to_string(),
input: json!({}),
is_input_complete: true,
thought_signature: None,
};
let mut tool_results = IndexMap::default();
tool_results.insert(
registered_tool_use_id.clone(),
LanguageModelToolResult {
tool_use_id: registered_tool_use_id.clone(),
tool_name: ReplayImageTool::NAME.into(),
is_error: false,
content: vec![
LanguageModelToolResultContent::Text("before".into()),
LanguageModelToolResultContent::Image(image.clone()),
LanguageModelToolResultContent::Text("after".into()),
],
output: Some(json!("raw output")),
},
);
tool_results.insert(
missing_tool_use_id.clone(),
LanguageModelToolResult {
tool_use_id: missing_tool_use_id.clone(),
tool_name: "missing_image_tool".into(),
is_error: false,
content: vec![LanguageModelToolResultContent::Image(image.clone())],
output: Some(json!("raw output")),
},
);
thread.messages.push(Message::Agent(AgentMessage {
content: vec![
AgentMessageContent::ToolUse(registered_tool_use),
AgentMessageContent::ToolUse(missing_tool_use),
],
tool_results,
reasoning_details: None,
}));
thread.replay(cx)
})
});
let mut tool_use_ids_with_image_content = HashSet::default();
while let Some(event) = replay_events.next().await {
let event = event.unwrap();
if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) =
event
&& let Some(content) = &update.fields.content
&& content.iter().any(|content| {
matches!(
content,
acp::ToolCallContent::Content(acp::Content {
content: acp::ContentBlock::Image(_),
..
})
)
})
{
tool_use_ids_with_image_content.insert(update.tool_call_id.to_string());
}
}
assert!(tool_use_ids_with_image_content.contains(&registered_tool_use_id.to_string()));
assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string()));
}
#[gpui::test]
async fn test_update_title_tool_replay_does_not_reenter_thread(cx: &mut TestAppContext) {
let (thread, _event_stream) = setup_thread_for_test(cx).await;
let tool_use_id = LanguageModelToolUseId::from("title_tool_id");
let mut replay_events = cx.update(|cx| {
thread.update(cx, |thread, cx| {
thread.add_tool(UpdateTitleTool::new(cx.weak_entity()));
push_completed_update_title_tool_call(thread, tool_use_id.clone());
thread.replay(cx)
})
});
let mut saw_tool_call_title = false;
let mut saw_replayed_title_update = false;
let mut saw_completed_update = false;
while let Some(event) = replay_events.next().await {
let event = event.unwrap();
match event {
ThreadEvent::ToolCall(tool_call)
if tool_call.tool_call_id.to_string() == tool_use_id.to_string()
&& tool_call.title == "Update title: Replayed title" =>
{
saw_tool_call_title = true;
}
ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update))
if update.tool_call_id.to_string() == tool_use_id.to_string() =>
{
if update.fields.title == Some("Update title: Replayed title".to_string()) {
saw_replayed_title_update = true;
}
if update.fields.status == Some(acp::ToolCallStatus::Completed) {
saw_completed_update = true;
}
}
_ => {}
}
}
assert!(saw_tool_call_title);
assert!(saw_replayed_title_update);
assert!(saw_completed_update);
thread.read_with(cx, |thread, _cx| {
assert_eq!(thread.title(), None);
});
}
#[gpui::test]
async fn test_update_title_tool_replay_title_when_tool_not_registered(cx: &mut TestAppContext) {
let (thread, _event_stream) = setup_thread_for_test(cx).await;
let tool_use_id = LanguageModelToolUseId::from("title_tool_id");
let mut replay_events = cx.update(|cx| {
thread.update(cx, |thread, cx| {
push_completed_update_title_tool_call(thread, tool_use_id.clone());
thread.replay(cx)
})
});
let mut saw_tool_call_title = false;
let mut saw_replayed_title_update = false;
let mut saw_completed_update = false;
while let Some(event) = replay_events.next().await {
let event = event.unwrap();
match event {
ThreadEvent::ToolCall(tool_call)
if tool_call.tool_call_id.to_string() == tool_use_id.to_string()
&& tool_call.title == "Update title: Replayed title" =>
{
saw_tool_call_title = true;
}
ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update))
if update.tool_call_id.to_string() == tool_use_id.to_string() =>
{
if update.fields.title == Some("Update title: Replayed title".to_string()) {
saw_replayed_title_update = true;
}
if update.fields.status == Some(acp::ToolCallStatus::Completed) {
saw_completed_update = true;
}
}
_ => {}
}
}
assert!(saw_tool_call_title);
assert!(saw_replayed_title_update);
assert!(saw_completed_update);
thread.read_with(cx, |thread, _cx| {
assert_eq!(thread.title(), None);
});
}
fn push_completed_update_title_tool_call(
thread: &mut Thread,
tool_use_id: LanguageModelToolUseId,
) {
let tool_use = LanguageModelToolUse {
id: tool_use_id.clone(),
name: UpdateTitleTool::NAME.into(),
raw_input: json!({ "title": "Replayed title" }).to_string(),
input: json!({ "title": "Replayed title" }),
is_input_complete: true,
thought_signature: None,
};
let mut tool_results = IndexMap::default();
tool_results.insert(
tool_use_id.clone(),
LanguageModelToolResult {
tool_use_id,
tool_name: UpdateTitleTool::NAME.into(),
is_error: false,
content: vec![LanguageModelToolResultContent::Text(
"Session title updated".into(),
)],
output: Some(json!("Session title updated")),
},
);
thread.messages.push(Message::Agent(AgentMessage {
content: vec![AgentMessageContent::ToolUse(tool_use)],
tool_results,
reasoning_details: None,
}));
}
#[gpui::test]
async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) {
let (parent, _event_stream) = setup_thread_for_test(cx).await;

View file

@ -24,6 +24,7 @@ mod symbol_locator;
mod terminal_tool;
mod tool_permissions;
mod update_plan_tool;
mod update_title_tool;
mod web_search_tool;
mod write_file_tool;
@ -80,6 +81,7 @@ pub use symbol_locator::*;
pub use terminal_tool::*;
pub use tool_permissions::*;
pub use update_plan_tool::*;
pub use update_title_tool::*;
pub use web_search_tool::*;
pub use write_file_tool::*;
@ -172,6 +174,7 @@ tools! {
SpawnAgentTool,
TerminalTool,
UpdatePlanTool,
UpdateTitleTool,
WebSearchTool,
WriteFileTool,
}

View file

@ -5,7 +5,7 @@ use collections::{BTreeMap, HashMap};
use context_server::{ContextServerId, client::NotificationSubscription};
use futures::FutureExt as _;
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use language_model::LanguageModelToolResultContent;
use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent};
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
use util::ResultExt;
@ -261,7 +261,8 @@ impl ContextServerRegistry {
}
ContextServerStatus::Stopped
| ContextServerStatus::Error(_)
| ContextServerStatus::AuthRequired => {
| ContextServerStatus::AuthRequired
| ContextServerStatus::ClientSecretRequired { .. } => {
if let Some(registered_server) = self.registered_servers.remove(server_id) {
if !registered_server.tools.is_empty() {
cx.emit(ContextServerRegistryEvent::ToolsChanged);
@ -346,7 +347,7 @@ impl AnyAgentTool for ContextServerTool {
let authorize =
event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx);
cx.spawn(async move |_cx| {
cx.spawn(async move |cx| {
let input = input
.recv()
.await
@ -394,15 +395,50 @@ impl AnyAgentTool for ContextServerTool {
}
let mut llm_output = Vec::new();
let mut tool_call_content = Vec::new();
let mut concatenated_text = String::new();
for content in response.content {
match content {
context_server::types::ToolResponseContent::Text { text } => {
concatenated_text.push_str(&text);
tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Text(acp::TextContent::new(text.clone())),
)));
llm_output.push(LanguageModelToolResultContent::Text(text.into()));
}
context_server::types::ToolResponseContent::Image { .. } => {
log::warn!("Ignoring image content from tool response");
context_server::types::ToolResponseContent::Image { data, mime_type } => {
tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::Image(acp::ImageContent::new(
data.clone(),
mime_type.clone(),
)),
)));
let language_model_image = cx
.background_spawn({
let mime_type = mime_type.clone();
async move {
LanguageModelImage::from_base64_image(&data, &mime_type)
}
})
.await;
match language_model_image {
Ok(Some(image)) => {
llm_output.push(LanguageModelToolResultContent::Image(image));
}
Ok(None) => {
log::warn!(
"Skipping MCP tool response image with MIME type `{}` because it cannot be converted for language model input",
mime_type
);
}
Err(error) => {
log::warn!(
"Failed to convert MCP tool response image with MIME type `{}` for language model input: {:#}",
mime_type,
error
);
}
}
}
context_server::types::ToolResponseContent::Audio { .. } => {
log::warn!("Ignoring audio content from tool response");
@ -415,6 +451,10 @@ impl AnyAgentTool for ContextServerTool {
}
}
}
if !tool_call_content.is_empty() {
event_stream
.update_fields(acp::ToolCallUpdateFields::new().content(tool_call_content));
}
let raw_output = serde_json::Value::String(concatenated_text);
Ok(AgentToolOutput {
raw_output,

View file

@ -1,16 +1,18 @@
use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::schema as acp;
use anyhow::Result;
use futures::FutureExt as _;
use gpui::{App, Entity, Task};
use futures::{Future, FutureExt as _};
use gpui::{App, AsyncApp, Entity, Task};
use language::{DiagnosticSeverity, OffsetRangeExt};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{fmt::Write, sync::Arc};
use ui::SharedString;
use util::markdown::MarkdownInlineCode;
type Result<T, E = String> = core::result::Result<T, E>;
/// Get errors and warnings for the project or a specific file.
///
/// This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase.
@ -18,6 +20,11 @@ use util::markdown::MarkdownInlineCode;
/// When a path is provided, shows all diagnostics for that specific file.
/// When no path is provided, shows a summary of error and warning counts for all files in the project.
///
/// This tool attempts to refresh diagnostics before returning.
/// If refreshing diagnostics fails (for example, if the language server does not support pull-based diagnostics), it will return any diagnostics already present.
/// Note that, in this case, the results may be out-of-date, and may or may not reflect the most recent edits.
/// If this happens, do not attempt to re-run this tool in the hope that refreshing will later succeed. Failures are typically persistent.
///
/// <example>
/// To get diagnostics for a specific file:
/// {
@ -60,6 +67,71 @@ impl DiagnosticsTool {
}
}
async fn with_cancellation<T>(f: impl Future<Output = T>, s: &ToolCallEventStream) -> Result<T> {
futures::select! {
result = f.fuse() => Ok(result),
_ = s.cancelled_by_user().fuse() => {
Err("Diagnostics cancelled by user".to_string())
}
}
}
fn freshness_message(refreshed: bool) -> &'static str {
if refreshed {
"Diagnostics successfully refreshed."
} else {
"Failed to refresh diagnostics. Diagnostics may be stale."
}
}
/// Attempt to pull fresh diagnostics from the LSP before reading them.
///
/// Returns `Ok(true)` if diagnostics were successfully refreshed,
/// `Ok(false)` if the pull failed (callers should fall through to
/// read cached diagnostics), or `Err` if cancelled by the user.
async fn pull_diagnostics(
project: &Entity<Project>,
path: Option<&Path>,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<bool, String> {
match path {
Some(path) => {
let open_buffer_task = project.update(cx, |project, cx| {
let Some(project_path) = project.find_project_path(path, cx) else {
return Err(format!("Could not find path {} in project", path.display()));
};
Ok(project.open_buffer(project_path, cx))
})?;
let buffer = with_cancellation(open_buffer_task, event_stream)
.await?
.map_err(|e| e.to_string())?;
let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store());
let pull_task = lsp_store.update(cx, |lsp_store, cx| {
lsp_store.pull_diagnostics_for_buffer(buffer, cx)
});
let pull_result = with_cancellation(pull_task, event_stream).await?;
if let Err(error) = &pull_result {
log::warn!("Failed to pull diagnostics, using cached: {error:#}");
}
Ok(pull_result.is_ok())
}
None => {
let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store());
let pull_task = lsp_store.update(cx, |lsp_store, cx| {
lsp_store.pull_workspace_diagnostics_once(cx)
});
let succeeded = with_cancellation(pull_task, event_stream).await?;
if !succeeded {
log::warn!("Failed to pull workspace diagnostics, using cached");
}
Ok(succeeded)
}
}
}
impl AgentTool for DiagnosticsTool {
type Input = DiagnosticsToolInput;
type Output = String;
@ -96,21 +168,22 @@ impl AgentTool for DiagnosticsTool {
let input = input.recv().await.map_err(|e| e.to_string())?;
match input.path {
Some(path) if !path.is_empty() => {
let (_project_path, open_buffer_task) = project.update(cx, |project, cx| {
let Some(project_path) = project.find_project_path(&path, cx) else {
Some(ref path) if !path.is_empty() => {
let refreshed =
pull_diagnostics(&project, Some(Path::new(path)), &event_stream, cx)
.await?;
let open_buffer_task = project.update(cx, |project, cx| {
let Some(project_path) = project.find_project_path(path, cx) else {
return Err(format!("Could not find path {path} in project"));
};
let task = project.open_buffer(project_path.clone(), cx);
Ok((project_path, task))
Ok(project.open_buffer(project_path, cx))
})?;
let buffer = futures::select! {
result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Diagnostics cancelled by user".to_string());
}
};
let buffer = with_cancellation(open_buffer_task, &event_stream)
.await?
.map_err(|e| e.to_string())?;
let mut output = String::new();
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
@ -133,13 +206,18 @@ impl AgentTool for DiagnosticsTool {
.ok();
}
let freshness = freshness_message(refreshed);
if output.is_empty() {
Ok("File doesn't have errors or warnings!".to_string())
Ok(format!(
"{freshness}\n\nFile doesn't have errors or warnings!"
))
} else {
Ok(output)
Ok(format!("{freshness}\n\n{output}"))
}
}
_ => {
let refreshed = pull_diagnostics(&project, None, &event_stream, cx).await?;
let (output, has_diagnostics) = project.read_with(cx, |project, cx| {
let mut output = String::new();
let mut has_diagnostics = false;
@ -165,10 +243,13 @@ impl AgentTool for DiagnosticsTool {
(output, has_diagnostics)
});
let freshness = freshness_message(refreshed);
if has_diagnostics {
Ok(output)
Ok(format!("{freshness}\n\n{output}"))
} else {
Ok("No errors or warnings found in the project.".into())
Ok(format!(
"{freshness}\n\nNo errors or warnings found in the project."
))
}
}
}

View file

@ -113,7 +113,7 @@ impl StreamingParser {
{
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 start = find_char_boundary(old_text, state.old_text_emitted_len);
let chunk = normalize_done_chunk(old_text[start..].to_string());
state.old_text_done = true;
state.old_text_emitted_len = old_text.len();
@ -124,9 +124,10 @@ impl StreamingParser {
});
} else {
let safe_end = safe_emit_end_for_edit_text(old_text);
let safe_start = find_char_boundary(old_text, state.old_text_emitted_len);
if safe_end > state.old_text_emitted_len {
let chunk = old_text[state.old_text_emitted_len..safe_end].to_string();
if safe_end > safe_start {
let chunk = old_text[safe_start..safe_end].to_string();
state.old_text_emitted_len = safe_end;
events.push(EditEvent::OldTextChunk {
edit_index: index,
@ -143,9 +144,10 @@ impl StreamingParser {
&& !state.new_text_done
{
let safe_end = safe_emit_end_for_edit_text(new_text);
let safe_start = find_char_boundary(new_text, state.new_text_emitted_len);
if safe_end > state.new_text_emitted_len {
let chunk = new_text[state.new_text_emitted_len..safe_end].to_string();
if safe_end > safe_start {
let chunk = new_text[safe_start..safe_end].to_string();
state.new_text_emitted_len = safe_end;
events.push(EditEvent::NewTextChunk {
edit_index: index,
@ -343,8 +345,10 @@ impl StreamingParser {
/// held back because it may be an artifact of the partial JSON fixer closing
/// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
/// The next partial will reveal the correct character.
///
/// The returned position is always a valid UTF-8 character boundary.
fn safe_emit_end(text: &str) -> usize {
if text.as_bytes().last() == Some(&b'\\') {
if text.ends_with('\\') {
text.len() - 1
} else {
text.len()
@ -353,13 +357,35 @@ fn safe_emit_end(text: &str) -> usize {
fn safe_emit_end_for_edit_text(text: &str) -> usize {
let safe_end = safe_emit_end(text);
if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' {
// Use string slicing to check the last character, ensuring we respect UTF-8 boundaries.
if safe_end > 0 && text[..safe_end].ends_with('\n') {
safe_end - 1
} else {
safe_end
}
}
/// Finds a valid UTF-8 character boundary at or before the target position.
///
/// When streaming partial JSON, the text structure can change between updates
/// (e.g., an escape sequence being completed). This means a byte position that
/// was valid in one partial may land inside a multi-byte character in the next.
/// This function finds the nearest valid boundary at or before the target.
fn find_char_boundary(text: &str, target: usize) -> usize {
if target >= text.len() {
return text.len();
}
if text.is_char_boundary(target) {
return target;
}
// Walk backwards to find a valid boundary.
let mut pos = target;
while pos > 0 && !text.is_char_boundary(pos) {
pos -= 1;
}
pos
}
fn normalize_done_chunk(mut chunk: String) -> String {
if chunk.ends_with('\n') {
chunk.pop();
@ -1146,4 +1172,77 @@ mod tests {
}]
);
}
#[test]
fn test_multibyte_char_with_trailing_backslash() {
// Reproduces a panic where the stored `old_text_emitted_len` from a previous
// partial lands inside a multi-byte UTF-8 character in the current partial.
//
// Scenario: The JSON fixer produces a literal backslash when the stream cuts
// mid-escape. If the *next* partial replaces that backslash with a multi-byte
// character (e.g., em-dash '—'), the stored byte position is no longer valid.
let mut parser = StreamingParser::default();
// First partial: text ends with backslash (held back by safe_emit_end).
// "abc" = 3 bytes, backslash held back, so emitted_len = 3.
let events = parser.push_edits(&[PartialEdit {
old_text: Some("abc\\".into()),
new_text: None,
}]);
assert_eq!(
events.as_slice(),
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "abc".into(),
done: false,
}]
);
// Second partial: the backslash is replaced by em-dash '—' (3 bytes: E2 80 94).
// "ab—" = 2 + 3 = 5 bytes total, with em-dash at bytes 2..5.
// The stored emitted_len (3) is inside the em-dash!
// This should NOT panic.
let events = parser.push_edits(&[PartialEdit {
old_text: Some("ab—".into()),
new_text: None,
}]);
// The parser should handle this gracefully.
let _ = events;
}
#[test]
fn test_emitted_len_inside_multibyte_char_boundary() {
// More direct reproduction: emitted_len points inside a multi-byte character.
//
// This can happen when:
// 1. First partial has text where byte N is a valid boundary
// 2. Second partial has *different* text where byte N is inside a multi-byte char
let mut parser = StreamingParser::default();
// First partial: "ab" (2 bytes), backslash held back.
// After processing: emitted_len = 2
let events = parser.push_edits(&[PartialEdit {
old_text: Some("ab\\".into()),
new_text: None,
}]);
assert_eq!(
events.as_slice(),
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "ab".into(),
done: false,
}]
);
// Second partial: "a—" where em-dash starts at byte 1 and spans bytes 1-3.
// Stored emitted_len = 2, but byte 2 is inside the em-dash!
// This should NOT panic.
let events = parser.push_edits(&[PartialEdit {
old_text: Some("a—".into()),
new_text: None,
}]);
// The parser should handle this gracefully.
// We don't care exactly what it emits, just that it doesn't panic.
let _ = events;
}
}

View file

@ -338,6 +338,7 @@ impl AgentTool for ReadFileTool {
}
let mut anchor = None;
let mut is_outline_response = false;
// Check if specific line ranges are provided
let result = if input.start_line.is_some() || input.end_line.is_some() {
@ -377,6 +378,8 @@ impl AgentTool for ReadFileTool {
log.buffer_read(buffer.clone(), cx);
});
is_outline_response = buffer_content.is_outline;
if buffer_content.is_outline {
Ok(formatdoc! {"
SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
@ -409,11 +412,12 @@ impl AgentTool for ReadFileTool {
}
if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
let text: &str = text;
let markdown = MarkdownCodeBlock {
tag: &input.path,
text,
}
.to_string();
// For outline responses, omit the path tag so the markdown renderer
// does not invoke tree-sitter syntax highlighting against pseudo-code
// outline text. The outline is not valid source for the file's language,
// so highlighting would be both expensive and incorrect.
let tag: &str = if is_outline_response { "" } else { &input.path };
let markdown = MarkdownCodeBlock { tag, text }.to_string();
event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
acp::ToolCallContent::Content(acp::Content::new(markdown)),
]));
@ -610,6 +614,131 @@ mod test {
);
}
// The outline returned for a large file is not valid source for the file's
// language, so the UI-side markdown wrapping must omit the path tag.
// Otherwise the markdown renderer routes the fenced block through
// `CodeBlockKind::FencedSrc`, resolves the file's language, and runs
// tree-sitter against pseudo-code outline text on every paint.
#[gpui::test]
async fn test_outline_response_uses_untagged_code_block(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(language::rust_lang());
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
let (event_stream, mut rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/large_file.rs".into(),
start_line: None,
end_line: None,
};
tool.clone()
.run(ToolInput::resolved(input), event_stream, cx)
})
.await
.unwrap();
// Sanity-check: the file is large enough to trigger the outline branch.
assert!(
result
.to_str()
.unwrap()
.starts_with("SUCCESS: File outline retrieved."),
"expected outline response, got: {:?}",
result.to_str().unwrap()
);
// The first update carries the location; the second carries the
// markdown content destined for the tool-call UI.
let _location_update = rx.expect_update_fields().await;
let content_update = rx.expect_update_fields().await;
let content_blocks = content_update.content.expect("expected content update");
let acp::ToolCallContent::Content(content) = content_blocks
.first()
.expect("expected at least one content block")
else {
panic!("expected ContentBlock, got {:?}", content_blocks.first());
};
let acp::ContentBlock::Text(text) = &content.content else {
panic!("expected text content block, got {:?}", content.content);
};
assert!(
text.text.starts_with("```\n"),
"outline response must use an untagged fenced code block; got first line: {:?}",
text.text.lines().next()
);
assert!(
!text.text.starts_with("```root/"),
"outline response must not include the file path as a code block tag"
);
}
// The full-file (non-outline) response should still tag the code block
// with the file path so the markdown renderer can resolve the file's
// language for syntax highlighting.
#[gpui::test]
async fn test_full_file_response_keeps_path_tag(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"small_file.rs": "fn main() {}"
}),
)
.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, mut rx) = ToolCallEventStream::test();
cx.update(|cx| {
let input = ReadFileToolInput {
path: "root/small_file.rs".into(),
start_line: None,
end_line: None,
};
tool.clone()
.run(ToolInput::resolved(input), event_stream, cx)
})
.await
.unwrap();
let _location_update = rx.expect_update_fields().await;
let content_update = rx.expect_update_fields().await;
let content_blocks = content_update.content.expect("expected content update");
let acp::ToolCallContent::Content(content) = content_blocks
.first()
.expect("expected at least one content block")
else {
panic!("expected ContentBlock, got {:?}", content_blocks.first());
};
let acp::ContentBlock::Text(text) = &content.content else {
panic!("expected text content block, got {:?}", content.content);
};
assert!(
text.text.starts_with("```root/small_file.rs\n"),
"full-file response must tag the code block with the file path; got first line: {:?}",
text.text.lines().next()
);
}
// When a worktree is named "foo" and contains a subdirectory also named "foo",
// read_file({"path": "foo/test.txt"}) should return the file at the worktree
// root (as the tool schema promises), not the one inside the foo/ subdirectory.

View file

@ -2,6 +2,7 @@ use std::fmt::Write;
use std::sync::Arc;
use agent_client_protocol::schema as acp;
use collections::HashSet;
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
@ -95,6 +96,12 @@ impl AgentTool for RenameTool {
));
}
let buffers = transaction.0.keys().cloned().collect::<HashSet<_>>();
project
.update(cx, |project, cx| project.save_buffers(buffers, cx))
.await
.map_err(|e| format!("Rename succeeded, but failed to save renamed files: {e}"))?;
let mut output = format!(
"Renamed `{}` to `{}` in {} file(s):\n",
input.symbol.symbol_name,

View file

@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String {
/// frontmatter), not O(total file size).
pub fn render_skill_envelope(skill: &Skill, body: &str) -> String {
let source = match &skill.source {
agent_skills::SkillSource::BuiltIn => "built-in",
agent_skills::SkillSource::Global => "global",
agent_skills::SkillSource::ProjectLocal { .. } => "project-local",
};
let worktree = match &skill.source {
agent_skills::SkillSource::Global => None,
agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None,
agent_skills::SkillSource::ProjectLocal {
worktree_root_name, ..
} => Some(worktree_root_name.clone()),
@ -200,31 +201,33 @@ impl AgentTool for SkillTool {
(skill.clone(), path_string)
};
// Read the body on demand. Bodies are not kept in memory
// between materializations — see `agent_skills::read_skill_body`.
let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path)
.await
.map_err(|e| SkillToolOutput::Error {
error: e.to_string(),
})?;
// For built-in skills the body is already in memory (compiled
// into the binary). For user skills, read on demand from disk.
let body = if let Some(embedded) = skill.embedded_body {
embedded.to_string()
} else {
agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path)
.await
.map_err(|e| SkillToolOutput::Error {
error: e.to_string(),
})?
};
let rendered = render_skill_envelope(&skill, &body);
// Activations go through the standard tool-permission flow so
// they participate in the same Allow-Once / Always-Allow UX as
// every other built-in tool. The auth context value is the
// skill's absolute SKILL.md path so that "always allow this
// specific skill" is keyed to a specific file: editing the
// SKILL.md will change the path's content but not the path,
// so for content-change re-trust we'd want a hash too — but
// at minimum, two skills with the same name from different
// locations get independent trust grants.
let authorize = cx.update(|cx| {
let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]);
event_stream.authorize(self.initial_title(Ok(input), cx), context, cx)
});
authorize.await.map_err(|e| SkillToolOutput::Error {
error: e.to_string(),
})?;
// Built-in skills ship with Zed and are trusted by default,
// so they skip the authorization prompt. User-installed skills
// go through the standard Allow-Once / Always-Allow UX.
let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn;
if !is_builtin {
let authorize = cx.update(|cx| {
let context =
crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]);
event_stream.authorize(self.initial_title(Ok(input), cx), context, cx)
});
authorize.await.map_err(|e| SkillToolOutput::Error {
error: e.to_string(),
})?;
}
Ok(SkillToolOutput::Found { rendered })
})

View file

@ -0,0 +1,140 @@
use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput};
use agent_client_protocol::schema as acp;
use gpui::{App, SharedString, Task, WeakEntity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const MAX_TITLE_LEN: usize = 200;
/// Updates the current session title.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct UpdateTitleToolInput {
/// A concise, human-readable title for the current session.
pub title: String,
}
pub struct UpdateTitleTool {
thread: WeakEntity<Thread>,
}
impl UpdateTitleTool {
pub fn new(thread: WeakEntity<Thread>) -> Self {
Self { thread }
}
pub(crate) fn title_for_input(
input: Result<UpdateTitleToolInput, serde_json::Value>,
) -> SharedString {
let Ok(input) = input else {
return "Update title".into();
};
let Ok(title) = normalize_title(&input.title) else {
return "Update title".into();
};
format!("Update title: {title}").into()
}
}
impl AgentTool for UpdateTitleTool {
type Input = UpdateTitleToolInput;
type Output = String;
const NAME: &'static str = "update_title";
fn kind() -> acp::ToolKind {
acp::ToolKind::Think
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
Self::title_for_input(input)
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output, Self::Output>> {
let thread = self.thread.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|error| error.to_string())?;
let title = normalize_title(&input.title)?;
thread
.update(cx, |thread, cx| {
thread.set_title(title.into(), cx);
})
.map_err(|error| error.to_string())?;
Ok("Session title updated".to_string())
})
}
fn replay(
&self,
input: Self::Input,
_output: Self::Output,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> anyhow::Result<()> {
let title = self.initial_title(Ok(input), cx).to_string();
event_stream.update_fields(acp::ToolCallUpdateFields::new().title(title));
Ok(())
}
}
fn normalize_title(title: &str) -> Result<String, String> {
let title = title.lines().next().unwrap_or("").trim();
if title.is_empty() {
return Err("Title cannot be empty".to_string());
}
Ok(util::truncate_and_trailoff(title, MAX_TITLE_LEN))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
#[test]
fn test_normalize_title() {
assert_eq!(
normalize_title(" Title from model\nignored").unwrap(),
"Title from model"
);
assert!(normalize_title(" \nignored").is_err());
}
#[gpui::test]
async fn test_initial_title(cx: &mut TestAppContext) {
let tool = UpdateTitleTool::new(WeakEntity::new_invalid());
let title = cx.update(|cx| {
tool.initial_title(
Ok(UpdateTitleToolInput {
title: "Investigate title updates".to_string(),
}),
cx,
)
});
assert_eq!(
title,
SharedString::from("Update title: Investigate title updates")
);
let title = cx.update(|cx| {
tool.initial_title(
Ok(UpdateTitleToolInput {
title: " ".to_string(),
}),
cx,
)
});
assert_eq!(title, SharedString::from("Update title"));
}
}

View file

@ -9,7 +9,7 @@ use agent_client_protocol::{
};
use anyhow::anyhow;
use async_channel;
use collections::HashMap;
use collections::{HashMap, HashSet};
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::channel::mpsc;
use futures::future::Shared;
@ -509,6 +509,7 @@ impl AgentSessionList for AcpSessionList {
cx: &mut App,
) -> Task<Result<AgentSessionListResponse>> {
let conn = self.connection.clone();
let include_additional_directories = cx.has_flag::<AcpBetaFeatureFlag>();
cx.foreground_executor().spawn(async move {
let acp_request = acp::ListSessionsRequest::new()
.cwd(request.cwd)
@ -522,7 +523,14 @@ impl AgentSessionList for AcpSessionList {
.into_iter()
.map(|s| AgentSessionInfo {
session_id: s.session_id,
work_dirs: Some(PathList::new(&[s.cwd])),
work_dirs: Some(work_dirs_from_session_info(
s.cwd,
if include_additional_directories {
s.additional_directories
} else {
vec![]
},
)),
title: s.title.map(Into::into),
updated_at: s.updated_at.and_then(|date_str| {
chrono::DateTime::parse_from_rfc3339(&date_str)
@ -1053,6 +1061,15 @@ impl AcpConnection {
}
}
fn session_directories_from_work_dirs(
&self,
work_dirs: &PathList,
cx: &App,
) -> Result<SessionDirectories> {
let supports_additional_directories = self.supports_session_additional_directories(cx);
session_directories_from_work_dirs(work_dirs, supports_additional_directories)
}
fn open_or_create_session(
self: Rc<Self>,
session_id: acp::SessionId,
@ -1062,7 +1079,7 @@ impl AcpConnection {
rpc_call: impl FnOnce(
ConnectionTo<Agent>,
acp::SessionId,
PathBuf,
SessionDirectories,
)
-> futures::future::LocalBoxFuture<'static, Result<SessionConfigResponse>>
+ 'static,
@ -1089,9 +1106,9 @@ impl AcpConnection {
}
}
// TODO: remove this once ACP supports multiple working directories
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else {
return Task::ready(Err(anyhow!("Working directory cannot be empty")));
let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) {
Ok(directories) => directories,
Err(error) => return Task::ready(Err(error)),
};
let shared_task = cx
@ -1133,7 +1150,9 @@ impl AcpConnection {
);
let response =
match rpc_call(this.connection.clone(), session_id.clone(), cwd).await {
match rpc_call(this.connection.clone(), session_id.clone(), directories)
.await
{
Ok(response) => response,
Err(err) => {
this.sessions.borrow_mut().remove(&session_id);
@ -1288,6 +1307,77 @@ impl AcpConnection {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SessionDirectories {
cwd: PathBuf,
additional_directories: Vec<PathBuf>,
}
impl SessionDirectories {
fn into_new_session_request(self, mcp_servers: Vec<acp::McpServer>) -> acp::NewSessionRequest {
acp::NewSessionRequest::new(self.cwd)
.additional_directories(self.additional_directories)
.mcp_servers(mcp_servers)
}
fn into_load_session_request(
self,
session_id: acp::SessionId,
mcp_servers: Vec<acp::McpServer>,
) -> acp::LoadSessionRequest {
acp::LoadSessionRequest::new(session_id, self.cwd)
.additional_directories(self.additional_directories)
.mcp_servers(mcp_servers)
}
fn into_resume_session_request(
self,
session_id: acp::SessionId,
mcp_servers: Vec<acp::McpServer>,
) -> acp::ResumeSessionRequest {
acp::ResumeSessionRequest::new(session_id, self.cwd)
.additional_directories(self.additional_directories)
.mcp_servers(mcp_servers)
}
}
fn session_directories_from_work_dirs(
work_dirs: &PathList,
supports_additional_directories: bool,
) -> Result<SessionDirectories> {
let mut ordered_paths = work_dirs.ordered_paths();
let cwd = ordered_paths
.next()
.cloned()
.ok_or_else(|| anyhow!("Working directory cannot be empty"))?;
let additional_directories = if supports_additional_directories {
ordered_paths.cloned().collect()
} else {
Vec::new()
};
Ok(SessionDirectories {
cwd,
additional_directories,
})
}
fn work_dirs_from_session_info(cwd: PathBuf, additional_directories: Vec<PathBuf>) -> PathList {
let mut seen_paths = HashSet::default();
let mut paths = Vec::with_capacity(1 + additional_directories.len());
seen_paths.insert(cwd.clone());
paths.push(cwd);
for path in additional_directories {
if seen_paths.insert(path.clone()) {
paths.push(path);
}
}
PathList::new(&paths)
}
fn emit_load_error_to_all_sessions(
sessions: &Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
error: LoadError,
@ -1385,17 +1475,18 @@ impl AgentConnection for AcpConnection {
work_dirs: PathList,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
// TODO: remove this once ACP supports multiple working directories
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else {
return Task::ready(Err(anyhow!("Working directory cannot be empty")));
let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) {
Ok(directories) => directories,
Err(error) => return Task::ready(Err(error)),
};
let name = self.id.0.clone();
let mcp_servers = mcp_servers_for_project(&project, cx);
cx.spawn(async move |cx| {
let response = into_foreground_future(
self.connection
.send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)),
self.connection.send_request(
directories.into_new_session_request(mcp_servers),
),
)
.await
.map_err(map_acp_error)?;
@ -1550,6 +1641,15 @@ impl AgentConnection for AcpConnection {
.is_some()
}
fn supports_session_additional_directories(&self, cx: &App) -> bool {
cx.has_flag::<AcpBetaFeatureFlag>()
&& self
.agent_capabilities
.session_capabilities
.additional_directories
.is_some()
}
fn load_session(
self: Rc<Self>,
session_id: acp::SessionId,
@ -1570,14 +1670,11 @@ impl AgentConnection for AcpConnection {
project,
work_dirs,
title,
move |connection, session_id, cwd| {
move |connection, session_id, directories| {
Box::pin(async move {
let response = into_foreground_future(
connection.send_request(
acp::LoadSessionRequest::new(session_id.clone(), cwd)
.mcp_servers(mcp_servers),
),
)
let response = into_foreground_future(connection.send_request(
directories.into_load_session_request(session_id.clone(), mcp_servers),
))
.await
.map_err(map_acp_error)?;
Ok(SessionConfigResponse {
@ -1616,14 +1713,11 @@ impl AgentConnection for AcpConnection {
project,
work_dirs,
title,
move |connection, session_id, cwd| {
move |connection, session_id, directories| {
Box::pin(async move {
let response = into_foreground_future(
connection.send_request(
acp::ResumeSessionRequest::new(session_id.clone(), cwd)
.mcp_servers(mcp_servers),
),
)
let response = into_foreground_future(connection.send_request(
directories.into_resume_session_request(session_id.clone(), mcp_servers),
))
.await
.map_err(map_acp_error)?;
Ok(SessionConfigResponse {
@ -2107,6 +2201,10 @@ pub mod test_support {
self.inner.supports_resume_session()
}
fn supports_session_additional_directories(&self, cx: &App) -> bool {
self.inner.supports_session_additional_directories(cx)
}
fn resume_session(
self: Rc<Self>,
session_id: acp::SessionId,
@ -2557,6 +2655,345 @@ mod tests {
);
}
#[test]
fn session_directories_use_ordered_paths_when_supported() {
let work_dirs = PathList::new(&[
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]);
let directories =
session_directories_from_work_dirs(&work_dirs, true).expect("work dirs should convert");
assert_eq!(
directories,
SessionDirectories {
cwd: std::path::PathBuf::from("/workspace-b"),
additional_directories: vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c")
],
}
);
let session_id = acp::SessionId::new("session-1");
let new_session_request = directories.clone().into_new_session_request(Vec::new());
let load_session_request = directories
.clone()
.into_load_session_request(session_id.clone(), Vec::new());
let resume_session_request =
directories.into_resume_session_request(session_id, Vec::new());
assert_eq!(
new_session_request.cwd,
std::path::PathBuf::from("/workspace-b")
);
assert_eq!(
new_session_request.additional_directories,
vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c")
]
);
assert_eq!(
load_session_request.additional_directories,
new_session_request.additional_directories
);
assert_eq!(
resume_session_request.additional_directories,
new_session_request.additional_directories
);
}
#[test]
fn session_directories_drop_additional_paths_when_unsupported() {
let work_dirs = PathList::new(&[
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
]);
let directories = session_directories_from_work_dirs(&work_dirs, false)
.expect("work dirs should convert");
assert_eq!(
directories,
SessionDirectories {
cwd: std::path::PathBuf::from("/workspace-b"),
additional_directories: Vec::new(),
}
);
}
#[test]
fn session_info_work_dirs_preserve_cwd_then_additional_directories() {
let work_dirs = work_dirs_from_session_info(
std::path::PathBuf::from("/workspace-b"),
vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
],
);
assert_eq!(
work_dirs.ordered_paths().cloned().collect::<Vec<_>>(),
vec![
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]
);
}
#[test]
fn session_info_work_dirs_deduplicate_cwd_and_additional_directories() {
let work_dirs = work_dirs_from_session_info(
std::path::PathBuf::from("/workspace-b"),
vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
],
);
assert_eq!(
work_dirs.ordered_paths().cloned().collect::<Vec<_>>(),
vec![
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]
);
}
#[gpui::test]
async fn session_list_includes_additional_directories_in_work_dirs_when_beta_enabled(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| set_acp_beta_override(cx, "on"));
let connection = connect_session_list_test_agent(
vec![
acp::SessionInfo::new("session-1", "/workspace-b").additional_directories(vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]),
],
cx,
)
.await;
let session_list = AcpSessionList::new(connection, false);
let response = cx
.update(|cx| session_list.list_sessions(AgentSessionListRequest::default(), cx))
.await
.expect("session list should load");
let session = response
.sessions
.first()
.expect("session list should include the returned session");
let work_dirs = session
.work_dirs
.as_ref()
.expect("session should include work dirs");
assert_eq!(
work_dirs.ordered_paths().cloned().collect::<Vec<_>>(),
vec![
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]
);
}
#[gpui::test]
async fn session_list_excludes_additional_directories_in_work_dirs_when_beta_disabled(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| set_acp_beta_override(cx, "off"));
let connection = connect_session_list_test_agent(
vec![
acp::SessionInfo::new("session-1", "/workspace-b").additional_directories(vec![
std::path::PathBuf::from("/workspace-a"),
std::path::PathBuf::from("/workspace-c"),
]),
],
cx,
)
.await;
let session_list = AcpSessionList::new(connection, false);
let response = cx
.update(|cx| session_list.list_sessions(AgentSessionListRequest::default(), cx))
.await
.expect("session list should load");
let session = response
.sessions
.first()
.expect("session list should include the returned session");
let work_dirs = session
.work_dirs
.as_ref()
.expect("session should include work dirs");
assert_eq!(
work_dirs.ordered_paths().cloned().collect::<Vec<_>>(),
vec![std::path::PathBuf::from("/workspace-b")]
);
}
fn set_acp_beta_override(cx: &mut App, value: &str) {
let store = settings::SettingsStore::test(cx);
cx.set_global(store);
settings::SettingsStore::update_global(cx, |store, _| {
store.register_setting::<feature_flags::FeatureFlagsSettings>();
});
feature_flags::FeatureFlagStore::init(cx);
let value = value.to_string();
settings::SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert(AcpBetaFeatureFlag::NAME.to_string(), value);
});
});
}
async fn connect_session_list_test_agent(
sessions: Vec<acp::SessionInfo>,
cx: &mut gpui::TestAppContext,
) -> ConnectionTo<Agent> {
let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex();
let sessions = Arc::new(sessions);
cx.background_spawn(
Agent
.builder()
.name("list-test-agent")
.on_receive_request(
{
let sessions = sessions.clone();
async move |_request: acp::ListSessionsRequest, responder, _cx| {
responder.respond(acp::ListSessionsResponse::new((*sessions).clone()))
}
},
agent_client_protocol::on_receive_request!(),
)
.connect_to(agent_transport),
)
.detach();
let (connection_tx, connection_rx) = futures::channel::oneshot::channel();
cx.background_spawn(Client.builder().name("list-test-client").connect_with(
client_transport,
move |connection: ConnectionTo<Agent>| async move {
connection_tx.send(connection).ok();
futures::future::pending::<Result<(), acp::Error>>().await
},
))
.detach();
connection_rx
.await
.expect("failed to receive ACP connection")
}
#[gpui::test]
async fn additional_directories_support_requires_beta_flag_and_agent_capability(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let store = settings::SettingsStore::test(cx);
cx.set_global(store);
settings::SettingsStore::update_global(cx, |store, _| {
store.register_setting::<feature_flags::FeatureFlagsSettings>();
});
feature_flags::FeatureFlagStore::init(cx);
});
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "a": {}, "b": {} }))
.await;
let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await;
let mut harness = test_support::connect_fake_acp_connection(project, cx).await;
cx.update(|cx| {
settings::SettingsStore::update_global(cx, |store, _| {
store.register_setting::<feature_flags::FeatureFlagsSettings>();
});
feature_flags::FeatureFlagStore::init(cx);
});
let work_dirs = PathList::new(&[
std::path::PathBuf::from("/workspace-b"),
std::path::PathBuf::from("/workspace-a"),
]);
let missing_capability = cx
.update(|cx| {
harness
.connection
.session_directories_from_work_dirs(&work_dirs, cx)
})
.expect("work dirs should convert");
assert!(missing_capability.additional_directories.is_empty());
Rc::get_mut(&mut harness.connection)
.expect("test harness should own the only ACP connection handle")
.agent_capabilities
.session_capabilities
.additional_directories = Some(acp::SessionAdditionalDirectoriesCapabilities::new());
cx.update(|cx| {
settings::SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert("acp-beta".to_string(), "off".to_string());
});
});
});
let disabled = cx
.update(|cx| {
harness
.connection
.session_directories_from_work_dirs(&work_dirs, cx)
})
.expect("work dirs should convert");
assert!(disabled.additional_directories.is_empty());
cx.update(|cx| {
settings::SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert("acp-beta".to_string(), "on".to_string());
});
});
});
let enabled = cx
.update(|cx| {
harness
.connection
.session_directories_from_work_dirs(&work_dirs, cx)
})
.expect("work dirs should convert");
assert_eq!(
enabled,
SessionDirectories {
cwd: std::path::PathBuf::from("/workspace-b"),
additional_directories: vec![std::path::PathBuf::from("/workspace-a")],
}
);
}
#[gpui::test]
async fn session_delete_support_requires_beta_flag_and_capability(
cx: &mut gpui::TestAppContext,
@ -3407,6 +3844,7 @@ fn mcp_servers_for_project(project: &Entity<Project>, cx: &App) -> Vec<acp::McpS
url,
headers,
timeout: _,
oauth: _,
} => Some(acp::McpServer::Http(
acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers(
headers

View file

@ -88,22 +88,18 @@ impl AgentServer for CustomAgentServer {
let config_id = config_id.to_string();
let value_id = value_id.to_string();
update_settings_file(fs, cx, move |settings, cx| {
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
.get_or_insert_default()
.entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
.or_insert_with(default_settings_for_agent);
match settings {
settings::CustomAgentServerSettings::Custom {
favorite_config_option_values,
..
}
| settings::CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
}
| settings::CustomAgentServerSettings::Registry {
favorite_config_option_values,
..
@ -129,16 +125,15 @@ impl AgentServer for CustomAgentServer {
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| {
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
.get_or_insert_default()
.entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
.or_insert_with(default_settings_for_agent);
match settings {
settings::CustomAgentServerSettings::Custom { default_mode, .. }
| settings::CustomAgentServerSettings::Extension { default_mode, .. }
| settings::CustomAgentServerSettings::Registry { default_mode, .. } => {
*default_mode = mode_id.map(|m| m.to_string());
}
@ -161,16 +156,15 @@ impl AgentServer for CustomAgentServer {
fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| {
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
.get_or_insert_default()
.entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
.or_insert_with(default_settings_for_agent);
match settings {
settings::CustomAgentServerSettings::Custom { default_model, .. }
| settings::CustomAgentServerSettings::Extension { default_model, .. }
| settings::CustomAgentServerSettings::Registry { default_model, .. } => {
*default_model = model_id.map(|m| m.to_string());
}
@ -205,20 +199,17 @@ impl AgentServer for CustomAgentServer {
cx: &App,
) {
let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| {
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
.get_or_insert_default()
.entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
.or_insert_with(default_settings_for_agent);
let favorite_models = match settings {
settings::CustomAgentServerSettings::Custom {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Registry {
favorite_models, ..
} => favorite_models,
@ -258,22 +249,18 @@ impl AgentServer for CustomAgentServer {
let agent_id = self.agent_id();
let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, cx| {
update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings
.agent_servers
.get_or_insert_default()
.entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
.or_insert_with(default_settings_for_agent);
match settings {
settings::CustomAgentServerSettings::Custom {
default_config_options,
..
}
| settings::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| settings::CustomAgentServerSettings::Registry {
default_config_options,
..
@ -307,10 +294,6 @@ impl AgentServer for CustomAgentServer {
default_config_options,
..
}
| project::agent_server_store::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| project::agent_server_store::CustomAgentServerSettings::Registry {
default_config_options,
..
@ -422,28 +405,14 @@ fn is_registry_agent(agent_id: impl Into<AgentId>, cx: &App) -> bool {
is_in_registry || is_settings_registry
}
fn default_settings_for_agent(
agent_id: impl Into<AgentId>,
cx: &App,
) -> settings::CustomAgentServerSettings {
if is_registry_agent(agent_id, cx) {
settings::CustomAgentServerSettings::Registry {
default_model: None,
default_mode: None,
env: Default::default(),
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
}
} else {
settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
env: Default::default(),
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
}
fn default_settings_for_agent() -> settings::CustomAgentServerSettings {
settings::CustomAgentServerSettings::Registry {
default_model: None,
default_mode: None,
env: Default::default(),
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
}
}
@ -547,53 +516,4 @@ mod tests {
assert!(is_registry_agent("agent-from-settings", cx));
});
}
#[gpui::test]
fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) {
init_test(cx);
set_agent_server_settings(
cx,
vec![(
"my-extension-agent",
settings::CustomAgentServerSettings::Extension {
env: HashMap::default(),
default_mode: None,
default_model: None,
favorite_models: Vec::new(),
default_config_options: HashMap::default(),
favorite_config_option_values: HashMap::default(),
},
)],
);
cx.update(|cx| {
assert!(!is_registry_agent("my-extension-agent", cx));
});
}
#[gpui::test]
fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
assert!(matches!(
default_settings_for_agent("some-extension-agent", cx),
settings::CustomAgentServerSettings::Extension { .. }
));
});
}
#[gpui::test]
fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) {
init_test(cx);
init_registry_with_agents(cx, &["new-registry-agent"]);
cx.update(|cx| {
assert!(matches!(
default_settings_for_agent("new-registry-agent", cx),
settings::CustomAgentServerSettings::Registry { .. }
));
assert!(matches!(
default_settings_for_agent("not-in-registry", cx),
settings::CustomAgentServerSettings::Extension { .. }
));
});
}
}

View file

@ -1,7 +1,8 @@
use anyhow::{Context as _, Result};
use const_format::concatcp;
use const_format::{concatcp, formatcp};
use fs::Fs;
use futures::StreamExt;
use gpui::{Global, SharedString};
use serde::{Deserialize, Serialize};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
@ -64,11 +65,19 @@ pub struct Skill {
/// `skill` tool refuses to load it. The user can still invoke it as a
/// slash command.
pub disable_model_invocation: bool,
/// For built-in skills whose content is compiled into the binary,
/// this holds the full SKILL.md body so the skill tool can serve it
/// without a filesystem read.
pub embedded_body: Option<&'static str>,
}
/// Indicates where a skill was loaded from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource {
/// Compiled into the Zed binary. These are always available and have
/// the lowest override priority (global and project-local skills can
/// shadow them).
BuiltIn,
/// From ~/.agents/skills/
Global,
/// From {project}/.agents/skills/
@ -79,6 +88,23 @@ pub enum SkillSource {
}
impl SkillSource {
/// Precedence for resolving same-named skills. Higher values shadow
/// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources
/// returning equal precedence (e.g. two project-local skills from
/// different worktrees) leave the winner up to the caller, which by
/// convention keeps the first one in iteration order.
///
/// Adding a new `SkillSource` variant should be a one-line change
/// here — every consumer routes through this method so the hierarchy
/// stays in sync.
pub fn precedence(&self) -> u8 {
match self {
Self::BuiltIn => 0,
Self::Global => 1,
Self::ProjectLocal { .. } => 2,
}
}
/// Scope prefix used in the `/<prefix>:<name>` slash-command
/// syntax that the autocomplete popup inserts. Global skills use
/// an empty prefix (so the inserted text is `/:<name>`), and
@ -91,9 +117,21 @@ impl SkillSource {
/// invoked as `/:<name>`, and the worktree's skill is invoked as
/// `/global:<name>`. The two grammars never collide on the
/// inserted text.
/// Human-readable label for this source, used in the UI to
/// distinguish skills from different origins.
pub fn display_label(&self) -> &str {
match self {
Self::BuiltIn => "built-in",
Self::Global => "global",
Self::ProjectLocal {
worktree_root_name, ..
} => worktree_root_name.as_ref(),
}
}
pub fn scope_prefix(&self) -> &str {
match self {
Self::Global => "",
Self::BuiltIn | Self::Global => "",
Self::ProjectLocal {
worktree_root_name, ..
} => worktree_root_name.as_ref(),
@ -112,7 +150,7 @@ impl SkillSource {
/// strictness only affects users typing by memory.
pub fn matches_scope(&self, scope: &str) -> bool {
match self {
Self::Global => scope.is_empty(),
Self::BuiltIn | Self::Global => scope.is_empty(),
Self::ProjectLocal {
worktree_root_name, ..
} => !scope.is_empty() && worktree_root_name.as_ref() == scope,
@ -120,6 +158,23 @@ impl SkillSource {
}
}
/// App-wide index of loaded skills, published by NativeAgent and read
/// by any UI that needs to display the skill list (e.g. Settings UI).
#[derive(Default)]
pub struct SkillIndex {
pub global_skills: Vec<Skill>,
pub project_skills: Vec<ProjectSkillGroup>,
}
#[derive(Clone)]
pub struct ProjectSkillGroup {
pub worktree_id: SkillScopeId,
pub worktree_root_name: SharedString,
pub skills: Vec<Skill>,
}
impl Global for SkillIndex {}
/// Just the frontmatter, used for parsing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
@ -196,8 +251,8 @@ pub fn parse_skill_frontmatter(
let (metadata, _body) = extract_frontmatter(content)?;
validate_name(&metadata.name)?;
validate_description(&metadata.description)?;
validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
let directory_path = skill_file_path
.parent()
@ -211,6 +266,7 @@ pub fn parse_skill_frontmatter(
directory_path,
skill_file_path: skill_file_path.to_path_buf(),
disable_model_invocation: metadata.disable_model_invocation,
embedded_body: None,
})
}
@ -290,6 +346,14 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
/// by [`validate_name`].
pub const MAX_SKILL_NAME_LEN: usize = 64;
/// Maximum length (in bytes) for a valid skill description. Mirrors the
/// upper bound enforced by [`validate_description`].
///
/// Byte-based rather than char-based because that's what `.len()` returns
/// and what every caller currently measures; the UI also surfaces this
/// limit as a byte count so the editor's counter matches the validator.
pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
/// Convert an arbitrary human-readable string into a valid skill name, or
/// return `None` if no valid name can be produced (e.g. the input contains
/// no ASCII alphanumeric characters at all).
@ -354,34 +418,54 @@ pub fn slugify_skill_name(input: &str) -> Option<String> {
if slug.is_empty() { None } else { Some(slug) }
}
fn validate_name(name: &str) -> Result<()> {
/// Validate a skill name against the rules enforced by both the loader
/// and the create-skill UI.
///
/// Rules:
/// * non-empty
/// * at most [`MAX_SKILL_NAME_LEN`] bytes
/// * ASCII lowercase letters, digits, and hyphens only
/// * must not start or end with a hyphen — [`slugify_skill_name`]
/// already guarantees this for its output, so requiring it in the
/// validator keeps hand-written `SKILL.md` files consistent with
/// slugifier output
///
/// Error messages are returned as `&'static str` (interpolated at
/// compile time via `formatcp!`) so that UI surfaces can store them in
/// `Option<&'static str>` fields without allocating, and loader callers
/// can convert them to `anyhow::Error` via `anyhow::Error::msg`.
pub fn validate_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
anyhow::bail!("Skill name cannot be empty");
return Err("Skill name cannot be empty");
}
if name.len() > MAX_SKILL_NAME_LEN {
anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters");
return Err(formatcp!(
"Skill name must be at most {MAX_SKILL_NAME_LEN} characters"
));
}
if name.starts_with('-') || name.ends_with('-') {
return Err("Skill name must not start or end with a hyphen");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
anyhow::bail!("Skill name must contain only lowercase letters, numbers, and hyphens");
return Err("Skill name must contain only lowercase letters, numbers, and hyphens");
}
Ok(())
}
fn validate_description(description: &str) -> Result<()> {
if description.is_empty() {
anyhow::bail!("Skill description cannot be empty");
/// Validate a skill description against the rules enforced by both the
/// loader and the create-skill UI.
pub fn validate_description(description: &str) -> Result<(), &'static str> {
if description.trim().is_empty() {
return Err("Skill description cannot be empty");
}
if description.len() > 1024 {
anyhow::bail!("Skill description must be at most 1024 characters");
if description.len() > MAX_SKILL_DESCRIPTION_LEN {
return Err(formatcp!(
"Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes"
));
}
Ok(())
}
@ -600,6 +684,53 @@ pub async fn read_skill_body(
Ok(body.trim().to_string())
}
/// Content of the built-in `create-skill` SKILL.md, embedded at compile time.
const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md");
/// Returns the set of skills that are compiled into the Zed binary.
pub fn builtin_skills() -> Vec<Skill> {
let mut skills = Vec::new();
if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) {
skills.push(skill);
}
skills
}
/// Parse a built-in skill from its embedded SKILL.md content. The skill
/// gets a synthetic `<built-in>` path since it doesn't live on disk.
fn parse_builtin_skill(name: &str, content: &'static str) -> Result<Skill> {
let (metadata, body) = extract_frontmatter(content)?;
validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
let synthetic_dir = PathBuf::from(format!("<built-in>/{}", name));
let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME);
Ok(Skill {
name: metadata.name,
description: metadata.description,
source: SkillSource::BuiltIn,
directory_path: synthetic_dir,
skill_file_path: synthetic_path,
disable_model_invocation: metadata.disable_model_invocation,
embedded_body: Some(body.trim()),
})
}
/// All built-in skills as `(name, raw_content)` pairs. Used by
/// `builtin_skill_content` to serve the full SKILL.md without disk I/O.
const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)];
/// Look up the full embedded content of a built-in skill by its
/// synthetic file path. Returns `None` if the path doesn't match any
/// built-in skill.
pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> {
BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| {
let expected = PathBuf::from(format!("<built-in>/{}", name)).join(SKILL_FILE_NAME);
(expected == skill_file_path).then_some(*content)
})
}
/// Returns the global skills directory: `~/.agents/skills`.
///
/// Other agents (e.g. Claude Code) already write skill files into this
@ -663,6 +794,34 @@ mod tests {
use fs::FakeFs;
use gpui::TestAppContext;
#[test]
fn test_skill_source_precedence_is_total_and_ordered() {
// Pin the hierarchy: project-local > global > built-in. Every
// override and conflict-resolution site routes through this,
// so the rest of the codebase relies on it being correct.
let built_in = SkillSource::BuiltIn.precedence();
let global = SkillSource::Global.precedence();
let project = SkillSource::ProjectLocal {
worktree_id: SkillScopeId(1),
worktree_root_name: "my-project".into(),
}
.precedence();
assert!(built_in < global, "global must shadow built-in");
assert!(global < project, "project-local must shadow global");
// Two project-local skills from different worktrees tie. The
// "first wins" convention is enforced by the callers, but the
// precedence itself must be equal so neither silently shadows
// the other.
let other_project = SkillSource::ProjectLocal {
worktree_id: SkillScopeId(2),
worktree_root_name: "other-project".into(),
}
.precedence();
assert_eq!(project, other_project);
}
#[test]
fn test_parse_valid_skill() {
let content = r#"---
@ -873,12 +1032,8 @@ Content.
SkillSource::Global,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at most 64 characters")
);
let expected = format!("at most {MAX_SKILL_NAME_LEN} characters");
assert!(result.unwrap_err().to_string().contains(&expected));
}
#[test]
@ -1154,12 +1309,8 @@ Content.
SkillSource::Global,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at most 1024 characters")
);
let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes");
assert!(result.unwrap_err().to_string().contains(&expected));
}
#[test]
@ -1532,6 +1683,7 @@ description: A skill with no body content
directory_path: PathBuf::from("/skills/test-skill"),
skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"),
disable_model_invocation: false,
embedded_body: None,
};
let summary = SkillSummary::from(&skill);
@ -1759,4 +1911,89 @@ description: A skill with no body content
"project/.AGENTS/SKILLS/foo"
)));
}
#[test]
fn validate_name_accepts_valid_names() {
assert!(validate_name("draft-pr").is_ok());
assert!(validate_name("a").is_ok());
assert!(validate_name("skill1").is_ok());
assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN)).is_ok());
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_name("").is_err());
}
#[test]
fn validate_name_rejects_uppercase() {
assert!(validate_name("Draft-PR").is_err());
}
#[test]
fn validate_name_rejects_leading_and_trailing_hyphens() {
assert!(validate_name("-draft").is_err());
assert!(validate_name("draft-").is_err());
}
#[test]
fn validate_name_rejects_invalid_chars() {
assert!(validate_name("draft_pr").is_err());
assert!(validate_name("draft pr").is_err());
assert!(validate_name("draft.pr").is_err());
}
#[test]
fn validate_name_rejects_too_long() {
assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN + 1)).is_err());
}
#[test]
fn validate_description_accepts_valid() {
assert!(validate_description("A useful skill").is_ok());
}
#[test]
fn validate_description_rejects_empty_and_whitespace_only() {
assert!(validate_description("").is_err());
assert!(validate_description(" ").is_err());
assert!(validate_description("\t\n ").is_err());
}
#[test]
fn validate_description_rejects_too_long() {
assert!(validate_description(&"a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1)).is_err());
}
#[test]
fn validate_description_length_is_measured_in_bytes() {
// "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has
// only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be
// rejected by a byte-based validator (and accepted by a char-based
// one). This regression-tests the byte semantics that the loader
// and UI both rely on.
let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1;
let description = "é".repeat(chars);
assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN);
assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN);
assert!(validate_description(&description).is_err());
}
#[test]
fn slugify_output_always_passes_validate_name() {
for input in [
"foo",
"Foo Bar",
"rock & roll",
"---weird---",
"a".repeat(200).as_str(),
] {
if let Some(slug) = slugify_skill_name(input) {
assert!(
validate_name(&slug).is_ok(),
"slug {slug:?} from {input:?} failed validate_name"
);
}
}
}
}

View file

@ -0,0 +1,95 @@
---
name: create-skill
description: Helps you create new agent skills for Zed. Use this to create a skill, ask about SKILLs.md, or package reusable agent instructions.
---
# Creating a Zed Agent Skill
Use this skill when the user wants to create, edit, or understand agent skills in Zed.
## What is a Skill?
A skill is a reusable set of instructions that an agent can load on demand. Each skill lives in its own directory and is defined by a `SKILL.md` file with YAML frontmatter.
## Where Skills Live
Skills can be placed in two locations:
| Scope | Path | When to use |
|-------|------|-------------|
| Global | `~/.agents/skills/<skill-name>/SKILL.md` | Personal skills, available in all projects |
| Project-local | `<project>/.agents/skills/<skill-name>/SKILL.md` | Project-specific skills, shared with collaborators through version control |
Prefer project-local when the skill is specific to a repository. Prefer global when the skill is a personal workflow the user wants everywhere.
## SKILL.md Format
Every `SKILL.md` must start with YAML frontmatter between `---` delimiters:
```markdown
---
name: my-skill-name
description: A clear, specific description of what this skill does and when to use it.
---
# Skill Title
Instructions for the agent go here. Write them as if you're telling the agent
what to do when this skill is activated.
```
### Required Frontmatter Fields
- **`name`** (required): Must be 164 characters, lowercase alphanumeric with single-hyphen separators. Must match the containing directory name exactly. Regex: `^[a-z0-9]+(-[a-z0-9]+)*$`
- **`description`** (required): Must be 11024 characters. This is what the agent sees when deciding whether to use the skill — make it specific and actionable.
### Optional Frontmatter Fields
- **`disable-model-invocation`**: When set to `true`, the skill is hidden from the agent's automatic catalog. The user can still invoke it manually via the `/` slash command menu. Useful for skills that should only run when explicitly requested.
## Naming Rules
The skill name must:
- Be lowercase letters and numbers only, with single hyphens as separators
- Not start or end with `-`
- Not contain consecutive `--`
- Match the directory name that contains the `SKILL.md`
Good: `git-release`, `pr-review`, `rust-patterns`
Bad: `Git-Release`, `pr--review`, `-my-skill`, `my_skill`
## Writing Good Skill Instructions
The body of the SKILL.md (after the frontmatter) contains the instructions the agent will follow. Guidelines:
1. **Be direct**: Write instructions as if talking to the agent. "Do X", "Check Y", "Ask the user about Z".
2. **Be specific**: Include concrete file paths, commands, formats, and patterns.
3. **Include when-to-use guidance**: Help the agent understand the right context for this skill.
4. **Reference supporting files**: Skills can include additional files in their directory. Reference them with relative paths (e.g., `templates/component.tsx`). The agent can read these files when the skill is activated.
5. **Keep descriptions actionable**: The `description` field is the agent's primary signal for whether to load this skill. "Helps with code" is too vague. "Generate React components following the project's design system patterns" is specific.
## Supporting Files
A skill directory can contain additional files beyond `SKILL.md`:
```
~/.agents/skills/react-component/
├── SKILL.md
├── templates/
│ ├── component.tsx
│ └── test.tsx
└── examples/
└── button.tsx
```
Reference these in the skill body. The agent can read them using the file path shown in the `<directory>` tag of the skill envelope.
## Step-by-Step: Creating a Skill
1. Decide on scope (global vs project-local) based on the user's needs.
2. Choose a descriptive, hyphenated name.
3. Create the directory structure.
4. Write the `SKILL.md` with frontmatter and instructions.
5. Optionally add supporting files (templates, examples, references).
After creating the skill, it will be automatically discovered by Zed's agent on the next conversation (no restart needed for global skills if the `~/.agents/skills/` directory already exists).

View file

@ -30,10 +30,10 @@ acp_thread.workspace = true
action_log.workspace = true
agent-client-protocol.workspace = true
agent.workspace = true
agent_skills.workspace = true
async-channel.workspace = true
agent_servers.workspace = true
agent_settings.workspace = true
agent_skills.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true
heapless.workspace = true
@ -69,6 +69,7 @@ language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
lru.workspace = true
lsp.workspace = true
markdown.workspace = true
menu.workspace = true
@ -89,6 +90,7 @@ remote.workspace = true
remote_connection.workspace = true
rope.workspace = true
rules_library.workspace = true
skill_creator.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -664,8 +664,14 @@ impl AgentConfiguration {
None
};
let auth_required = matches!(server_status, ContextServerStatus::AuthRequired);
let client_secret_required = matches!(
server_status,
ContextServerStatus::ClientSecretRequired { .. }
);
let authenticating = matches!(server_status, ContextServerStatus::Authenticating);
let context_server_store = self.context_server_store.clone();
let workspace = self.workspace.clone();
let language_registry = self.language_registry.clone();
let tool_count = self
.context_server_registry
@ -685,6 +691,9 @@ impl AgentConfiguration {
ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
ContextServerStatus::ClientSecretRequired { .. } => {
AiSettingItemStatus::ClientSecretRequired
}
ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
};
@ -886,7 +895,7 @@ impl AgentConfiguration {
),
)
.child(
Button::new("error-logout-server", "Authenticate")
Button::new("authenticate-server", "Authenticate")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.on_click({
@ -900,6 +909,46 @@ impl AgentConfiguration {
)
.into_any_element(),
)
} else if client_secret_required {
Some(
feedback_base_container()
.child(
h_flex()
.pr_4()
.min_w_0()
.w_full()
.gap_2()
.child(
Icon::new(IconName::Info)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
Label::new("Enter a client secret to connect this server")
.color(Color::Muted)
.size(LabelSize::Small),
),
)
.child(
Button::new("enter-client-secret", "Enter Client Secret")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.on_click({
let context_server_id = context_server_id.clone();
move |_event, window, cx| {
ConfigureContextServerModal::show_modal_for_existing_server(
context_server_id.clone(),
language_registry.clone(),
workspace.clone(),
window,
cx,
)
.detach();
}
}),
)
.into_any_element(),
)
} else if authenticating {
Some(
h_flex()
@ -1125,7 +1174,6 @@ impl AgentConfiguration {
};
let source_kind = match source {
ExternalAgentSource::Extension => AiSettingItemSource::Extension,
ExternalAgentSource::Registry => AiSettingItemSource::Registry,
ExternalAgentSource::Custom => AiSettingItemSource::Custom,
};
@ -1169,26 +1217,6 @@ impl AgentConfiguration {
});
let uninstall_button = match source {
ExternalAgentSource::Extension => Some(
IconButton::new(
SharedString::from(format!("uninstall-{}", id)),
IconName::Trash,
)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Uninstall Agent Extension"))
.on_click(cx.listener(move |this, _, _window, cx| {
let agent_name = agent_server_name.clone();
if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
store.get_extension_id_for_agent(&agent_name)
}) {
ExtensionStore::global(cx)
.update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
.detach_and_log_err(cx);
}
})),
),
ExternalAgentSource::Registry => {
let fs = self.fs.clone();
Some(

View file

@ -17,7 +17,7 @@ use project::{
ContextServerStatus, ContextServerStore, ServerStatusChangedEvent,
registry::ContextServerDescriptorRegistry,
},
project_settings::{ContextServerSettings, ProjectSettings},
project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings},
worktree_store::WorktreeStore,
};
use serde::Deserialize;
@ -43,7 +43,9 @@ enum ConfigurationTarget {
id: ContextServerId,
url: String,
headers: HashMap<String, String>,
oauth: Option<OAuthClientSettings>,
},
Extension {
id: ContextServerId,
repository_url: Option<SharedString>,
@ -121,15 +123,17 @@ impl ConfigurationSource {
id,
url,
headers: auth,
oauth,
} => ConfigurationSource::Existing {
editor: create_editor(
context_server_http_input(Some((id, url, auth))),
context_server_http_input(Some((id, url, auth, oauth))),
jsonc_language,
window,
cx,
),
is_http: true,
},
ConfigurationTarget::Extension {
id,
repository_url,
@ -168,7 +172,7 @@ impl ConfigurationSource {
ConfigurationSource::New { editor, is_http }
| ConfigurationSource::Existing { editor, is_http } => {
if *is_http {
parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| {
parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| {
(
id,
ContextServerSettings::Http {
@ -176,6 +180,7 @@ impl ConfigurationSource {
url,
headers: auth,
timeout: None,
oauth,
},
)
})
@ -256,11 +261,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
}
fn context_server_http_input(
existing: Option<(ContextServerId, String, HashMap<String, String>)>,
existing: Option<(
ContextServerId,
String,
HashMap<String, String>,
Option<OAuthClientSettings>,
)>,
) -> String {
let (name, url, headers) = match existing {
Some((id, url, headers)) => {
let header = if headers.is_empty() {
let (name, url, headers, oauth) = match existing {
Some((id, url, headers, oauth)) => {
let headers = if headers.is_empty() {
r#"// "Authorization": "Bearer <token>"#.to_string()
} else {
let json = serde_json::to_string_pretty(&headers).unwrap();
@ -274,15 +284,48 @@ fn context_server_http_input(
.map(|line| format!(" {}", line))
.collect::<String>()
};
(id.0.to_string(), url, header)
(id.0.to_string(), url, headers, oauth)
}
None => (
"some-remote-server".to_string(),
"https://example.com/mcp".to_string(),
r#"// "Authorization": "Bearer <token>"#.to_string(),
None,
),
};
let oauth = oauth.map_or_else(
|| {
r#"
/// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain.
// "oauth": {
// "client_id": "your-client-id",
// },"#
.to_string()
},
|oauth| {
let mut lines = vec![
String::from("\n \"oauth\": {"),
format!(" \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()),
];
if let Some(client_secret) = oauth.client_secret {
lines.push(format!(
" \"client_secret\": {}",
serde_json::to_string(&client_secret).unwrap()
));
} else {
lines.push(String::from(
" /// Optional client secret for confidential clients\n // \"client_secret\": \"your-client-secret\"",
));
}
lines.push(String::from(" },"));
lines.join("\n")
},
);
format!(
r#"{{
/// Configure an MCP server that you connect to over HTTP
@ -290,7 +333,7 @@ fn context_server_http_input(
/// The name of your remote MCP server
"{name}": {{
/// The URL of the remote MCP server
"url": "{url}",
"url": "{url}",{oauth}
"headers": {{
/// Any headers to send along
{headers}
@ -300,12 +343,21 @@ fn context_server_http_input(
)
}
fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap<String, String>)> {
fn parse_http_input(
text: &str,
) -> Result<(
ContextServerId,
String,
HashMap<String, String>,
Option<OAuthClientSettings>,
)> {
#[derive(Deserialize)]
struct Temp {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
oauth: Option<OAuthClientSettings>,
}
let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?;
if value.len() != 1 {
@ -314,7 +366,12 @@ fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap<Stri
let (key, value) = value.into_iter().next().unwrap();
Ok((ContextServerId(key.into()), value.url, value.headers))
Ok((
ContextServerId(key.into()),
value.url,
value.headers,
value.oauth,
))
}
fn resolve_context_server_extension(
@ -349,8 +406,16 @@ fn resolve_context_server_extension(
enum State {
Idle,
Waiting,
AuthRequired { server_id: ContextServerId },
Authenticating { _server_id: ContextServerId },
AuthRequired {
server_id: ContextServerId,
},
ClientSecretRequired {
server_id: ContextServerId,
error: Option<SharedString>,
},
Authenticating {
server_id: ContextServerId,
},
Error(SharedString),
}
@ -361,10 +426,47 @@ pub struct ConfigureContextServerModal {
state: State,
original_server_id: Option<ContextServerId>,
scroll_handle: ScrollHandle,
secret_editor: Entity<Editor>,
_auth_subscription: Option<Subscription>,
}
impl ConfigureContextServerModal {
fn initial_state(
context_server_store: &Entity<ContextServerStore>,
target: &ConfigurationTarget,
cx: &App,
) -> State {
let Some(server_id) = (match target {
ConfigurationTarget::Existing { id, .. }
| ConfigurationTarget::ExistingHttp { id, .. }
| ConfigurationTarget::Extension { id, .. } => Some(id),
ConfigurationTarget::New => None,
}) else {
return State::Idle;
};
match context_server_store.read(cx).status_for_server(server_id) {
Some(ContextServerStatus::AuthRequired) => State::AuthRequired {
server_id: server_id.clone(),
},
Some(ContextServerStatus::ClientSecretRequired { error }) => {
State::ClientSecretRequired {
server_id: server_id.clone(),
error: error.map(SharedString::from),
}
}
Some(ContextServerStatus::Authenticating) => State::Authenticating {
server_id: server_id.clone(),
},
Some(ContextServerStatus::Error(error)) => State::Error(error.into()),
Some(ContextServerStatus::Starting)
| Some(ContextServerStatus::Running)
| Some(ContextServerStatus::Stopped)
| None => State::Idle,
}
}
pub fn register(
workspace: &mut Workspace,
language_registry: Arc<LanguageRegistry>,
@ -426,12 +528,14 @@ impl ConfigureContextServerModal {
url,
headers,
timeout: _,
..
oauth,
} => Some(ConfigurationTarget::ExistingHttp {
id: server_id,
url,
headers,
oauth,
}),
ContextServerSettings::Extension { .. } => {
match workspace
.update(cx, |workspace, cx| {
@ -468,9 +572,10 @@ impl ConfigureContextServerModal {
let workspace_handle = cx.weak_entity();
let context_server_store = workspace.project().read(cx).context_server_store();
workspace.toggle_modal(window, cx, |window, cx| Self {
context_server_store,
context_server_store: context_server_store.clone(),
workspace: workspace_handle,
state: State::Idle,
state: Self::initial_state(&context_server_store, &target, cx),
original_server_id: match &target {
ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()),
@ -485,6 +590,16 @@ impl ConfigureContextServerModal {
cx,
),
scroll_handle: ScrollHandle::new(),
secret_editor: cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text(
"Enter client secret (leave empty for public clients)",
window,
cx,
);
editor.set_masked(true, cx);
editor
}),
_auth_subscription: None,
})
})
@ -497,13 +612,12 @@ impl ConfigureContextServerModal {
}
fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
if matches!(
self.state,
State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
) {
if matches!(self.state, State::Waiting | State::Authenticating { .. }) {
return;
}
self._auth_subscription = None;
self.state = State::Idle;
let Some(workspace) = self.workspace.upgrade() else {
return;
@ -519,7 +633,7 @@ impl ConfigureContextServerModal {
self.state = State::Waiting;
let existing_server = self.context_server_store.read(cx).get_running_server(&id);
let existing_server = self.context_server_store.read(cx).get_server(&id);
if existing_server.is_some() {
self.context_server_store.update(cx, |store, cx| {
store.stop_server(&id, cx).log_err();
@ -542,6 +656,13 @@ impl ConfigureContextServerModal {
this.state = State::AuthRequired { server_id: id };
cx.notify();
}
Ok(ContextServerStatus::ClientSecretRequired { error }) => {
this.state = State::ClientSecretRequired {
server_id: id,
error: error.map(SharedString::from),
};
cx.notify();
}
Err(err) => {
this.set_error(err, cx);
}
@ -581,13 +702,33 @@ impl ConfigureContextServerModal {
cx.emit(DismissEvent);
}
fn cancel_authentication(&mut self, server_id: &ContextServerId, cx: &mut Context<Self>) {
self._auth_subscription = None;
self.context_server_store.update(cx, |store, cx| {
store.stop_server(server_id, cx).log_err();
});
self.state = State::Idle;
cx.notify();
}
fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
self.context_server_store.update(cx, |store, cx| {
store.authenticate_server(&server_id, cx).log_err();
});
self.await_auth_outcome(server_id, cx);
}
fn submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
let secret = self.secret_editor.read(cx).text(cx);
self.context_server_store.update(cx, |store, cx| {
store.submit_client_secret(&server_id, secret, cx).log_err();
});
self.await_auth_outcome(server_id, cx);
}
fn await_auth_outcome(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
self.state = State::Authenticating {
_server_id: server_id.clone(),
server_id: server_id.clone(),
};
self._auth_subscription = Some(cx.subscribe(
@ -610,6 +751,14 @@ impl ConfigureContextServerModal {
};
cx.notify();
}
ContextServerStatus::ClientSecretRequired { error } => {
this._auth_subscription = None;
this.state = State::ClientSecretRequired {
server_id: event.server_id.clone(),
error: error.clone().map(SharedString::from),
};
cx.notify();
}
ContextServerStatus::Error(error) => {
this._auth_subscription = None;
this.set_error(error.clone(), cx);
@ -814,10 +963,7 @@ impl ConfigureContextServerModal {
fn render_modal_footer(&self, cx: &mut Context<Self>) -> ModalFooter {
let focus_handle = self.focus_handle(cx);
let is_busy = matches!(
self.state,
State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
);
let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. });
ModalFooter::new()
.start_slot::<Button>(
@ -944,6 +1090,112 @@ impl ConfigureContextServerModal {
)
}
fn render_client_secret_required(
&self,
server_id: &ContextServerId,
error: Option<SharedString>,
cx: &mut Context<Self>,
) -> Div {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: cx.theme().colors().text,
font_family: settings.buffer_font.family.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_size: settings.buffer_font_size(cx).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
};
v_flex()
.w_full()
.gap_2()
.when_some(error, |this, error| {
this.child(Self::render_modal_error(error))
})
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Info)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new(
"Enter your OAuth client secret, or leave empty for public clients",
)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
h_flex()
.w_full()
.gap_2()
.capture_action({
let server_id = server_id.clone();
cx.listener(move |this, _: &editor::actions::Newline, _window, cx| {
this.submit_client_secret(server_id.clone(), cx);
})
})
.child(div().flex_1().child(EditorElement::new(
&self.secret_editor,
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: text_style,
syntax: cx.theme().syntax().clone(),
..Default::default()
},
)))
.child(
Button::new("submit-client-secret", "Submit")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.on_click({
let server_id = server_id.clone();
cx.listener(move |this, _event, _window, cx| {
this.submit_client_secret(server_id.clone(), cx);
})
}),
),
)
}
fn render_authenticating(&self, server_id: &ContextServerId, cx: &mut Context<Self>) -> Div {
h_flex()
.h_8()
.gap_2()
.justify_center()
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::XSmall)
.color(Color::Muted)
.with_rotate_animation(3),
)
.child(
Label::new("Authenticating…")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
Button::new("cancel-authentication", "Cancel")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.on_click({
let server_id = server_id.clone();
cx.listener(move |this, _event, _window, cx| {
this.cancel_authentication(&server_id, cx);
})
}),
)
}
fn render_modal_error(error: SharedString) -> Div {
h_flex()
.h_8()
@ -1003,8 +1255,15 @@ impl Render for ConfigureContextServerModal {
State::AuthRequired { server_id } => {
self.render_auth_required(&server_id.clone(), cx)
}
State::Authenticating { .. } => {
self.render_loading("Authenticating…")
State::ClientSecretRequired { server_id, error } => {
self.render_client_secret_required(
&server_id.clone(),
error.clone(),
cx,
)
}
State::Authenticating { server_id } => {
self.render_authenticating(&server_id.clone(), cx)
}
State::Error(error) => {
Self::render_modal_error(error.clone())
@ -1040,7 +1299,9 @@ fn wait_for_context_server(
}
match status {
ContextServerStatus::Running | ContextServerStatus::AuthRequired => {
ContextServerStatus::Running
| ContextServerStatus::AuthRequired
| ContextServerStatus::ClientSecretRequired { .. } => {
if let Some(tx) = tx.lock().take() {
let _ = tx.send(Ok(status.clone()));
}
@ -1104,3 +1365,52 @@ pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_http_input_reads_oauth_settings() {
let (id, url, headers, oauth) = parse_http_input(
r#"{
"figma": {
"url": "https://mcp.figma.com/mcp",
"oauth": {
"client_id": "client-id",
"client_secret": "client-secret"
},
"headers": {
"X-Test": "test"
}
}
}"#,
)
.unwrap();
assert_eq!(id, ContextServerId("figma".into()));
assert_eq!(url, "https://mcp.figma.com/mcp");
assert_eq!(headers.get("X-Test"), Some(&String::from("test")));
let oauth = oauth.expect("oauth should be present");
assert_eq!(oauth.client_id, "client-id");
assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
}
#[test]
fn context_server_http_input_preserves_existing_oauth_settings() {
let text = context_server_http_input(Some((
ContextServerId("figma".into()),
String::from("https://mcp.figma.com/mcp"),
HashMap::default(),
Some(OAuthClientSettings {
client_id: String::from("client-id"),
client_secret: Some(String::from("client-secret")),
}),
)));
let (_, _, _, oauth) = parse_http_input(&text).unwrap();
let oauth = oauth.expect("oauth should be present");
assert_eq!(oauth.client_id, "client-id");
assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,6 @@ enum RegistryInstallStatus {
NotInstalled,
InstalledRegistry,
InstalledCustom,
InstalledExtension,
}
#[derive(IntoElement)]
@ -155,9 +154,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledRegistry
}
CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
CustomAgentServerSettings::Extension { .. } => {
RegistryInstallStatus::InstalledExtension
}
};
self.installed_statuses.insert(id.clone(), status);
}
@ -560,9 +556,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
.style(ButtonStyle::OutlinedGhost)
.disabled(true),
RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
.style(ButtonStyle::OutlinedGhost)
.disabled(true),
}
}
}

View file

@ -43,10 +43,12 @@ use ::ui::IconName;
use agent_client_protocol::schema as acp;
use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag};
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
use gpui::{
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, Window, actions,
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, TaskExt, Window,
actions,
};
use language::{
LanguageRegistry,
@ -57,6 +59,7 @@ use language_model::{
};
use project::{AgentId, DisableAiSettings};
use prompt_store::{PromptBuilder, rules_to_skills_migration};
use rope::Point;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
@ -112,6 +115,42 @@ pub(crate) fn resolve_agent_image(
None
}
pub(crate) fn open_abs_path_at_point(
workspace: &mut Workspace,
abs_path: PathBuf,
point: Point,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
let project = workspace.project();
let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
else {
return false;
};
let item = workspace.open_path(path, None, true, window, cx);
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
let range = point..point;
editor
.update_in(cx, |editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|selections| selections.select_ranges([range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
true
}
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
@ -244,6 +283,8 @@ actions!(
ScrollOutputToNextMessage,
/// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
ImportThreadsFromOtherChannels,
/// Starts a new terminal thread.
NewTerminalThread,
]
);
@ -506,6 +547,7 @@ pub fn init(
) {
agent::ThreadStore::init_global(cx);
rules_library::init(cx);
skill_creator::init(cx);
if !is_eval {
// Initializing the language model from the user settings messes with the eval, so we only initialize them when
// we're not running inside of the eval.
@ -554,32 +596,6 @@ pub fn init(
);
})
.detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
|workspace: &mut Workspace,
_: &zed_actions::agent::OpenRulesToSkillsMigrationInfo,
window: &mut Window,
cx: &mut Context<Workspace>| {
// The banner is the only intended entry point and is
// gated on the skills flag, but dispatch from the
// command palette or a keybind is still possible — only
// open the explainer if the flag is enabled so it never
// surfaces outside its intended audience.
//
// Race note: `has_flag` returns false before server
// flags are received, so a dispatch during that brief
// window is a no-op even for users who genuinely have
// the flag. The banner itself has the same race — it
// stays hidden until flags arrive — so a user who can
// see the banner has, by definition, already passed it.
if cx.has_flag::<SkillsFeatureFlag>() {
crate::ui::RulesToSkillsModal::toggle(workspace, window, cx);
}
},
);
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
@ -608,10 +624,16 @@ pub fn init(
})
.detach();
// Once the `skills` feature flag has resolved, kick off the one-time
// migration of non-Default Rules to global Skills. Idempotent and
// self-gated on the flag, so it's safe to call on every flag-ready
// notification (and a no-op for users without the flag).
// Kick off the one-time migration of non-Default Rules to global
// Skills, deferred until server feature flags arrive.
//
// The migration itself is idempotent and no longer gated on a flag,
// but the deferral via `on_flags_ready` still matters: the migration
// writes to the on-disk `GlobalKeyValueStore`, which dispatches work
// on the `sqlezWorker` background thread. In `gpui::test` contexts,
// server flags are never received, so this callback never fires —
// which keeps that sqlite worker activity from racing with the
// `TestScheduler` and tripping its non-determinism panic.
{
let fs = fs.clone();
cx.on_flags_ready(move |_, cx| {
@ -653,12 +675,6 @@ fn update_command_palette_filter(cx: &mut App) {
.edit_predictions
.provider;
// The Skills feature flag is loaded asynchronously, so this value may
// be `false` before flags resolve. `update_command_palette_filter`
// gets re-run from `cx.on_flags_ready` (see `init`), which means the
// filter is reapplied with the correct value once flags arrive.
let skills_enabled = cx.has_flag::<SkillsFeatureFlag>();
CommandPaletteFilter::update_global(cx, |filter, _| {
use editor::actions::{
AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction,
@ -676,6 +692,7 @@ fn update_command_palette_filter(cx: &mut App) {
];
let open_rules_library_action = [TypeId::of::<zed_actions::assistant::OpenRulesLibrary>()];
let open_skill_creator_action = [TypeId::of::<zed_actions::assistant::OpenSkillCreator>()];
if disable_ai {
filter.hide_namespace("agent");
@ -726,15 +743,17 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_namespace("multi_workspace");
}
// Hide `assistant: open rules library` when Skills are enabled —
// Rules are surfaced through the Skills UI in that case. Applied
// after the disable-ai / agent-enabled branches so it overrides
// the `show_namespace("assistant")` call above without affecting
// the rest of that namespace's actions.
if !disable_ai && skills_enabled {
// Hide `assistant: open rules library` — Rules are surfaced
// through the Skills UI now. Applied after the disable-ai /
// agent-enabled branches so it overrides the
// `show_namespace("assistant")` call above without affecting the
// rest of that namespace's actions.
if !disable_ai {
filter.hide_action_types(&open_rules_library_action);
filter.show_action_types(open_skill_creator_action.iter());
} else {
filter.show_action_types(open_rules_library_action.iter());
filter.hide_action_types(&open_skill_creator_action);
}
});
}
@ -872,6 +891,10 @@ mod tests {
!filter.is_hidden(&NewThread),
"NewThread should be visible by default"
);
assert!(
!filter.is_hidden(&NewTerminalThread),
"NewTerminalThread should be visible by default"
);
});
// Disable agent
@ -891,6 +914,10 @@ mod tests {
filter.is_hidden(&NewThread),
"NewThread should be hidden when agent is disabled"
);
assert!(
filter.is_hidden(&NewTerminalThread),
"NewTerminalThread should be hidden when agent is disabled"
);
});
// Test EditPredictionProvider

View file

@ -1868,10 +1868,11 @@ impl MentionCompletion {
offset_to_line: usize,
supported_modes: &[PromptContextType],
) -> Option<Self> {
// Find the rightmost '@' that has a word boundary before it and no whitespace immediately after
// Find the rightmost '@' that has a boundary before it and no whitespace immediately after.
// A boundary is the start of the line, whitespace, or an opening bracket.
let mut last_mention_start = None;
for (idx, _) in line.rmatch_indices('@') {
// No whitespace immediately after '@'
// No whitespace immediately after '@'.
if line[idx + 1..]
.chars()
.next()
@ -1880,12 +1881,11 @@ impl MentionCompletion {
continue;
}
// Must be a word boundary before '@'
if idx > 0
&& line[..idx]
.chars()
.last()
.is_some_and(|c| !c.is_whitespace())
.is_some_and(|c| !c.is_whitespace() && !matches!(c, '(' | '[' | '{'))
{
continue;
}
@ -2603,7 +2603,7 @@ fn completion_text_for_terminal_selections(
};
mention_set
.update(cx, |mention_set, _| {
.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
@ -2612,6 +2612,8 @@ fn completion_text_for_terminal_selections(
tracked_buffers: vec![],
}))
.shared(),
None,
cx,
);
})
.ok();
@ -2958,6 +2960,39 @@ mod tests {
}),
"Should parse URL ending with @ (even if URL is incomplete)"
);
// Bracketed mentions: opening brackets count as a boundary before '@' so
// typing `(@`, `[@`, or `{@` still opens the completion menu.
assert_eq!(
MentionCompletion::try_parse("(@", 0, &supported_modes),
Some(MentionCompletion {
source_range: 1..2,
mode: None,
argument: None,
}),
"Should parse mention immediately after '('"
);
assert_eq!(
MentionCompletion::try_parse("[@", 0, &supported_modes),
Some(MentionCompletion {
source_range: 1..2,
mode: None,
argument: None,
}),
"Should parse mention immediately after '['"
);
assert_eq!(
MentionCompletion::try_parse("{@", 0, &supported_modes),
Some(MentionCompletion {
source_range: 1..2,
mode: None,
argument: None,
}),
"Should parse mention immediately after '{{'"
);
}
#[gpui::test]

View file

@ -40,14 +40,16 @@ use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
use markdown::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
};
use parking_lot::RwLock;
use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
use parking_lot::{Mutex, RwLock};
use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
use prompt_store::{PromptId, PromptStore};
use crate::message_editor::SessionCapabilities;
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
use lru::LruCache;
use rope::Point;
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
@ -61,11 +63,17 @@ use ui::{
KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
right_click_menu,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use util::{debug_panic, defer};
use util::{
ResultExt, debug_panic, defer,
paths::{PathStyle, PathWithPosition},
rel_path::RelPath,
size::format_file_size,
time::duration_alt_display,
};
use workspace::PathList;
use workspace::{
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
path_link::sanitize_path_text,
};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@ -509,6 +517,9 @@ pub struct ConversationView {
/// causes mermaid diagrams to re-render).
last_theme_id: Option<String>,
draft_prompt_persist_task: Option<Task<()>>,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Shared with the child [`ThreadView`] when one is constructed.
pub(crate) code_span_resolver: AgentCodeSpanResolver,
_subscriptions: Vec<Subscription>,
}
@ -707,7 +718,8 @@ impl ConversationView {
cx: &mut Context<Self>,
) -> Self {
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = vec![
let code_span_resolver = AgentCodeSpanResolver::new(&project.downgrade(), cx);
let mut subscriptions = vec![
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
@ -718,6 +730,20 @@ impl ConversationView {
Self::handle_agent_servers_updated,
),
];
subscriptions.push(cx.subscribe(&project, {
let resolver = code_span_resolver.clone();
move |_this: &mut Self, _project, event: &project::Event, cx| {
if matches!(
event,
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeUpdatedEntries(_, _)
) {
resolver.clear_cache();
cx.notify();
}
}
}));
cx.on_release(|this, cx| {
if let Some(connected) = this.as_connected() {
@ -764,6 +790,7 @@ impl ConversationView {
auth_task: None,
last_theme_id: Some(cx.theme().id.clone()),
draft_prompt_persist_task: None,
code_span_resolver,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
}
@ -1218,6 +1245,7 @@ impl ConversationView {
session_capabilities,
resumed_without_history,
self.project.downgrade(),
self.code_span_resolver.clone(),
self.thread_store.clone(),
self.prompt_store.clone(),
initial_content,
@ -2511,7 +2539,7 @@ impl ConversationView {
markdown,
style,
&self.workspace,
&self.project.downgrade(),
&self.code_span_resolver,
cx,
)
}
@ -2533,18 +2561,24 @@ impl ConversationView {
return false;
};
multi_workspace.read(cx).sidebar_open()
|| multi_workspace.read(cx).workspace() == &workspace
&& AgentPanel::is_visible(&workspace, cx)
&& multi_workspace
.read(cx)
.workspace()
.read(cx)
.panel::<AgentPanel>(cx)
.map_or(false, |p| {
p.read(cx).active_conversation_view().map(|c| c.entity_id())
== Some(cx.entity_id())
})
let multi_workspace = multi_workspace.read(cx);
multi_workspace.sidebar_open() && multi_workspace.is_threads_list_view_active(cx)
|| multi_workspace.workspace() == &workspace
&& self.is_visible_in_agent_panel(&workspace, cx)
}
fn is_visible_in_agent_panel(&self, workspace: &Entity<Workspace>, cx: &Context<Self>) -> bool {
AgentPanel::is_visible(workspace, cx)
&& workspace
.read(cx)
.panel::<AgentPanel>(cx)
.is_some_and(|panel| {
panel
.read(cx)
.visible_conversation_view()
.map(|conversation_view| conversation_view.entity_id())
== Some(cx.entity_id())
})
}
fn agent_status_visible(&self, window: &Window, cx: &Context<Self>) -> bool {
@ -2557,7 +2591,7 @@ impl ConversationView {
} else {
self.workspace
.upgrade()
.is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
.is_some_and(|workspace| self.is_visible_in_agent_panel(&workspace, cx))
}
}
@ -2569,7 +2603,7 @@ impl ConversationView {
} else {
self.workspace
.upgrade()
.is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
.is_some_and(|workspace| self.is_visible_in_agent_panel(&workspace, cx))
};
let settings = AgentSettings::get_global(cx);
if settings.play_sound_when_agent_done.should_play(visible) {
@ -3112,25 +3146,191 @@ fn render_agent_markdown(
markdown: Entity<Markdown>,
style: MarkdownStyle,
workspace: &WeakEntity<Workspace>,
project: &WeakEntity<Project>,
code_span_resolver: &AgentCodeSpanResolver,
cx: &App,
) -> MarkdownElement {
let workspace = workspace.clone();
let worktree_roots: Vec<PathBuf> = project
.upgrade()
.map(|project| {
project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect()
})
.unwrap_or_default();
let worktree_roots = code_span_resolver.worktree_roots(cx);
let resolver = code_span_resolver.clone();
MarkdownElement::new(markdown, style)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: markdown::WrapButtonVisibility::VisibleOnHover,
border: false,
})
.image_resolver(move |dest_url| resolve_agent_image(dest_url, &worktree_roots))
.on_url_click(move |text, window, cx| {
thread_view::open_link(text, &workspace, window, cx);
})
.on_code_span_link(move |text, cx| resolver.try_resolve(text, cx))
}
/// Shared, cloneable handle for resolving inline markdown code spans like
/// `` `src/main.rs:42` `` to clickable workspace file links.
#[derive(Clone)]
pub(crate) struct AgentCodeSpanResolver {
inner: Arc<AgentCodeSpanResolverInner>,
}
/// Maximum number of memoized code-span resolutions kept in the cache.
const CODE_SPAN_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(2048) {
Some(n) => n,
None => unreachable!(),
};
struct AgentCodeSpanResolverInner {
project: WeakEntity<Project>,
cache: Mutex<LruCache<Arc<str>, Option<SharedString>>>,
}
impl AgentCodeSpanResolver {
pub(crate) fn new(project: &WeakEntity<Project>, _cx: &App) -> Self {
Self {
inner: Arc::new(AgentCodeSpanResolverInner {
project: project.clone(),
cache: Mutex::new(LruCache::new(CODE_SPAN_CACHE_CAPACITY)),
}),
}
}
pub(crate) fn clear_cache(&self) {
self.inner.cache.lock().clear();
}
/// Absolute paths of every current worktree.
/// Used by the markdown image resolver, which needs the same set of roots.
fn worktree_roots(&self, cx: &App) -> Vec<PathBuf> {
self.inner
.project
.upgrade()
.map(|project| {
project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect()
})
.unwrap_or_default()
}
fn try_resolve(&self, text: &str, cx: &App) -> Option<SharedString> {
let trimmed = sanitize_path_text(text.trim());
if !Self::is_path_like(trimmed) {
return None;
}
if let Some(cached) = self.inner.cache.lock().get(trimmed).cloned() {
return cached;
}
let resolved = self.resolve_uncached(trimmed, cx);
self.inner
.cache
.lock()
.push(Arc::from(trimmed), resolved.clone());
resolved
}
fn resolve_uncached(&self, trimmed: &str, cx: &App) -> Option<SharedString> {
let path_with_position = PathWithPosition::parse_str(trimmed);
let candidate_path = &path_with_position.path;
if candidate_path.as_os_str().is_empty() {
return None;
}
let project = self.inner.project.upgrade()?;
let project = project.read(cx);
for worktree in project.visible_worktrees(cx) {
let worktree = worktree.read(cx);
for relative_path in Self::candidate_relative_paths(
candidate_path,
&worktree.abs_path(),
worktree.path_style(),
) {
let project_path = ProjectPath {
worktree_id: worktree.id(),
path: relative_path.clone(),
};
let Some(entry) = project.entry_for_path(&project_path, cx) else {
continue;
};
if !entry.is_file() {
continue;
}
let abs_path = worktree.absolutize(&relative_path);
let mention = match path_with_position.row.and_then(|row| row.checked_sub(1)) {
Some(line) => MentionUri::Selection {
abs_path: Some(abs_path),
line_range: line..=line,
column: path_with_position
.column
.map(|column| column.saturating_sub(1)),
},
None => MentionUri::File { abs_path },
};
return Some(mention.to_uri().to_string().into());
}
}
None
}
fn candidate_relative_paths(
path: &Path,
worktree_abs_path: &Path,
path_style: PathStyle,
) -> Vec<Arc<RelPath>> {
let path_text = path.to_string_lossy();
let relative_path: Option<Arc<RelPath>> =
if util::paths::is_absolute(path_text.as_ref(), path_style) {
path_style
.strip_prefix(path, worktree_abs_path)
.map(std::borrow::Cow::into_owned)
.map(Into::into)
} else {
RelPath::new(path, path_style)
.ok()
.map(std::borrow::Cow::into_owned)
.map(Into::into)
};
let Some(relative_path) = relative_path else {
return Vec::new();
};
let mut paths = vec![relative_path.clone()];
if let Some(root_name) = worktree_abs_path.file_name().and_then(|name| name.to_str())
&& let Ok(root_name) = RelPath::new(Path::new(root_name), path_style)
&& let Ok(stripped) = relative_path.strip_prefix(root_name.as_ref())
&& !stripped.is_empty()
{
paths.push(Arc::from(stripped));
}
paths
}
fn is_path_like(text: &str) -> bool {
if text.len() < 3
|| text.contains("://")
|| text.contains('|')
|| text.chars().any(char::is_control)
|| text.chars().all(|character| character.is_ascii_digit())
{
return false;
}
let path = PathWithPosition::parse_str(text).path;
let path_text = path.to_string_lossy();
if path_text.contains('/') || path_text.contains('\\') {
return true;
}
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| !extension.is_empty())
}
}
fn plan_label_markdown_style(
@ -3179,6 +3379,7 @@ pub(crate) mod tests {
use crate::agent_panel;
use crate::completion_provider::AgentContextSource;
use crate::test_support::register_test_sidebar;
use crate::thread_metadata_store::ThreadMetadataStore;
use super::*;
@ -3244,6 +3445,82 @@ pub(crate) mod tests {
});
}
#[gpui::test]
async fn test_agent_code_span_resolver_resolves_worktree_paths(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
util::path!("/project"),
json!({
"src": {
"main.rs": ""
},
"README.md": ""
}),
)
.await;
let project = Project::test(fs, [Path::new(util::path!("/project"))], cx).await;
let resolver = cx.update(|cx| AgentCodeSpanResolver::new(&project.downgrade(), cx));
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:10", cx))
.expect("expected worktree-relative file path to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: None,
}
);
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:10:5", cx))
.expect("expected worktree-relative file path with row and column to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: Some(4),
}
);
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:0", cx))
.expect("`:0` should fall back to a file mention instead of returning None");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::File {
abs_path: PathBuf::from(util::path!("/project/src/main.rs")),
}
);
assert!(cx.update(|cx| resolver.try_resolve("String", cx)).is_none());
assert!(
cx.update(|cx| resolver.try_resolve("does/not/exist.rs", cx))
.is_none()
);
assert!(
cx.update(|cx| resolver.try_resolve("src/main.rs.", cx))
.is_some()
);
let uri = cx
.update(|cx| resolver.try_resolve("project/src/main.rs:10", cx))
.expect("expected root-prefixed worktree path to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: None,
}
);
}
#[gpui::test]
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
init_test(cx);
@ -4164,6 +4441,7 @@ pub(crate) mod tests {
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
register_test_sidebar(true, cx);
// Open the sidebar so that sidebar_open() returns true.
multi_workspace_handle
@ -4228,6 +4506,80 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_notification_when_sidebar_open_but_thread_list_hidden(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
<dyn Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs, [], cx).await;
let multi_workspace_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace_handle
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
register_test_sidebar(false, cx);
multi_workspace_handle
.update(cx, |mw, _window, cx| {
mw.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let conversation_view = cx.update(|window, cx| {
cx.new(|cx| {
ConversationView::new(
Rc::new(StubAgentServer::default_response()),
connection_store,
Agent::Custom { id: "Test".into() },
None,
None,
None,
None,
None,
workspace.downgrade(),
project.clone(),
Some(thread_store),
None,
AgentThreadSource::AgentPanel,
window,
cx,
)
})
});
cx.run_until_parked();
let message_editor = message_editor(&conversation_view, cx);
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello", window, cx);
});
active_thread(&conversation_view, cx)
.update_in(cx, |view, window, cx| view.send(window, cx));
cx.run_until_parked();
assert!(
cx.windows()
.iter()
.any(|window| window.downcast::<AgentNotification>().is_some()),
"Expected notification when the sidebar is open but the thread list is hidden"
);
}
#[gpui::test]
async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) {
init_test(cx);
@ -4250,6 +4602,7 @@ pub(crate) mod tests {
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
register_test_sidebar(true, cx);
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
let connection_store =

View file

@ -1,17 +1,19 @@
use crate::{
DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
agent_configuration::configure_context_server_modal::default_markdown_style,
open_abs_path_at_point,
thread_metadata_store::{ThreadId, ThreadMetadataStore},
};
use agent_client_protocol::schema as acp;
use std::cell::RefCell;
use acp_thread::{ContentBlock, PlanEntry};
use agent::{SkillLoadingError, SkillLoadingErrorsUpdated};
use agent::{SkillLoadingError, SkillLoadingErrorsUpdated, UserAgentsMd};
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
use editor::actions::OpenExcerpts;
use feature_flags::AcpBetaFeatureFlag;
use crate::completion_provider::AvailableSkill;
use crate::message_editor::SharedSessionCapabilities;
use gpui::List;
@ -329,12 +331,14 @@ pub struct ThreadView {
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
pub project: WeakEntity<Project>,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Cloned from the parent `ConversationView` so the cache is shared and the
/// snapshot stays in sync via the parent's project-event subscription.
pub(crate) code_span_resolver: AgentCodeSpanResolver,
pub show_external_source_prompt_warning: bool,
pub show_codex_windows_warning: bool,
pub multi_root_callout_dismissed: bool,
pub generating_indicator_in_list: bool,
/// Errors emitted by the agent while loading SKILL.md files. Each one
/// renders as a clickable banner that opens the offending file.
pub skill_loading_errors: Vec<SkillLoadingError>,
/// Errors the user has explicitly dismissed. Each entry is matched against
/// emitted errors by full equality; when an error no longer appears in the
@ -383,6 +387,7 @@ impl ThreadView {
session_capabilities: SharedSessionCapabilities,
resumed_without_history: bool,
project: WeakEntity<Project>,
code_span_resolver: AgentCodeSpanResolver,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
initial_content: Option<AgentInitialContent>,
@ -450,6 +455,23 @@ impl ThreadView {
&& project.upgrade().is_some_and(|p| p.read(cx).is_local())
&& agent_id.as_ref() == "Codex";
if let Some(project) = project.upgrade() {
subscriptions.push(cx.subscribe(&project, {
let resolver = code_span_resolver.clone();
move |_this: &mut Self, _project, event: &project::Event, cx| {
if matches!(
event,
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeUpdatedEntries(_, _)
) {
resolver.clear_cache();
cx.notify();
}
}
}));
}
let title_editor = {
let metadata = ThreadMetadataStore::try_global(cx)
.and_then(|store| store.read(cx).entry(root_thread_id).cloned());
@ -602,6 +624,7 @@ impl ThreadView {
add_context_menu_handle: PopoverMenuHandle::default(),
thinking_effort_menu_handle: PopoverMenuHandle::default(),
project,
code_span_resolver,
show_external_source_prompt_warning,
show_codex_windows_warning,
multi_root_callout_dismissed: false,
@ -3609,12 +3632,10 @@ impl ThreadView {
let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6));
let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self
let (project_rules_count, project_entry_ids) = self
.as_native_thread(cx)
.map(|thread| {
let project_context = thread.read(cx).project_context().read(cx);
let user_rules_count = project_context.user_rules.len();
let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0);
let project_entry_ids = project_context
.worktrees
.iter()
@ -3622,15 +3643,14 @@ impl ThreadView {
.map(|rf| ProjectEntryId::from_usize(rf.project_entry_id))
.collect::<Vec<_>>();
let project_rules_count = project_entry_ids.len();
(
user_rules_count,
first_user_rules_id,
project_rules_count,
project_entry_ids,
)
(project_rules_count, project_entry_ids)
})
.unwrap_or_default();
let global_agents_md_loaded = UserAgentsMd::global(cx)
.and_then(|md| md.content())
.is_some();
let workspace = self.workspace.clone();
let max_output_tokens = self
@ -3665,8 +3685,7 @@ impl ThreadView {
show_split,
cost_label,
separator_color: tooltip_separator_color,
user_rules_count,
first_user_rules_id,
global_agents_md_loaded,
project_rules_count,
project_entry_ids,
workspace,
@ -4159,6 +4178,8 @@ impl ThreadView {
let session_capabilities = self.session_capabilities.read();
let supports_images = session_capabilities.supports_images();
let supports_embedded_context = session_capabilities.supports_embedded_context();
let available_skills = session_capabilities.completion_skills();
drop(session_capabilities);
let has_editor_selection = workspace
.upgrade()
@ -4182,7 +4203,6 @@ impl ThreadView {
ContextMenu::build(window, cx, move |menu, _window, _cx| {
menu.key_context("AddContextMenu")
.header("Context")
.item(
ContextMenuEntry::new("Files & Directories")
.icon(IconName::File)
@ -4228,21 +4248,19 @@ impl ThreadView {
}
}),
)
.item(
ContextMenuEntry::new("Skills")
.icon(IconName::Sparkle)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.handler({
let message_editor = message_editor.clone();
move |window, cx| {
message_editor.focus_handle(cx).focus(window, cx);
message_editor.update(cx, |editor, cx| {
editor.insert_context_type("skill", window, cx);
});
.when(!available_skills.is_empty(), |this| {
this.submenu_with_colored_icon("Skills", IconName::Sparkle, Color::Muted, {
let message_editor = message_editor.clone();
let available_skills = available_skills.clone();
move |mut menu, _window, _cx| {
for skill in &available_skills {
menu = menu
.item(Self::skill_menu_entry(skill, message_editor.clone()));
}
}),
)
menu
}
})
})
.item(
ContextMenuEntry::new("Image")
.icon(IconName::Image)
@ -4291,6 +4309,25 @@ impl ThreadView {
})
}
fn skill_menu_entry(
skill: &AvailableSkill,
message_editor: Entity<crate::message_editor::MessageEditor>,
) -> ContextMenuEntry {
let label = format!("{} ({})", skill.name, skill.source);
let skill = skill.clone();
ContextMenuEntry::new(label)
.icon(IconName::Sparkle)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.handler(move |window, cx| {
message_editor.focus_handle(cx).focus(window, cx);
message_editor.update(cx, |editor, cx| {
editor.insert_skill_crease(&skill, window, cx);
});
})
}
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
let following = self.is_following(cx);
@ -4342,8 +4379,7 @@ struct TokenUsageTooltip {
show_split: bool,
cost_label: Option<String>,
separator_color: Color,
user_rules_count: usize,
first_user_rules_id: Option<uuid::Uuid>,
global_agents_md_loaded: bool,
project_rules_count: usize,
project_entry_ids: Vec<ProjectEntryId>,
workspace: WeakEntity<Workspace>,
@ -4361,8 +4397,7 @@ impl Render for TokenUsageTooltip {
let output_max = self.output_max.clone();
let show_split = self.show_split;
let cost_label = self.cost_label.clone();
let user_rules_count = self.user_rules_count;
let first_user_rules_id = self.first_user_rules_id;
let global_agents_md_loaded = self.global_agents_md_loaded;
let project_rules_count = self.project_rules_count;
let project_entry_ids = self.project_entry_ids.clone();
let workspace = self.workspace.clone();
@ -4425,7 +4460,7 @@ impl Render for TokenUsageTooltip {
)
})
.when(
user_rules_count > 0 || project_rules_count > 0,
global_agents_md_loaded || project_rules_count > 0,
move |this| {
this.child(
v_flex()
@ -4443,26 +4478,39 @@ impl Render for TokenUsageTooltip {
.child(
v_flex()
.mx_neg_1()
.when(user_rules_count > 0, move |this| {
this.child(
Button::new(
"open-user-rules",
format!("{} user rules", user_rules_count),
.when(global_agents_md_loaded, {
let workspace = workspace.clone();
move |this| {
this.child(
Button::new(
"open-global-agents-md",
"1 global rule",
)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.on_click(move |_, window, cx| {
workspace
.update(cx, |workspace, cx| {
workspace
.open_abs_path(
paths::agents_file()
.clone(),
workspace::OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
.detach_and_log_err(cx);
})
.log_err();
}),
)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(OpenRulesLibrary {
prompt_to_select: first_user_rules_id,
}),
cx,
);
}),
)
}
})
.when(project_rules_count > 0, move |this| {
let workspace = workspace.clone();
@ -5993,6 +6041,7 @@ impl ThreadView {
.render_markdown(command, style, cx)
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
});
let copy_button = CopyButton::new("copy-command", command_text)
@ -6446,7 +6495,6 @@ impl ThreadView {
content_ix,
tool_call,
use_card_layout,
has_image_content,
failed_or_canceled,
focus_handle,
window,
@ -6578,7 +6626,6 @@ impl ThreadView {
content_ix,
tool_call,
use_card_layout,
has_image_content,
failed_or_canceled,
focus_handle,
window,
@ -6587,6 +6634,32 @@ impl ThreadView {
)
}),
)
.when(!use_card_layout, |this| {
let button_id =
SharedString::from(format!("tool_output-collapse-{:?}", tool_call.id));
let tool_call_id = tool_call.id.clone();
this.child(
div()
.ml(rems(0.4))
.px_3p5()
.pt_2()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
.child(
IconButton::new(button_id, IconName::ChevronUp)
.full_width()
.style(ButtonStyle::Outlined)
.icon_color(Color::Muted)
.on_click(cx.listener({
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
this.expanded_tool_calls.remove(&tool_call_id);
cx.notify();
}
})),
),
)
})
.into_any(),
ToolCallStatus::Rejected => Empty.into_any(),
}
@ -7570,7 +7643,6 @@ impl ThreadView {
context_ix: usize,
tool_call: &ToolCall,
card_layout: bool,
is_image_tool_call: bool,
has_failed: bool,
focus_handle: &FocusHandle,
window: &Window,
@ -7583,20 +7655,19 @@ impl ThreadView {
} else if let Some(markdown) = content.markdown() {
self.render_markdown_output(
markdown.clone(),
tool_call.id.clone(),
context_ix,
card_layout,
window,
cx,
)
} else if let Some(image) = content.image() {
} else if let Some((image, dimensions)) = content.image() {
let location = tool_call.locations.first().cloned();
self.render_image_output(
entry_ix,
image.clone(),
dimensions,
location,
card_layout,
is_image_tool_call,
cx,
)
} else {
@ -7727,14 +7798,11 @@ impl ThreadView {
fn render_markdown_output(
&self,
markdown: Entity<Markdown>,
tool_call_id: acp::ToolCallId,
context_ix: usize,
card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
v_flex()
.gap_2()
.map(|this| {
@ -7757,20 +7825,6 @@ impl ThreadView {
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
cx,
))
.when(!card_layout, |this| {
this.child(
IconButton::new(button_id, IconName::ChevronUp)
.full_width()
.style(ButtonStyle::Outlined)
.icon_color(Color::Muted)
.on_click(cx.listener({
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
this.expanded_tool_calls.remove(&tool_call_id);
cx.notify();
}
})),
)
})
.into_any_element()
}
@ -7778,30 +7832,26 @@ impl ThreadView {
&self,
entry_ix: usize,
image: Arc<gpui::Image>,
dimensions: Option<gpui::Size<u32>>,
location: Option<acp::ToolCallLocation>,
card_layout: bool,
show_dimensions: bool,
cx: &Context<Self>,
) -> AnyElement {
let dimensions_label = if show_dimensions {
let format_name = match image.format() {
gpui::ImageFormat::Png => "PNG",
gpui::ImageFormat::Jpeg => "JPEG",
gpui::ImageFormat::Webp => "WebP",
gpui::ImageFormat::Gif => "GIF",
gpui::ImageFormat::Svg => "SVG",
gpui::ImageFormat::Bmp => "BMP",
gpui::ImageFormat::Tiff => "TIFF",
gpui::ImageFormat::Ico => "ICO",
gpui::ImageFormat::Pnm => "PNM",
};
let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
.with_guessed_format()
.ok()
.and_then(|reader| reader.into_dimensions().ok());
dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
let format_name = match image.format() {
gpui::ImageFormat::Png => "PNG",
gpui::ImageFormat::Jpeg => "JPEG",
gpui::ImageFormat::Webp => "WebP",
gpui::ImageFormat::Gif => "GIF",
gpui::ImageFormat::Svg => "SVG",
gpui::ImageFormat::Bmp => "BMP",
gpui::ImageFormat::Tiff => "TIFF",
gpui::ImageFormat::Ico => "ICO",
gpui::ImageFormat::Pnm => "PNM",
};
let dimensions_label = if let Some(size) = dimensions {
format!("{}×{} {}", size.width, size.height, format_name)
} else {
None
format_name.into()
};
v_flex()
@ -7816,29 +7866,27 @@ impl ThreadView {
.border_color(self.tool_card_border_color(cx))
}
})
.when(dimensions_label.is_some() || location.is_some(), |this| {
this.child(
h_flex()
.w_full()
.justify_between()
.items_center()
.children(dimensions_label.map(|label| {
Label::new(label)
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx)
}))
.when_some(location, |this, _loc| {
this.child(
Button::new(("go-to-file", entry_ix), "Go to File")
.label_size(LabelSize::Small)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
})),
)
}),
)
})
.child(
h_flex()
.w_full()
.justify_between()
.items_center()
.child(
Label::new(dimensions_label)
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx),
)
.when_some(location, |this, _loc| {
this.child(
Button::new(("go-to-file", entry_ix), "Go to File")
.label_size(LabelSize::Small)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
})),
)
}),
)
.child(
img(image)
.max_w_96()
@ -8684,7 +8732,13 @@ impl ThreadView {
style: MarkdownStyle,
cx: &App,
) -> MarkdownElement {
render_agent_markdown(markdown, style, &self.workspace, &self.project, cx)
render_agent_markdown(
markdown,
style,
&self.workspace,
&self.code_span_resolver,
cx,
)
}
fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
@ -8826,6 +8880,15 @@ impl ThreadView {
return None;
}
if self
.thread
.read(cx)
.connection()
.supports_session_additional_directories(cx)
{
return None;
}
let project = self.project.upgrade()?;
let worktree_count = project.read(cx).visible_worktrees(cx).count();
if worktree_count <= 1 {
@ -9346,39 +9409,27 @@ pub(crate) fn open_link(
abs_path: path,
line_range,
..
} => {
open_abs_path_at_point(
workspace,
path,
Point::new(*line_range.start(), 0),
window,
cx,
);
}
| MentionUri::Selection {
MentionUri::Selection {
abs_path: Some(path),
line_range,
column,
} => {
let project = workspace.project();
let Some(path) =
project.update(cx, |project, cx| project.find_project_path(path, cx))
else {
return;
};
let item = workspace.open_path(path, None, true, window, cx);
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
let range =
Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
editor
.update_in(cx, |editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|s| s.select_ranges(vec![range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
open_abs_path_at_point(
workspace,
path,
Point::new(*line_range.start(), column.unwrap_or(0)),
window,
cx,
);
}
MentionUri::Selection { abs_path: None, .. } => {}
MentionUri::Thread { id, name } => {

View file

@ -12,6 +12,7 @@ use agent_client_protocol::schema as acp;
use anyhow::Context as _;
use db::kvp::KeyValueStore;
use gpui::{App, AppContext as _, Entity, Task};
use itertools::Itertools;
use ui::SharedString;
use util::ResultExt as _;
use workspace::Workspace;
@ -28,7 +29,7 @@ pub fn read(thread_id: ThreadId, cx: &App) -> Option<Vec<acp::ContentBlock>> {
let kvp = KeyValueStore::global(cx);
let raw = kvp
.scoped(NAMESPACE)
.read(&thread_id_key(thread_id))
.read(&thread_id.to_key_string())
.log_err()
.flatten()?;
serde_json::from_str(&raw).log_err()
@ -40,7 +41,7 @@ pub fn write(
cx: &App,
) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx);
let key = thread_id_key(thread_id);
let key = thread_id.to_key_string();
let payload = match serde_json::to_string(prompt).context("serializing draft prompt") {
Ok(payload) => payload,
Err(err) => return Task::ready(Err(err)),
@ -50,12 +51,43 @@ pub fn write(
pub fn delete(thread_id: ThreadId, cx: &App) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx);
let key = thread_id_key(thread_id);
let key = thread_id.to_key_string();
cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await })
}
fn thread_id_key(thread_id: ThreadId) -> String {
thread_id.to_key_string()
pub fn draft_has_user_content<'a>(
thread_id: ThreadId,
workspaces: impl IntoIterator<Item = &'a Entity<Workspace>>,
cx: &App,
) -> bool {
let mut found_live_copy = false;
for blocks in workspaces
.into_iter()
.filter_map(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
.filter_map(|panel| {
panel
.read(cx)
.draft_prompt_blocks_if_in_memory(thread_id, cx)
})
{
found_live_copy = true;
if blocks_have_user_content(&blocks) {
return true;
}
}
if found_live_copy {
false
} else {
read(thread_id, cx).is_some_and(|blocks| blocks_have_user_content(&blocks))
}
}
fn blocks_have_user_content(blocks: &[acp::ContentBlock]) -> bool {
blocks.iter().any(|block| match block {
acp::ContentBlock::Text(text) => !text.text.trim().is_empty(),
_ => true,
})
}
/// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the
@ -128,7 +160,6 @@ pub fn display_label_for_draft(
acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
truncate_draft_label(&raw)
}

View file

@ -63,6 +63,7 @@ pub struct MentionSet {
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
crease_entities: HashMap<CreaseId, Entity<LoadingContext>>,
}
impl MentionSet {
@ -76,6 +77,7 @@ impl MentionSet {
thread_store,
prompt_store,
mentions: HashMap::default(),
crease_entities: HashMap::default(),
}
}
@ -110,12 +112,24 @@ impl MentionSet {
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
self.mentions.remove(&crease_id);
self.crease_entities.remove(&crease_id);
}
}
}
pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) {
pub fn insert_mention(
&mut self,
crease_id: CreaseId,
uri: MentionUri,
task: MentionTask,
crease_entity: Option<Entity<LoadingContext>>,
cx: &mut App,
) {
self.mentions.insert(crease_id, (uri, task));
if let Some(entity) = crease_entity {
self.crease_entities.insert(crease_id, entity);
}
self.recompute_disambiguation(cx);
}
/// Creates the appropriate confirmation task for a mention based on its URI type.
@ -153,6 +167,7 @@ impl MentionSet {
MentionUri::Selection {
abs_path: Some(abs_path),
line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
"Untitled buffer selection mentions are not supported for paste"
@ -165,8 +180,10 @@ impl MentionSet {
}
}
pub fn remove_mention(&mut self, crease_id: &CreaseId) {
pub fn remove_mention(&mut self, crease_id: &CreaseId, cx: &mut App) {
self.mentions.remove(crease_id);
self.crease_entities.remove(crease_id);
self.recompute_disambiguation(cx);
}
pub fn creases(&self) -> HashSet<CreaseId> {
@ -196,13 +213,32 @@ impl MentionSet {
}
pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
self.crease_entities
.retain(|id, _| mentions.contains_key(id));
self.mentions = mentions;
}
pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
self.crease_entities.clear();
self.mentions.drain()
}
fn recompute_disambiguation(&self, cx: &mut App) {
let labels =
compute_disambiguated_labels(self.mentions.iter().map(|(id, (uri, _))| (*id, uri)));
for (crease_id, new_label) in labels {
if let Some(entity) = self.crease_entities.get(&crease_id) {
entity.update(cx, |loading_ctx, cx| {
if loading_ctx.label != new_label {
loading_ctx.label = new_label;
cx.notify();
}
});
}
}
}
pub fn confirm_mention_completion(
&mut self,
crease_text: SharedString,
@ -273,7 +309,7 @@ impl MentionSet {
cx,
)
};
let Some((crease_id, tx)) = crease else {
let Some((crease_id, tx, crease_entity)) = crease else {
return Task::ready(());
};
@ -325,6 +361,10 @@ impl MentionSet {
.spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
.shared();
self.mentions.insert(crease_id, (mention_uri, task.clone()));
if let Some(entity) = crease_entity {
self.crease_entities.insert(crease_id, entity);
}
self.recompute_disambiguation(cx);
// Notify the user if we failed to load the mentioned context
let workspace = workspace.downgrade();
@ -338,6 +378,7 @@ impl MentionSet {
editor.edit([(start_anchor..end_anchor, "")], cx);
});
this.mentions.remove(&crease_id);
this.crease_entities.remove(&crease_id);
})
.ok();
}
@ -451,6 +492,14 @@ impl MentionSet {
skill_file_path: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
// Built-in skills have synthetic paths that don't exist on disk;
// serve their content directly from the compiled-in data.
if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) {
return Task::ready(Ok(Mention::Text {
content: content.to_string(),
tracked_buffers: Vec::new(),
}));
}
cx.background_spawn(async move {
let content = std::fs::read_to_string(&skill_file_path).map_err(|e| {
anyhow!(
@ -522,6 +571,7 @@ impl MentionSet {
let uri = MentionUri::Selection {
abs_path: abs_path.clone(),
line_range: line_range.clone(),
column: None,
};
let crease = crease_for_mention(
selection_name(abs_path.as_deref(), &line_range).into(),
@ -669,6 +719,26 @@ impl MentionSet {
}
}
/// Computes disambiguated labels for a set of mentions. When multiple mentions
/// share the same base name, their labels include extra context (additional
/// parent path components for files/directories, source for skills) so the user
/// can tell them apart. Driven by [`util::disambiguate::compute_disambiguation_details`],
/// which is the same utility used for buffer tab titles and the sidebar.
fn compute_disambiguated_labels<'a>(
mentions: impl Iterator<Item = (CreaseId, &'a MentionUri)>,
) -> HashMap<CreaseId, SharedString> {
let mentions: Vec<_> = mentions.collect();
let details =
util::disambiguate::compute_disambiguation_details(&mentions, |(_, uri), detail| {
uri.disambiguated_name(detail)
});
mentions
.into_iter()
.zip(details)
.map(|((id, uri), detail)| (id, uri.disambiguated_name(detail).into()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -737,6 +807,7 @@ mod tests {
MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 1..=2,
column: None,
},
false,
http_client,
@ -821,7 +892,7 @@ pub(crate) async fn insert_images_as_context(
snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
});
let image = Arc::new(image);
let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
let Ok(Some((crease_id, tx, crease_entity))) = cx.update(|window, cx| {
insert_crease_for_mention(
text_anchor,
content_len,
@ -856,13 +927,15 @@ pub(crate) async fn insert_images_as_context(
})
.shared();
mention_set.update(cx, |mention_set, _cx| {
mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
MentionUri::PastedImage {
name: name.to_string(),
},
task.clone(),
crease_entity,
cx,
)
});
@ -874,8 +947,8 @@ pub(crate) async fn insert_images_as_context(
editor.update(cx, |editor, cx| {
editor.edit([(start_anchor..end_anchor, "")], cx);
});
mention_set.update(cx, |mention_set, _cx| {
mention_set.remove_mention(&crease_id)
mention_set.update(cx, |mention_set, cx| {
mention_set.remove_mention(&crease_id, cx)
});
}
}
@ -991,7 +1064,11 @@ pub(crate) fn insert_crease_for_mention(
editor: Entity<Editor>,
window: &mut Window,
cx: &mut App,
) -> Option<(CreaseId, postage::barrier::Sender)> {
) -> Option<(
CreaseId,
postage::barrier::Sender,
Option<Entity<LoadingContext>>,
)> {
let (tx, rx) = postage::barrier::channel();
let crease_id = editor.update(cx, |editor, cx| {
@ -1002,19 +1079,20 @@ pub(crate) fn insert_crease_for_mention(
let start = start.bias_right(&snapshot);
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let (render, crease_entity) = render_mention_fold_button(
crease_label.clone(),
crease_icon.clone(),
crease_tooltip,
mention_uri.clone(),
workspace.clone(),
start..end,
rx,
image,
cx.weak_entity(),
cx,
);
let placeholder = FoldPlaceholder {
render: render_mention_fold_button(
crease_label.clone(),
crease_icon.clone(),
crease_tooltip,
mention_uri.clone(),
workspace.clone(),
start..end,
rx,
image,
cx.weak_entity(),
cx,
),
render,
merge_adjacent: false,
..Default::default()
};
@ -1033,10 +1111,11 @@ pub(crate) fn insert_crease_for_mention(
let ids = editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
Some(ids[0])
Some((ids[0], crease_entity))
})?;
Some((crease_id, tx))
let (crease_id, crease_entity) = crease_id;
Some((crease_id, tx, Some(crease_entity)))
}
pub(crate) fn crease_for_mention(
@ -1215,7 +1294,10 @@ fn render_mention_fold_button(
image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: WeakEntity<Editor>,
cx: &mut App,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
) -> (
Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement>,
Entity<LoadingContext>,
) {
let loading = cx.new(|cx| {
let loading = cx.spawn(async move |this, cx| {
loading_finished.recv().await;
@ -1238,10 +1320,13 @@ fn render_mention_fold_button(
image: image_task.clone(),
}
});
Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
let loading_clone = loading.clone();
let render: Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> =
Arc::new(move |_fold_id, _fold_range, _cx| loading_clone.clone().into_any_element());
(render, loading)
}
struct LoadingContext {
pub struct LoadingContext {
id: EntityId,
label: SharedString,
icon: SharedString,

View file

@ -1119,6 +1119,7 @@ impl MessageEditor {
let mention_uri = MentionUri::Selection {
abs_path: Some(file_path.clone()),
line_range: line_range.clone(),
column: None,
};
let mention_text = mention_uri.as_link().to_string();
@ -1134,7 +1135,7 @@ impl MessageEditor {
(text_anchor, mention_text.len())
});
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
text_anchor,
content_len,
crease_text.into(),
@ -1181,8 +1182,14 @@ impl MessageEditor {
})
.shared();
self.mention_set.update(cx, |mention_set, _cx| {
mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
mention_task,
crease_entity,
cx,
)
});
}
}
@ -1241,7 +1248,7 @@ impl MessageEditor {
let http_client = workspace.read(cx).client().http_client();
for (anchor, content_len, mention_uri) in all_mentions {
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
content_len,
mention_uri.name().into(),
@ -1271,8 +1278,14 @@ impl MessageEditor {
.spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
.shared();
self.mention_set.update(cx, |mention_set, _cx| {
mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
task.clone(),
crease_entity,
cx,
)
});
// Drop the tx after inserting to signal the crease is ready
@ -1463,7 +1476,7 @@ impl MessageEditor {
(text_anchor, mention_text.len())
});
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
text_anchor,
content_len,
mention_uri.name().into(),
@ -1488,14 +1501,75 @@ impl MessageEditor {
.spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
.shared();
mention_set.update(cx, |mention_set, _| {
mention_set.insert_mention(crease_id, mention_uri, mention_task);
mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri,
mention_task,
crease_entity,
cx,
);
});
})
})
.detach_and_log_err(cx);
}
pub fn insert_skill_crease(
&mut self,
skill: &AvailableSkill,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let mention_uri = MentionUri::Skill {
name: skill.name.to_string(),
source: skill.source.to_string(),
skill_file_path: skill.skill_file_path.clone(),
};
let link_text = mention_uri.as_link().to_string();
let content_len = link_text.len();
let mention_text = format!("{} ", link_text);
let crease_text: SharedString = mention_uri.name().into();
let start_anchor = self.editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let buffer_snapshot = snapshot.as_singleton()?;
let cursor = editor.selections.newest_anchor().start;
let text_anchor = snapshot
.anchor_to_buffer_anchor(cursor)?
.0
.bias_left(buffer_snapshot);
editor.insert(&mention_text, window, cx);
Some(text_anchor)
});
let Some(start_anchor) = start_anchor else {
return;
};
self.mention_set
.update(cx, |mention_set, cx| {
mention_set.confirm_mention_completion(
crease_text,
start_anchor,
content_len,
mention_uri,
false,
self.editor.clone(),
&workspace,
window,
cx,
)
})
.detach();
}
pub(crate) fn insert_selections(
&mut self,
selection: AgentContextSelection,
@ -1744,7 +1818,7 @@ impl MessageEditor {
for (range, mention_uri, mention) in mentions {
let adjusted_start = insertion_start + range.start;
let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention(
snapshot.anchor_to_buffer_anchor(anchor).unwrap().0,
range.end - range.start,
mention_uri.name().into(),
@ -1761,11 +1835,13 @@ impl MessageEditor {
};
drop(tx);
self.mention_set.update(cx, |mention_set, _cx| {
self.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
mention_uri.clone(),
Task::ready(Ok(mention)).shared(),
crease_entity,
cx,
)
});
}
@ -4322,10 +4398,12 @@ mod tests {
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
@ -4349,7 +4427,7 @@ mod tests {
"line 3\nline 4\n".to_string(),
),
] {
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
snapshot
.anchor_to_buffer_anchor(
snapshot.anchor_before(MultiBufferOffset(range.start)),
@ -4371,7 +4449,7 @@ mod tests {
};
drop(tx);
message_editor.mention_set.update(cx, |mention_set, _cx| {
message_editor.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
uri,
@ -4380,6 +4458,8 @@ mod tests {
tracked_buffers: Vec::new(),
}))
.shared(),
None,
cx,
);
});
}
@ -4481,10 +4561,12 @@ mod tests {
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
@ -4508,7 +4590,7 @@ mod tests {
"line 3\nline 4\n".to_string(),
),
] {
let Some((crease_id, tx)) = insert_crease_for_mention(
let Some((crease_id, tx, _crease_entity)) = insert_crease_for_mention(
snapshot
.anchor_to_buffer_anchor(
snapshot.anchor_before(MultiBufferOffset(range.start)),
@ -4530,7 +4612,7 @@ mod tests {
};
drop(tx);
message_editor.mention_set.update(cx, |mention_set, _cx| {
message_editor.mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention(
crease_id,
uri,
@ -4539,6 +4621,8 @@ mod tests {
tracked_buffers: Vec::new(),
}))
.shared(),
None,
cx,
);
});
}

View file

@ -10,6 +10,7 @@ use db::{
},
sqlez_macros::sql,
};
use futures::{FutureExt, future::Shared};
use gpui::{AppContext as _, Entity, Global, Task};
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use ui::{App, Context, SharedString};
@ -69,6 +70,7 @@ pub struct TerminalThreadMetadataStore {
terminals: HashMap<TerminalId, TerminalThreadMetadata>,
terminals_by_paths: HashMap<PathList, HashSet<TerminalId>>,
terminals_by_main_paths: HashMap<PathList, HashSet<TerminalId>>,
reload_task: Option<Shared<Task<()>>>,
pending_terminal_ops_tx: async_channel::Sender<DbOperation>,
_db_operations_task: Task<()>,
}
@ -125,6 +127,12 @@ impl TerminalThreadMetadataStore {
self.terminals.values()
}
pub fn reload_task(&self) -> Shared<Task<()>> {
self.reload_task
.clone()
.unwrap_or_else(|| Task::ready(()).shared())
}
pub fn entries_for_path<'a>(
&'a self,
path_list: &PathList,
@ -312,6 +320,7 @@ impl TerminalThreadMetadataStore {
terminals: HashMap::default(),
terminals_by_paths: HashMap::default(),
terminals_by_main_paths: HashMap::default(),
reload_task: None,
pending_terminal_ops_tx: tx,
_db_operations_task,
};
@ -332,30 +341,32 @@ impl TerminalThreadMetadataStore {
fn reload(&mut self, cx: &mut Context<Self>) {
let db = self.db.clone();
cx.spawn(async move |this, cx| {
let rows = cx
.background_spawn(async move {
db.list()
.context("Failed to fetch terminal thread metadata")
self.reload_task = Some(
cx.spawn(async move |this, cx| {
let rows = cx
.background_spawn(async move {
db.list()
.context("Failed to fetch terminal thread metadata")
})
.await
.log_err()
.unwrap_or_default();
this.update(cx, |this, cx| {
this.terminals.clear();
this.terminals_by_paths.clear();
this.terminals_by_main_paths.clear();
for row in rows {
this.cache_terminal_metadata(row);
}
cx.notify();
})
.await
.log_err()
.unwrap_or_default();
this.update(cx, |this, cx| {
this.terminals.clear();
this.terminals_by_paths.clear();
this.terminals_by_main_paths.clear();
for row in rows {
this.cache_terminal_metadata(row);
}
cx.notify();
.ok();
})
.ok();
})
.detach();
.shared(),
);
}
}

View file

@ -1,13 +1,17 @@
use acp_thread::{AgentConnection, StubAgentConnection};
use agent_client_protocol::schema as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
use gpui::{Entity, Task, TestAppContext, VisualTestContext};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
Pixels, Render, Task, TestAppContext, VisualTestContext, Window, div, px,
};
use project::AgentId;
use project::Project;
use settings::SettingsStore;
use std::any::Any;
use std::cell::RefCell;
use std::rc::Rc;
use workspace::{MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent, SidebarSide};
use crate::AgentPanel;
use crate::agent_panel;
@ -109,6 +113,71 @@ pub fn init_test(cx: &mut TestAppContext) {
});
}
pub struct TestWorkspaceSidebar {
focus_handle: FocusHandle,
threads_list_active: bool,
}
impl TestWorkspaceSidebar {
fn new(threads_list_active: bool, cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
threads_list_active,
}
}
}
impl EventEmitter<SidebarEvent> for TestWorkspaceSidebar {}
impl Focusable for TestWorkspaceSidebar {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl WorkspaceSidebar for TestWorkspaceSidebar {
fn width(&self, _cx: &App) -> Pixels {
px(300.)
}
fn set_width(&mut self, _width: Option<Pixels>, _cx: &mut Context<Self>) {}
fn has_notifications(&self, _cx: &App) -> bool {
false
}
fn side(&self, _cx: &App) -> SidebarSide {
SidebarSide::Left
}
fn is_threads_list_view_active(&self) -> bool {
self.threads_list_active
}
}
impl Render for TestWorkspaceSidebar {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
pub fn register_test_sidebar(
threads_list_active: bool,
cx: &mut VisualTestContext,
) -> Entity<TestWorkspaceSidebar> {
cx.update(|window, cx| {
let multi_workspace = window
.root::<MultiWorkspace>()
.flatten()
.expect("test window should have a MultiWorkspace root");
let sidebar = cx.new(|cx| TestWorkspaceSidebar::new(threads_list_active, cx));
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.register_sidebar(sidebar.clone(), cx);
});
sidebar
})
}
pub fn open_thread_with_connection(
panel: &Entity<AgentPanel>,
connection: StubAgentConnection,

View file

@ -347,6 +347,20 @@ impl ThreadMetadata {
pub fn main_worktree_paths(&self) -> &PathList {
self.worktree_paths.main_worktree_path_list()
}
pub fn references_folder_path(&self, path: &Path) -> bool {
self.folder_paths()
.paths()
.iter()
.any(|folder_path| folder_path.as_path() == path)
}
pub fn matches_remote_connection(
&self,
remote_connection: Option<&RemoteConnectionOptions>,
) -> bool {
same_remote_connection_identity(self.remote_connection.as_ref(), remote_connection)
}
}
/// Derives worktree display info from a thread's stored path list.
@ -587,6 +601,12 @@ impl ThreadMetadataStore {
self.threads.values()
}
pub fn reload_task(&self) -> Shared<Task<()>> {
self.reload_task
.clone()
.unwrap_or_else(|| Task::ready(()).shared())
}
/// Returns all archived threads.
pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
self.entries().filter(|t| t.archived)
@ -609,9 +629,7 @@ impl ThreadMetadataStore {
.flatten()
.filter_map(|s| self.threads.get(s))
.filter(|s| !s.archived)
.filter(move |s| {
same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection)
})
.filter(move |s| s.matches_remote_connection(remote_connection))
}
/// Returns threads whose `main_worktree_paths` matches the given path list
@ -633,9 +651,7 @@ impl ThreadMetadataStore {
.flatten()
.filter_map(|s| self.threads.get(s))
.filter(|s| !s.archived)
.filter(move |s| {
same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection)
})
.filter(move |s| s.matches_remote_connection(remote_connection))
}
fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
@ -852,19 +868,28 @@ impl ThreadMetadataStore {
thread_id: Option<ThreadId>,
path: &Path,
remote_connection: Option<&RemoteConnectionOptions>,
) -> bool {
self.path_is_referenced_by_unarchived_threads_matching(
thread_id,
path,
remote_connection,
|_| true,
)
}
pub fn path_is_referenced_by_unarchived_threads_matching(
&self,
thread_id: Option<ThreadId>,
path: &Path,
remote_connection: Option<&RemoteConnectionOptions>,
matches: impl Fn(&ThreadMetadata) -> bool,
) -> bool {
self.entries().any(|thread| {
Some(thread.thread_id) != thread_id
&& !thread.archived
&& same_remote_connection_identity(
thread.remote_connection.as_ref(),
remote_connection,
)
&& thread
.folder_paths()
.paths()
.iter()
.any(|other_path| other_path.as_path() == path)
&& thread.matches_remote_connection(remote_connection)
&& thread.references_folder_path(path)
&& matches(thread)
})
}
@ -1117,6 +1142,26 @@ impl ThreadMetadataStore {
cx.notify();
}
pub fn unarchived_draft_ids_matching(
&self,
matches: impl Fn(&ThreadMetadata) -> bool,
) -> Vec<ThreadId> {
self.entries()
.filter(|thread| thread.is_draft() && !thread.archived && matches(thread))
.map(|thread| thread.thread_id)
.collect()
}
pub fn delete_all(
&mut self,
thread_ids: impl IntoIterator<Item = ThreadId>,
cx: &mut Context<Self>,
) {
for thread_id in thread_ids {
self.delete(thread_id, cx);
}
}
fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
let weak_store = cx.weak_entity();

View file

@ -831,6 +831,27 @@ pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
.collect()
}
pub fn workspaces_for_archive(
multi_workspace: Option<&Entity<MultiWorkspace>>,
cx: &App,
) -> Vec<Entity<Workspace>> {
let mut workspaces = multi_workspace
.map(|multi_workspace| {
multi_workspace
.read(cx)
.workspaces()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
for workspace in all_open_workspaces(cx) {
if !workspaces.contains(&workspace) {
workspaces.push(workspace);
}
}
workspaces
}
fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
cx.update(|cx| {
all_open_workspaces(cx)

View file

@ -3,7 +3,6 @@ mod end_trial_upsell;
mod hold_for_default;
mod mention_crease;
mod model_selector_components;
mod rules_to_skills_modal;
mod undo_reject_toast;
pub use agent_notification::*;
@ -11,7 +10,6 @@ pub use end_trial_upsell::*;
pub use hold_for_default::*;
pub use mention_crease::*;
pub use model_selector_components::*;
pub use rules_to_skills_modal::*;
pub use undo_reject_toast::*;
/// Returns the appropriate [`DocumentationSide`] for documentation asides

View file

@ -1,12 +1,13 @@
use std::{ops::RangeInclusive, path::PathBuf, time::Duration};
use std::{path::PathBuf, time::Duration};
use acp_thread::MentionUri;
use agent_client_protocol::schema as acp;
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use editor::Editor;
use gpui::{
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
pulsating_between,
};
use language::Buffer;
use prompt_store::PromptId;
use rope::Point;
use settings::Settings;
@ -14,6 +15,8 @@ use theme_settings::ThemeSettings;
use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
use workspace::{OpenOptions, Workspace};
use crate::open_abs_path_at_point;
#[derive(IntoElement)]
pub struct MentionCrease {
id: ElementId,
@ -164,12 +167,27 @@ fn open_mention_uri(
abs_path,
line_range,
..
} => {
open_file(
workspace,
abs_path,
Some(Point::new(*line_range.start(), 0)),
window,
cx,
);
}
| MentionUri::Selection {
MentionUri::Selection {
abs_path: Some(abs_path),
line_range,
column,
} => {
open_file(workspace, abs_path, Some(line_range), window, cx);
open_file(
workspace,
abs_path,
Some(Point::new(*line_range.start(), column.unwrap_or(0))),
window,
cx,
);
}
MentionUri::Directory { abs_path } => {
reveal_in_project_panel(workspace, abs_path, cx);
@ -203,6 +221,46 @@ fn open_skill_file(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
// Built-in skills have synthetic paths that don't exist on disk.
// Open a read-only buffer with the embedded content instead.
//
// The buffer is intentionally not registered with the project's buffer
// store: it has no on-disk backing, isn't searchable, and `Project::
// create_local_buffer` panics for remote projects (SSH/collab), which
// would crash Zed if a user clicked a built-in skill mention while
// connected to a remote project.
if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) {
let languages = workspace.project().read(cx).languages().clone();
let buffer = cx.new(|cx| Buffer::local(content, cx));
// Set markdown highlighting asynchronously — the buffer
// opens instantly and the highlighting appears once loaded.
cx.spawn({
let buffer = buffer.clone();
async move |_, cx| {
if let Ok(markdown) = languages.language_for_name("Markdown").await {
buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx));
}
}
})
.detach();
let editor = cx.new(|cx| {
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_read_only(true);
let title = skill_file_path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "built-in skill".into());
editor
.buffer()
.update(cx, |buffer, cx| buffer.set_title(title, cx));
editor
});
let pane = workspace.active_pane().clone();
workspace.add_item(pane, Box::new(editor), None, true, true, window, cx);
return;
}
workspace
.open_abs_path(
skill_file_path,
@ -219,40 +277,23 @@ fn open_skill_file(
fn open_file(
workspace: &mut Workspace,
abs_path: PathBuf,
line_range: Option<RangeInclusive<u32>>,
point: Option<Point>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project();
if let Some(point) = point {
if open_abs_path_at_point(workspace, abs_path.clone(), point, window, cx) {
return;
}
}
let project = workspace.project();
if let Some(project_path) =
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
{
let item = workspace.open_path(project_path, None, true, window, cx);
if let Some(line_range) = line_range {
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
editor
.update_in(cx, |editor, window, cx| {
let range = Point::new(*line_range.start(), 0)
..Point::new(*line_range.start(), 0);
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|selections| selections.select_ranges(vec![range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
item.detach_and_log_err(cx);
}
workspace
.open_path(project_path, None, true, window, cx)
.detach_and_log_err(cx);
} else if abs_path.exists() {
workspace
.open_abs_path(

View file

@ -1,196 +0,0 @@
//! Mini modal shown when the user clicks the title-bar Skills
//! announcement banner. Renders one of two flavours depending on the
//! persisted [`MigrationResult`]:
//!
//! * **No rules migrated** (new user, or an existing user who never
//! touched Rules): a generic "Introducing Skills" intro that explains
//! what Skills are and how to invoke them.
//! * **Rules migrated**: a per-destination summary of exactly which
//! Rules ended up where (Skills directory, global AGENTS.md, top of
//! AGENTS.md for customized built-ins), capped at three names per
//! section with an "…and N more" overflow line.
use agent_skills::GLOBAL_SKILLS_DIR_DISPLAY;
use gpui::{
DismissEvent, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, Styled,
};
use paths::GLOBAL_AGENTS_FILE_DISPLAY;
use prompt_store::rules_to_skills_migration::{self, MigrationResult};
use ui::{
AlertModal, Button, ButtonCommon, ButtonStyle, Clickable, KeyBinding, ListBulletItem, h_flex,
prelude::*,
};
use workspace::{ModalView, Workspace};
/// Maximum number of rule names to list inline in the modal before
/// collapsing the rest into an "…and N more" line.
const MAX_LISTED_NAMES: usize = 3;
pub struct RulesToSkillsModal {
focus_handle: FocusHandle,
}
impl RulesToSkillsModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
workspace.toggle_modal(window, cx, |_window, cx| Self {
focus_handle: cx.focus_handle(),
});
}
fn dismiss(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl Focusable for RulesToSkillsModal {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for RulesToSkillsModal {}
impl ModalView for RulesToSkillsModal {}
impl Render for RulesToSkillsModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let result = rules_to_skills_migration::migration_result().unwrap_or_default();
let mut modal = AlertModal::new("rules-to-skills-migration")
.width(rems(28.))
.key_context("RulesToSkillsModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.dismiss(cx)))
.on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)));
if result.is_empty() {
modal = render_introducing_skills(modal);
} else {
modal = render_migration_summary(modal, &result);
}
// Both flavours close with the same invocation instructions.
modal = modal.child(Label::new(
"To include a Skill in a prompt, type /skill-name (or @-mention it).",
));
modal.footer(
h_flex().p_3().items_center().justify_end().child(
Button::new("got-it", "Got it")
.style(ButtonStyle::Filled)
.layer(ui::ElevationIndex::ModalSurface)
.key_binding(
KeyBinding::for_action(&menu::Confirm, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, _window, cx| {
this.dismiss(cx);
cx.stop_propagation();
})),
),
)
}
}
/// Render the modal body for users who had no Rules to migrate — a
/// generic introduction to the Skills feature.
fn render_introducing_skills(modal: AlertModal) -> AlertModal {
modal.title("Introducing Skills").child(Label::new(format!(
"Skills are reusable instructions for the agent, stored as Markdown files \
under {GLOBAL_SKILLS_DIR_DISPLAY}/<name>/SKILL.md."
)))
}
/// Render the modal body for users whose Rules were migrated, listing
/// each destination's contents (capped at [`MAX_LISTED_NAMES`] per
/// section).
fn render_migration_summary(mut modal: AlertModal, result: &MigrationResult) -> AlertModal {
modal = modal.title("Skills have replaced Rules");
if !result.skill_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Rules have been migrated to Skills in {GLOBAL_SKILLS_DIR_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.skill_names);
}
if !result.agents_md_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Default Rules were added to {GLOBAL_AGENTS_FILE_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.agents_md_names);
}
if !result.customized_builtins.is_empty() {
modal = modal.child(Label::new(customized_builtins_sentence(
&result.customized_builtins,
)));
}
modal
}
/// Append up to [`MAX_LISTED_NAMES`] bullet items naming individual
/// rules, plus a final "…and N more" bullet if the list is longer.
fn add_bulleted_names(mut modal: AlertModal, names: &[String]) -> AlertModal {
for name in names.iter().take(MAX_LISTED_NAMES) {
modal = modal.child(ListBulletItem::new(name.clone()));
}
if names.len() > MAX_LISTED_NAMES {
let extras = names.len() - MAX_LISTED_NAMES;
let label = if extras == 1 {
"…and 1 more".to_string()
} else {
format!("…and {extras} more")
};
modal = modal.child(ListBulletItem::new(label));
}
modal
}
/// Build the sentence describing any customized built-in prompts that
/// were prepended to AGENTS.md. Singular wording for the common
/// one-built-in case; comma-joined for the (currently hypothetical)
/// multi-built-in case.
fn customized_builtins_sentence(names: &[String]) -> String {
debug_assert!(
!names.is_empty(),
"caller should only invoke this for a non-empty list"
);
if names.len() == 1 {
format!(
"Your customization of the {name} built-in prompt has been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}.",
name = names[0],
)
} else {
format!(
"Your customizations of these built-in prompts have been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}: {joined}.",
joined = names.join(", "),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn customized_builtins_sentence_uses_singular_wording_for_one_item() {
let sentence = customized_builtins_sentence(&["Commit message".to_string()]);
assert!(sentence.contains("Your customization of the Commit message"));
assert!(sentence.contains("built-in prompt has been added"));
assert!(sentence.contains(GLOBAL_AGENTS_FILE_DISPLAY));
}
#[test]
fn customized_builtins_sentence_uses_plural_wording_for_multiple_items() {
let sentence = customized_builtins_sentence(&[
"Commit message".to_string(),
"Future Built-in".to_string(),
]);
assert!(sentence.contains("customizations of these built-in prompts"));
assert!(sentence.contains("Commit message, Future Built-in"));
}
}

View file

@ -12,22 +12,20 @@ workspace = true
path = "src/auto_update_ui.rs"
[dependencies]
agent_settings.workspace = true
agent_skills.workspace = true
anyhow.workspace = true
auto_update.workspace = true
client.workspace = true
db.workspace = true
editor.workspace = true
fs.workspace = true
gpui.workspace = true
markdown_preview.workspace = true
notifications.workspace = true
project.workspace = true
prompt_store.workspace = true
release_channel.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
telemetry.workspace = true
ui.workspace = true

View file

@ -1,31 +1,30 @@
use std::sync::Arc;
use agent_settings::{AgentSettings, WindowLayout};
use agent_skills::GLOBAL_SKILLS_DIR_DISPLAY;
use auto_update::{AutoUpdater, release_notes_url};
use client::zed_urls;
use db::kvp::Dismissable;
use editor::{Editor, MultiBuffer};
use fs::Fs;
use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TaskExt, Window, actions,
prelude::*,
};
use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
use notifications::status_toast::StatusToast;
use prompt_store::rules_to_skills_migration;
use release_channel::{AppVersion, ReleaseChannel};
use semver::Version;
use serde::Deserialize;
use settings::Settings as _;
use smol::io::AsyncReadExt;
use ui::{AnnouncementToast, ListBulletItem, ParallelAgentsIllustration, prelude::*};
use ui::{AnnouncementToast, ListBulletItem, SkillsIllustration, prelude::*};
use util::{ResultExt as _, maybe};
use workspace::{
FocusWorkspaceSidebar, Workspace,
Workspace,
notifications::{
ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification,
simple_message_notification::MessageNotification,
},
};
use zed_actions::{ShowUpdateNotification, assistant::FocusAgent};
use zed_actions::ShowUpdateNotification;
actions!(
auto_update,
@ -186,103 +185,57 @@ struct AnnouncementContent {
description: SharedString,
bullet_items: Vec<SharedString>,
primary_action_label: SharedString,
secondary_action_label: SharedString,
primary_action_url: Option<SharedString>,
primary_action_callback: Option<Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>>,
secondary_action_url: Option<SharedString>,
on_dismiss: Option<Arc<dyn Fn(&mut App) + Send + Sync>>,
}
struct ParallelAgentAnnouncement;
struct SkillsAnnouncement;
impl Dismissable for ParallelAgentAnnouncement {
const KEY: &'static str = "parallel-agent-announcement";
impl Dismissable for SkillsAnnouncement {
const KEY: &'static str = "skills_announcement_dismissed";
}
fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementContent> {
let version_with_parallel_agents = match ReleaseChannel::global(cx) {
ReleaseChannel::Stable => Version::new(0, 233, 0),
let version_with_skills = match ReleaseChannel::global(cx) {
ReleaseChannel::Stable => Version::new(1, 4, 0),
ReleaseChannel::Dev | ReleaseChannel::Nightly | ReleaseChannel::Preview => {
Version::new(0, 232, 0)
Version::new(1, 4, 0)
}
};
if *version >= version_with_parallel_agents
&& !ParallelAgentAnnouncement::dismissed(cx)
&& !project::DisableAiSettings::get_global(cx).disable_ai
{
let fs = <dyn Fs>::global(cx);
if *version >= version_with_skills && !SkillsAnnouncement::dismissed(cx) {
// Only mention the Rules → Skills migration if the user actually
// had Rules that got migrated. New users (and existing users who
// never created a Rule) would otherwise be confused by a bullet
// referring to "your rules" that don't exist.
let migrated_anything =
rules_to_skills_migration::migration_result().is_some_and(|result| !result.is_empty());
let mut bullet_items: Vec<SharedString> = Vec::with_capacity(3);
bullet_items
.push(format!("Skills live in {GLOBAL_SKILLS_DIR_DISPLAY}/<name>/SKILL.md").into());
if migrated_anything {
bullet_items.push(
"Default Rules are converted into your global AGENTS.md; all other rules become skills".into(),
);
}
bullet_items.push("Type / to manually invoke a skill".into());
Some(AnnouncementContent {
heading: "Introducing Parallel Agents".into(),
description: "Run multiple threads of your favorite agents simultaneously across projects in a new workspace layout, tailored for agentic workflows.".into(),
bullet_items: vec![
"Use your favorite agents in parallel".into(),
"Optionally isolate agents using worktrees".into(),
"Combine multiple projects in one window".into(),
],
primary_action_label: "Try Agentic Layout".into(),
heading: "Introducing Skills Support".into(),
description: "Extend the agent with focused instructions and domain knowledge.".into(),
bullet_items,
primary_action_label: "Try Now".into(),
secondary_action_label: "Read Documentation".into(),
primary_action_url: None,
primary_action_callback: Some(Arc::new(move |window, cx| {
let get_layout = AgentSettings::get_layout(cx);
let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_));
let update;
if !already_agent_layout {
update = Some(AgentSettings::set_layout(
WindowLayout::Agent(None),
fs.clone(),
cx,
));
} else {
update = None;
}
let revert_fs = fs.clone();
window
.spawn(cx, async move |cx| {
if let Some(update) = update {
update.await.ok();
}
cx.update(|window, cx| {
if !already_agent_layout {
if let Some(workspace) = Workspace::for_window(window, cx) {
let toast = StatusToast::new(
"You are in the new agentic layout!",
cx,
move |this, _cx| {
this.icon(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
.action("Revert", move |_window, cx| {
let _ = AgentSettings::set_layout(
get_layout.clone(),
revert_fs.clone(),
cx,
);
})
.auto_dismiss(false)
.dismiss_button(true)
},
);
workspace.update(cx, |workspace, cx| {
workspace.toggle_status_toast(toast, cx);
});
}
}
window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
window.dispatch_action(Box::new(FocusAgent), cx);
})
})
.detach();
window.dispatch_action(Box::new(zed_actions::assistant::FocusAgent), cx);
})),
on_dismiss: Some(Arc::new(|cx| {
ParallelAgentAnnouncement::set_dismissed(true, cx)
})),
secondary_action_url: Some("https://zed.dev/blog/".into()),
on_dismiss: Some(Arc::new(|cx| SkillsAnnouncement::set_dismissed(true, cx))),
secondary_action_url: Some(zed_urls::skills_docs(cx).into()),
})
} else {
None
@ -323,7 +276,7 @@ impl Notification for AnnouncementToastNotification {}
impl Render for AnnouncementToastNotification {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
AnnouncementToast::new()
.illustration(ParallelAgentsIllustration::new())
.illustration(SkillsIllustration::new())
.heading(self.content.heading.clone())
.description(self.content.description.clone())
.bullet_items(
@ -333,11 +286,12 @@ impl Render for AnnouncementToastNotification {
.map(|item| ListBulletItem::new(item.clone())),
)
.primary_action_label(self.content.primary_action_label.clone())
.secondary_action_label(self.content.secondary_action_label.clone())
.primary_on_click(cx.listener({
let url = self.content.primary_action_url.clone();
let callback = self.content.primary_action_callback.clone();
move |this, _, window, cx| {
telemetry::event!("Parallel Agent Announcement Main Click");
telemetry::event!("Skills Announcement Main Click");
if let Some(callback) = &callback {
callback(window, cx);
}
@ -350,14 +304,14 @@ impl Render for AnnouncementToastNotification {
.secondary_on_click(cx.listener({
let url = self.content.secondary_action_url.clone();
move |_, _, _window, cx| {
telemetry::event!("Parallel Agent Announcement Secondary Click");
telemetry::event!("Skills Announcement Secondary Click");
if let Some(url) = &url {
cx.open_url(url);
}
}
}))
.dismiss_on_click(cx.listener(|this, _, _window, cx| {
telemetry::event!("Parallel Agent Announcement Dismiss");
telemetry::event!("Skills Announcement Dismiss");
this.dismiss(cx);
}))
}

View file

@ -267,6 +267,10 @@ impl BufferDiffSnapshot {
.then(|| self.inner.base_text.text())
}
pub fn base_text_exists(&self) -> bool {
self.inner.base_text_exists
}
pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> {
self.secondary_diff.as_deref()
}

View file

@ -222,6 +222,7 @@ impl AnyActiveCall for ActiveCallEntity {
room::Event::LocalScreenShareStopped => {
Some(ActiveCallEvent::LocalScreenShareStopped)
}
room::Event::RoomLeft { .. } => Some(ActiveCallEvent::RoomLeft),
_ => None,
};
if let Some(event) = mapped {

View file

@ -27,7 +27,6 @@ cloud_api_types.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
credentials_provider.workspace = true
db.workspace = true
derive_more.workspace = true
feature_flags.workspace = true
fs.workspace = true

View file

@ -334,6 +334,7 @@ struct ClientState {
credentials: Option<Credentials>,
status: (watch::Sender<Status>, watch::Receiver<Status>),
_reconnect_task: Option<Task<()>>,
_cloud_connection_task: Option<Task<()>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@ -435,6 +436,7 @@ impl Default for ClientState {
credentials: None,
status: watch::channel_with(Status::SignedOut),
_reconnect_task: None,
_cloud_connection_task: None,
}
}
}
@ -607,6 +609,7 @@ impl Client {
pub fn teardown(&self) {
let mut state = self.state.write();
state._reconnect_task.take();
state._cloud_connection_task.take();
self.handler_set.lock().clear();
self.peer.teardown();
}
@ -724,6 +727,7 @@ impl Client {
Status::SignedOut | Status::UpgradeRequired => {
self.telemetry.set_authenticated_user_info(None, false);
state._reconnect_task.take();
state._cloud_connection_task.take();
}
_ => {}
}
@ -957,28 +961,56 @@ impl Client {
}
}
/// Establishes a WebSocket connection with Cloud for receiving updates from the server.
async fn connect_to_cloud(self: &Arc<Self>, cx: &AsyncApp) -> Result<()> {
/// Maintains a WebSocket connection with Cloud for receiving updates from the server.
///
/// The connection is re-established with exponential backoff if it drops or fails to
/// establish.
fn connect_to_cloud(self: &Arc<Self>, cx: &AsyncApp) {
let this = self.clone();
let task = cx.spawn(async move |cx| {
#[cfg(any(test, feature = "test-support"))]
let mut rng = StdRng::seed_from_u64(0);
#[cfg(not(any(test, feature = "test-support")))]
let mut rng = StdRng::from_os_rng();
let mut delay = INITIAL_RECONNECTION_DELAY;
loop {
match Self::run_cloud_connection(&this, cx).await {
Ok(()) => {
log::info!("cloud websocket disconnected, will reconnect");
delay = INITIAL_RECONNECTION_DELAY;
}
Err(err) => {
log::warn!(
"cloud websocket connect failed: {err:#}; retrying in {delay:?}"
);
}
}
let jitter = Duration::from_millis(rng.random_range(0..delay.as_millis() as u64));
cx.background_executor().timer(delay + jitter).await;
delay = cmp::min(delay * 2, MAX_RECONNECTION_DELAY);
}
});
self.state.write()._cloud_connection_task = Some(task);
}
/// Runs a single attempt of the cloud websocket connection, returning once the connection
/// closes (cleanly or otherwise) or fails to establish.
async fn run_cloud_connection(self: &Arc<Self>, cx: &mut AsyncApp) -> Result<()> {
let connect_task = cx.update({
let cloud_client = self.cloud_client.clone();
move |cx| cloud_client.connect(cx)
})?;
let connection = connect_task.await?;
let (mut messages, task) = cx.update(|cx| connection.spawn(cx));
task.detach();
let (mut messages, _cloud_io_task) = cx.update(|cx| connection.spawn(cx));
cx.spawn({
let this = self.clone();
async move |cx| {
while let Some(message) = messages.next().await {
if let Some(message) = message.log_err() {
this.handle_message_to_client(message, cx);
}
}
while let Some(message) = messages.next().await {
if let Some(message) = message.log_err() {
self.handle_message_to_client(message, cx);
}
})
.detach();
}
Ok(())
}
@ -1009,7 +1041,7 @@ impl Client {
let credentials = self.sign_in(try_provider, cx).await?;
self.connect_to_cloud(cx).await.log_err();
self.connect_to_cloud(cx);
cx.update(move |cx| {
cx.spawn({

View file

@ -4,19 +4,19 @@ use chrono::{DateTime, Utc};
use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{
GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo,
UpdateSystemSettingsBody,
};
use cloud_api_types::OrganizationConfiguration;
use cloud_llm_client::{
EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
};
use collections::{HashMap, HashSet, hash_map::Entry};
use db::kvp::KeyValueStore;
use derive_more::Deref;
use feature_flags::FeatureFlagAppExt;
use futures::{Future, StreamExt, channel::mpsc};
use gpui::{
App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, TaskExt,
WeakEntity,
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task,
TaskExt, WeakEntity,
};
use http_client::http::{HeaderMap, HeaderValue};
use postage::{sink::Sink, watch};
@ -28,8 +28,6 @@ use std::{
use text::ReplicaId;
use util::{ResultExt, TryFutureExt as _};
const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id";
pub type LegacyUserId = u64;
#[derive(
@ -708,24 +706,41 @@ impl UserStore {
&mut self,
organization: Arc<Organization>,
cx: &mut Context<Self>,
) {
) -> Task<Result<()>> {
let is_same_organization = self
.current_organization
.as_ref()
.is_some_and(|current| current.id == organization.id);
if !is_same_organization {
let organization_id = organization.id.0.to_string();
self.current_organization.replace(organization);
cx.emit(Event::OrganizationChanged);
cx.notify();
let kvp = KeyValueStore::global(cx);
db::write_and_log(cx, move || async move {
kvp.write_kvp(CURRENT_ORGANIZATION_ID_KEY.into(), organization_id)
.await
});
if is_same_organization {
return Task::ready(Ok(()));
}
let organization_id = organization.id.clone();
self.current_organization.replace(organization);
cx.emit(Event::OrganizationChanged);
cx.notify();
let Some(client) = self.client.upgrade() else {
return Task::ready(Ok(()));
};
let Some(system_id) = client.telemetry().system_id().map(|id| id.to_string()) else {
// Without a system ID we have no addressable target row on the
// server, so the selection stays purely session-local.
return Task::ready(Ok(()));
};
let cloud_client = client.cloud_client();
cx.background_spawn(async move {
let body = UpdateSystemSettingsBody {
selected_organization_id: Some(organization_id),
};
cloud_client
.update_system_settings(system_id, body)
.await
.context("failed to persist selected organization")?;
Ok(())
})
}
pub fn organizations(&self) -> &Vec<Arc<Organization>> {
@ -861,29 +876,15 @@ impl UserStore {
}
self.organizations = response.organizations.into_iter().map(Arc::new).collect();
let persisted_org_id = KeyValueStore::global(cx)
.read_kvp(CURRENT_ORGANIZATION_ID_KEY)
.log_err()
.flatten()
.map(|id| OrganizationId(Arc::from(id)));
self.current_organization = persisted_org_id
.and_then(|persisted_id| {
self.current_organization = response
.default_organization_id
.and_then(|default_organization_id| {
self.organizations
.iter()
.find(|org| org.id == persisted_id)
.find(|organization| organization.id == default_organization_id)
.cloned()
})
.or_else(|| {
response
.default_organization_id
.and_then(|default_organization_id| {
self.organizations
.iter()
.find(|organization| organization.id == default_organization_id)
.cloned()
})
})
.or_else(|| self.organizations.first().cloned());
self.plans_by_organization = response
.plans_by_organization

View file

@ -52,6 +52,10 @@ pub fn edit_prediction_docs(cx: &App) -> String {
)
}
pub fn skills_docs(cx: &App) -> String {
format!("{server_url}/docs/ai/skills", server_url = server_url(cx))
}
/// Returns the URL to Zed's ACP registry blog post.
pub fn acp_registry_blog(cx: &App) -> String {
format!(
@ -60,11 +64,6 @@ pub fn acp_registry_blog(cx: &App) -> String {
)
}
/// Returns the URL to Zed's Parallel Agents blog post.
pub fn parallel_agents_blog(cx: &App) -> String {
format!("{server_url}/blog", server_url = server_url(cx))
}
pub fn shared_agent_thread_url(session_id: &str) -> String {
format!("zed://agent/shared/{}", session_id)
}

View file

@ -239,6 +239,56 @@ impl CloudApiClient {
serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into()))
}
pub async fn update_system_settings(
&self,
system_id: String,
body: UpdateSystemSettingsBody,
) -> Result<SystemSettings, ClientApiError> {
let host = self.cloud_host();
let request_builder = Request::builder()
.method(Method::PATCH)
.uri(
self.http_client
.build_zed_cloud_url("/client/system_settings")
.map_err(ClientApiError::RequestBuildFailed)?
.as_ref(),
)
.header(ZED_SYSTEM_ID_HEADER_NAME, system_id);
let request = self.build_request(request_builder, Json(body))?;
let mut response = self.http_client.send(request).await.map_err(|source| {
ClientApiError::ConnectionFailed {
host: host.clone(),
source,
}
})?;
if !response.status().is_success() {
if response.status() == StatusCode::UNAUTHORIZED {
return Err(ClientApiError::Unauthorized);
}
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await.ok();
return Err(ClientApiError::ServerError {
host,
status: response.status(),
body,
});
}
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.map_err(|e| ClientApiError::InvalidResponse(e.into()))?;
serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into()))
}
pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result<bool> {
let request = build_request(
Request::builder().method(Method::GET).uri(

View file

@ -87,6 +87,16 @@ pub struct CreateLlmTokenResponse {
pub token: LlmToken,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
pub struct UpdateSystemSettingsBody {
pub selected_organization_id: Option<OrganizationId>,
}
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
pub struct SystemSettings {
pub selected_organization_id: Option<OrganizationId>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct SubmitAgentThreadFeedbackBody {
pub organization_id: Option<OrganizationId>,

View file

@ -42,8 +42,11 @@ pub enum ExtensionProvides {
Grammars,
LanguageServers,
ContextServers,
/// Deprecated
AgentServers,
/// Deprecated
SlashCommands,
/// Deprecated
IndexedDocsProviders,
Snippets,
DebugAdapters,

View file

@ -430,6 +430,48 @@ async fn test_auto_watch_is_disabled_when_following_collaborator(
});
}
#[gpui::test]
async fn test_auto_watch_is_disabled_when_leaving_call(
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);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, _cx| {
assert_eq!(
*workspace.auto_watch_state(),
AutoWatch::Active { watched_peer: None },
"auto-watch should be enabled after toggling on"
);
});
let active_call_a = user_a.read(ActiveCall::global);
active_call_a
.update(user_a, |call, cx| call.hang_up(cx))
.await
.unwrap();
executor.run_until_parked();
workspace_a.update(user_a, |workspace, _cx| {
assert_eq!(
*workspace.auto_watch_state(),
AutoWatch::Off,
"auto-watch should be off after leaving the call"
);
});
}
#[track_caller]
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
let has_shared_screen_tab = workspace

View file

@ -633,6 +633,26 @@ impl TokenResponse {
}
}
/// An OAuth token error response (RFC 6749 Section 5.2).
#[derive(Debug, Deserialize, PartialEq)]
pub struct OAuthTokenError {
pub error: String,
#[serde(default)]
pub error_description: Option<String>,
}
impl std::fmt::Display for OAuthTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "OAuth token error: {}", self.error)?;
if let Some(description) = &self.error_description {
write!(f, " ({description})")?;
}
Ok(())
}
}
impl std::error::Error for OAuthTokenError {}
/// Build the form-encoded body for an authorization code token exchange.
pub fn token_exchange_params(
code: &str,
@ -640,15 +660,20 @@ pub fn token_exchange_params(
redirect_uri: &str,
code_verifier: &str,
resource: &str,
client_secret: Option<&str>,
) -> Vec<(&'static str, String)> {
vec![
let mut params = vec![
("grant_type", "authorization_code".to_string()),
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
("client_id", client_id.to_string()),
("code_verifier", code_verifier.to_string()),
("resource", resource.to_string()),
]
];
if let Some(secret) = client_secret {
params.push(("client_secret", secret.to_string()));
}
params
}
/// Build the form-encoded body for a token refresh request.
@ -656,13 +681,18 @@ pub fn token_refresh_params(
refresh_token: &str,
client_id: &str,
resource: &str,
client_secret: Option<&str>,
) -> Vec<(&'static str, String)> {
vec![
let mut params = vec![
("grant_type", "refresh_token".to_string()),
("refresh_token", refresh_token.to_string()),
("client_id", client_id.to_string()),
("resource", resource.to_string()),
]
];
if let Some(secret) = client_secret {
params.push(("client_secret", secret.to_string()));
}
params
}
// -- DCR request body (RFC 7591) ---------------------------------------------
@ -782,6 +812,7 @@ pub async fn fetch_auth_server_metadata(
match fetch_json::<AuthServerMetadataResponse>(http_client, url).await {
Ok(response) => {
let reported_issuer = response.issuer.unwrap_or_else(|| issuer.clone());
if reported_issuer != *issuer {
bail!(
"Auth server metadata issuer mismatch: expected {}, got {}",
@ -844,15 +875,6 @@ pub async fn discover(
None => bail!("authorization server does not advertise code_challenge_methods_supported"),
}
// Verify there is at least one supported registration strategy before we
// present the server as ready to authenticate.
match determine_registration_strategy(&auth_server_metadata) {
ClientRegistrationStrategy::Cimd { .. } | ClientRegistrationStrategy::Dcr { .. } => {}
ClientRegistrationStrategy::Unavailable => {
bail!("authorization server supports neither CIMD nor DCR")
}
}
let scopes = select_scopes(www_authenticate, &resource_metadata);
Ok(OAuthDiscovery {
@ -956,8 +978,16 @@ pub async fn exchange_code(
redirect_uri: &str,
code_verifier: &str,
resource: &str,
client_secret: Option<&str>,
) -> Result<OAuthTokens> {
let params = token_exchange_params(code, client_id, redirect_uri, code_verifier, resource);
let params = token_exchange_params(
code,
client_id,
redirect_uri,
code_verifier,
resource,
client_secret,
);
post_token_request(http_client, &auth_server_metadata.token_endpoint, &params).await
}
@ -968,8 +998,9 @@ pub async fn refresh_tokens(
refresh_token: &str,
client_id: &str,
resource: &str,
client_secret: Option<&str>,
) -> Result<OAuthTokens> {
let params = token_refresh_params(refresh_token, client_id, resource);
let params = token_refresh_params(refresh_token, client_id, resource, client_secret);
post_token_request(http_client, token_endpoint, &params).await
}
@ -997,11 +1028,12 @@ async fn post_token_request(
if !response.status().is_success() {
let mut error_body = String::new();
response.body_mut().read_to_string(&mut error_body).await?;
bail!(
"token request failed with status {}: {}",
response.status(),
error_body
);
let status = response.status();
// Try to parse as an OAuth error response (RFC 6749 Section 5.2).
if let Ok(token_error) = serde_json::from_str::<OAuthTokenError>(&error_body) {
return Err(token_error.into());
}
bail!("token request failed with status {status}: {error_body}");
}
let mut response_body = String::new();
@ -1198,7 +1230,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
}
async fn try_refresh(&self) -> Result<bool> {
let (refresh_token, token_endpoint, resource, client_id) = {
let (refresh_token, token_endpoint, resource, client_id, client_secret) = {
let session = self.session.lock();
match session.tokens.refresh_token.clone() {
Some(refresh_token) => (
@ -1206,6 +1238,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
session.token_endpoint.clone(),
session.resource.clone(),
session.client_registration.client_id.clone(),
session.client_registration.client_secret.clone(),
),
None => return Ok(false),
}
@ -1219,6 +1252,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
&refresh_token,
&client_id,
&resource_str,
client_secret.as_deref(),
)
.await
{
@ -1801,6 +1835,7 @@ mod tests {
"http://127.0.0.1:5555/callback",
"verifier_123",
"https://mcp.example.com",
None,
);
let map: std::collections::HashMap<&str, &str> =
params.iter().map(|(k, v)| (*k, v.as_str())).collect();
@ -1815,8 +1850,12 @@ mod tests {
#[test]
fn test_token_refresh_params() {
let params =
token_refresh_params("refresh_token_abc", "client_xyz", "https://mcp.example.com");
let params = token_refresh_params(
"refresh_token_abc",
"client_xyz",
"https://mcp.example.com",
None,
);
let map: std::collections::HashMap<&str, &str> =
params.iter().map(|(k, v)| (*k, v.as_str())).collect();
@ -2422,6 +2461,7 @@ mod tests {
"http://127.0.0.1:9999/callback",
"verifier_abc",
"https://mcp.example.com",
None,
)
.await
.unwrap();
@ -2461,6 +2501,7 @@ mod tests {
"old_refresh_token",
CIMD_URL,
"https://mcp.example.com",
None,
)
.await
.unwrap();
@ -2497,11 +2538,21 @@ mod tests {
"http://127.0.0.1:1/callback",
"verifier",
"https://mcp.example.com",
None,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("400"));
let err = result.unwrap_err();
let token_error = err
.downcast_ref::<OAuthTokenError>()
.expect("expected OAuthTokenError");
assert_eq!(
*token_error,
OAuthTokenError {
error: "invalid_grant".into(),
error_description: None,
}
);
});
}

View file

@ -1413,10 +1413,7 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
.await;
if should_install {
node_runtime
.npm_install_packages(
paths::copilot_dir(),
&[(PACKAGE_NAME, &latest_version.to_string())],
)
.npm_install_latest_packages(paths::copilot_dir(), &[PACKAGE_NAME])
.await?;
}

View file

@ -240,6 +240,7 @@ impl DiagnosticBlock {
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click({

View file

@ -1,18 +1,24 @@
use crate::{StoredEvent, example_spec::ExampleSpec};
use crate::{
StoredEvent,
example_spec::{ExampleSpec, RecentFile},
};
use anyhow::Result;
use buffer_diff::BufferDiffSnapshot;
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use collections::HashMap;
use gpui::{App, Entity, Task};
use language::Buffer;
use project::{Project, WorktreeId};
use project::Project;
use std::{collections::hash_map, fmt::Write as _, ops::Range, path::Path, sync::Arc};
use text::{BufferSnapshot as TextBufferSnapshot, Point};
use text::Point;
pub fn capture_example(
project: Entity<Project>,
buffer: Entity<Buffer>,
cursor_anchor: language::Anchor,
mut events: Vec<StoredEvent>,
events: Vec<StoredEvent>,
recently_opened_files: Vec<RecentFile>,
recently_viewed_files: Vec<RecentFile>,
uncommitted_diffs_by_path: HashMap<Arc<Path>, Entity<BufferDiff>>,
populate_expected_patch: bool,
cx: &mut App,
) -> Option<Task<Result<ExampleSpec>>> {
@ -34,16 +40,39 @@ pub fn capture_example(
.or_else(|| repository_snapshot.remote_upstream_url.clone())?;
let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string();
let git_store = project.read(cx).git_store().clone();
Some(cx.spawn(async move |cx| {
let mut events = events;
let mut diff_buffers_by_path: HashMap<Arc<Path>, (Entity<Buffer>, Entity<BufferDiff>)> =
HashMap::default();
for stored_event in &events {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
let Some((project_path, relative_path)) = project.read_with(cx, |project, cx| {
let project_path = project
.find_project_path(path, cx)
.filter(|path| path.worktree_id == worktree_id)?;
let relative_path: Arc<Path> = project_path.path.as_std_path().into();
Some((project_path, relative_path))
}) else {
continue;
};
Some(cx.spawn(async move |mut cx| {
let snapshots_by_path =
collect_snapshots(&project, &git_store, worktree_id, &events, &mut cx).await?;
if let hash_map::Entry::Vacant(entry) = diff_buffers_by_path.entry(relative_path) {
let Some(diff) = uncommitted_diffs_by_path.get(entry.key()).cloned() else {
continue;
};
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})
.await?;
entry.insert((buffer, diff));
}
}
events.retain(|stored_event| {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
let relative_path = strip_root_name(path, &root_name);
snapshots_by_path.contains_key(relative_path)
diff_buffers_by_path.contains_key(relative_path)
});
let line_comment_prefix = snapshot
@ -56,9 +85,17 @@ pub fn capture_example(
.background_executor()
.spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) })
.await;
let uncommitted_diff_snapshots = diff_buffers_by_path
.into_iter()
.map(|(relative_path, (buffer, diff))| {
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx));
(relative_path, (snapshot, diff_snapshot))
})
.collect();
let uncommitted_diff = cx
.background_executor()
.spawn(async move { compute_uncommitted_diff(snapshots_by_path) })
.spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshots) })
.await;
let mut edit_history = String::new();
@ -68,6 +105,7 @@ pub fn capture_example(
edit_history.push('\n');
}
}
let uncommitted_diff_contains_edit_history = !edit_history.is_empty();
// Initialize an empty patch with context lines, to make it easy
// to write the expected patch by hand.
@ -100,6 +138,9 @@ pub fn capture_example(
tags: Vec::new(),
reasoning: None,
uncommitted_diff,
recently_opened_files,
recently_viewed_files,
uncommitted_diff_contains_edit_history,
cursor_path,
cursor_position: String::new(),
edit_history,
@ -184,63 +225,107 @@ fn compute_cursor_excerpt(
)
}
async fn collect_snapshots(
project: &Entity<Project>,
git_store: &Entity<project::git_store::GitStore>,
worktree_id: WorktreeId,
events: &[StoredEvent],
cx: &mut gpui::AsyncApp,
) -> Result<HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>> {
let mut snapshots_by_path = HashMap::default();
for stored_event in events {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
if let Some((project_path, relative_path)) = project.read_with(cx, |project, cx| {
let project_path = project
.find_project_path(path, cx)
.filter(|path| path.worktree_id == worktree_id)?;
let relative_path: Arc<Path> = project_path.path.as_std_path().into();
Some((project_path, relative_path))
}) {
if let hash_map::Entry::Vacant(entry) = snapshots_by_path.entry(relative_path) {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})
.await?;
let diff = git_store
.update(cx, |git_store, cx| {
git_store.open_uncommitted_diff(buffer.clone(), cx)
})
.await?;
let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx));
entry.insert((stored_event.old_snapshot.clone(), diff_snapshot));
}
}
}
Ok(snapshots_by_path)
}
fn compute_uncommitted_diff(
snapshots_by_path: HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>,
snapshots_by_path: HashMap<Arc<Path>, (language::BufferSnapshot, BufferDiffSnapshot)>,
) -> String {
let mut uncommitted_diff = String::new();
for (relative_path, (before_text, diff_snapshot)) in snapshots_by_path {
if let Some(head_text) = &diff_snapshot.base_text_string() {
let file_diff = language::unified_diff(head_text, &before_text.text());
if !file_diff.is_empty() {
let path_str = relative_path.to_string_lossy();
writeln!(uncommitted_diff, "--- a/{path_str}").ok();
writeln!(uncommitted_diff, "+++ b/{path_str}").ok();
uncommitted_diff.push_str(&file_diff);
if !uncommitted_diff.ends_with('\n') {
uncommitted_diff.push('\n');
}
let mut snapshots_by_path = snapshots_by_path.into_iter().collect::<Vec<_>>();
snapshots_by_path.sort_by(|(left_path, _), (right_path, _)| left_path.cmp(right_path));
for (relative_path, (buffer_snapshot, diff_snapshot)) in snapshots_by_path {
let base_snapshot = diff_snapshot.base_text();
let is_existing_file = diff_snapshot.base_text_exists();
let new_path_str = relative_path.to_string_lossy();
let old_path_str = if is_existing_file {
new_path_str.as_ref()
} else {
"/dev/null"
};
writeln!(
uncommitted_diff,
"--- {}{old_path_str}",
if is_existing_file { "a/" } else { "" }
)
.ok();
writeln!(uncommitted_diff, "+++ b/{new_path_str}").ok();
if !is_existing_file {
let new_text = buffer_snapshot.text();
writeln!(
uncommitted_diff,
"@@ -0,0 +1,{} @@",
new_text.lines().count()
)
.ok();
for line in new_text.lines() {
writeln!(uncommitted_diff, "+{line}").ok();
}
continue;
}
let mut ranges: Vec<(Range<u32>, Range<u32>)> = Vec::new();
for hunk in (&diff_snapshot).hunks(&buffer_snapshot) {
let old_start = base_snapshot
.offset_to_point(hunk.diff_base_byte_range.start)
.row;
let old_end =
exclusive_end_row(base_snapshot.offset_to_point(hunk.diff_base_byte_range.end));
let new_start = hunk.range.start.row;
let new_end = exclusive_end_row(hunk.range.end);
let old_range = old_start.saturating_sub(3)..old_end + 3;
let new_range = new_start.saturating_sub(3)..new_end + 3;
if let Some((last_old_range, last_new_range)) = ranges.last_mut()
&& (old_range.start <= last_old_range.end || new_range.start <= last_new_range.end)
{
last_old_range.end = last_old_range.end.max(old_range.end);
last_new_range.end = last_new_range.end.max(new_range.end);
continue;
}
ranges.push((old_range, new_range));
}
for (old_range, new_range) in ranges {
uncommitted_diff.push_str(&language::unified_diff_with_offsets(
&base_snapshot
.text_for_range(
Point::new(old_range.start, 0)
..row_start_or_max(base_snapshot, old_range.end),
)
.collect::<String>(),
&buffer_snapshot
.text_for_range(
Point::new(new_range.start, 0)
..row_start_or_max(&buffer_snapshot, new_range.end),
)
.collect::<String>(),
old_range.start,
new_range.start,
));
}
if !uncommitted_diff.ends_with('\n') {
uncommitted_diff.push('\n');
}
}
uncommitted_diff
}
fn row_start_or_max(snapshot: &language::BufferSnapshot, row: u32) -> Point {
if row >= snapshot.max_point().row {
snapshot.max_point()
} else {
Point::new(row, 0)
}
}
fn exclusive_end_row(point: Point) -> u32 {
if point.column == 0 {
point.row
} else {
point.row + 1
}
}
fn generate_timestamp_name() -> String {
let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
match format {
@ -309,7 +394,9 @@ mod tests {
json!({
".git": {},
"src": {
"deleted.rs": "pub fn deleted_file() {\n deleted();\n}\n",
"main.rs": disk_contents,
"new.rs": "pub fn new_file() {\n}\n",
}
}),
)
@ -326,7 +413,13 @@ mod tests {
fs.set_head_for_repo(
Path::new("/project/.git"),
&[("src/main.rs", committed_contents.to_string())],
&[
(
"src/deleted.rs",
"pub fn deleted_file() {\n deleted();\n}\n".to_string(),
),
("src/main.rs", committed_contents.to_string()),
],
"abc123def456",
);
fs.set_remote_for_repo(
@ -350,6 +443,21 @@ mod tests {
});
cx.run_until_parked();
let deleted_file_buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/project/src/deleted.rs", cx)
})
.await
.unwrap();
ep_store.update(cx, |ep_store, cx| {
ep_store.register_buffer(&deleted_file_buffer, &project, cx)
});
cx.run_until_parked();
deleted_file_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "")], None, cx);
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
let point = Point::new(6, 0);
buffer.edit([(point..point, " // comment 3\n")], None, cx);
@ -379,6 +487,22 @@ mod tests {
});
cx.run_until_parked();
let new_file_buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/project/src/new.rs", cx)
})
.await
.unwrap();
ep_store.update(cx, |ep_store, cx| {
ep_store.register_buffer(&new_file_buffer, &project, cx)
});
cx.run_until_parked();
new_file_buffer.update(cx, |buffer, cx| {
let point = Point::new(1, 0);
buffer.edit([(point..point, " created();\n")], None, cx);
});
cx.run_until_parked();
// Open and edit an external file (outside the main project's worktree)
let external_buffer = project
.update(cx, |project, cx| {
@ -410,6 +534,13 @@ mod tests {
"external file edit should be in events"
);
let worktree_id = buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx));
let uncommitted_diffs_by_path = ep_store
.update(cx, |store, cx| {
store.uncommitted_diffs_for_events(project.clone(), worktree_id, events.clone(), cx)
})
.await
.unwrap();
let mut example = cx
.update(|cx| {
capture_example(
@ -417,6 +548,9 @@ mod tests {
buffer.clone(),
Anchor::min_for_buffer(buffer.read(cx).remote_id()),
events,
Vec::new(),
Vec::new(),
uncommitted_diffs_by_path,
true,
cx,
)
@ -435,23 +569,41 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: indoc! {"
--- a/src/deleted.rs
+++ b/src/deleted.rs
@@ -1,3 +1,0 @@
-pub fn deleted_file() {
- deleted();
-}
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
@@ -1,11 +1,15 @@
fn main() {
+ // comment 1
one();
two();
+ // comment 4
three();
@@ -7,5 +8,6 @@
four();
+ // comment 3
five();
six();
seven();
eight();
+ // comment 2
nine();
}
--- /dev/null
+++ b/src/new.rs
@@ -0,0 +1,3 @@
+pub fn new_file() {
+ created();
+}
"}
.to_string(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: true,
cursor_path: Path::new("src/main.rs").into(),
cursor_position: indoc! {"
fn main() {
@ -473,6 +625,12 @@ mod tests {
"}
.to_string(),
edit_history: indoc! {"
--- a/src/deleted.rs
+++ b/src/deleted.rs
@@ -1,3 +1,0 @@
-pub fn deleted_file() {
- deleted();
-}
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,8 +2,10 @@
@ -486,6 +644,12 @@ mod tests {
five();
six();
seven();
--- a/src/new.rs
+++ b/src/new.rs
@@ -1,2 +1,3 @@
pub fn new_file() {
+ created();
}
"}
.to_string(),
expected_patches: vec![

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use buffer_diff::BufferDiff;
use client::{Client, EditPredictionUsage, UserStore, global_llm_token};
use cloud_api_client::LlmApiToken;
use cloud_api_types::{
@ -88,6 +89,7 @@ mod edit_prediction_tests;
use crate::cursor_excerpt::expand_context_syntactically_then_linewise;
use crate::example_spec::ExampleSpec;
use crate::example_spec::RecentFile;
use crate::license_detection::LicenseDetectionWatcher;
use crate::mercury::Mercury;
pub use crate::metrics::{KeptRateResult, compute_kept_rate};
@ -95,7 +97,6 @@ use crate::onboarding_modal::ZedPredictModal;
pub use crate::prediction::EditPrediction;
pub use crate::prediction::EditPredictionId;
use crate::prediction::EditPredictionResult;
pub use capture_example::capture_example;
pub use language_model::ApiKeyState;
pub use telemetry_events::EditPredictionRating;
pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@ -238,6 +239,7 @@ pub struct StoredEvent {
pub old_snapshot: TextBufferSnapshot,
pub new_snapshot_version: clock::Global,
pub total_edit_range: Range<Anchor>,
pub uncommitted_diff: Option<Entity<BufferDiff>>,
}
impl StoredEvent {
@ -572,6 +574,7 @@ impl LastEvent {
new_snapshot_version: self.new_snapshot.version.clone(),
total_edit_range: self.new_snapshot.anchor_before(edit_range.start)
..self.new_snapshot.anchor_before(edit_range.end),
uncommitted_diff: None,
})
}
}
@ -1007,6 +1010,89 @@ impl EditPredictionStore {
.unwrap_or_default()
}
pub fn uncommitted_diffs_for_events(
&mut self,
project: Entity<Project>,
worktree_id: WorktreeId,
events: Vec<StoredEvent>,
cx: &mut Context<Self>,
) -> Task<Result<HashMap<Arc<Path>, Entity<BufferDiff>>>> {
let project_id = project.entity_id();
let git_store = project.read(cx).git_store().clone();
cx.spawn(async move |this, cx| {
let mut diffs_by_path = HashMap::default();
for stored_event in events.into_iter().rev() {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
let Some(project_path) = project.read_with(cx, |project, cx| {
project
.find_project_path(path, cx)
.filter(|path| path.worktree_id == worktree_id)
}) else {
continue;
};
let relative_path: Arc<Path> = project_path.path.as_std_path().into();
if diffs_by_path.contains_key(&relative_path) {
continue;
}
let old_snapshot_remote_id = stored_event.old_snapshot.remote_id();
let new_snapshot_version = stored_event.new_snapshot_version.clone();
let event_path = path.clone();
if let Some(diff) = stored_event.uncommitted_diff.or_else(|| {
this.update(cx, |store, _| {
store
.projects
.get(&project_id)?
.events
.iter()
.find(|event| {
event.old_snapshot.remote_id() == old_snapshot_remote_id
&& event.new_snapshot_version == new_snapshot_version
})?
.uncommitted_diff
.clone()
})
.log_err()
.flatten()
}) {
diffs_by_path.insert(relative_path, diff);
continue;
}
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await?;
let diff = git_store
.update(cx, |git_store, cx| {
git_store.open_uncommitted_diff(buffer.clone(), cx)
})
.await?;
this.update(cx, |store, _| {
let Some(project) = store.projects.get_mut(&project_id) else {
return;
};
let Some(target_index) = project.events.iter().position(|event| {
event.old_snapshot.remote_id() == old_snapshot_remote_id
&& event.new_snapshot_version == new_snapshot_version
}) else {
return;
};
for event in project.events.iter_mut().take(target_index + 1) {
let zeta_prompt::Event::BufferChange { path, .. } = event.event.as_ref();
if path == &event_path {
event.uncommitted_diff = None;
}
}
project.events[target_index].uncommitted_diff = Some(diff.clone());
})
.log_err();
diffs_by_path.insert(relative_path, diff);
}
Ok(diffs_by_path)
})
}
pub fn context_for_project<'a>(
&'a self,
project: &Entity<Project>,
@ -2475,6 +2561,7 @@ impl EditPredictionStore {
&& is_open_source
&& self.is_data_collection_enabled(cx)
&& matches!(self.edit_prediction_model, EditPredictionModel::Zeta);
let capture_worktree_id = snapshot.file().map(|file| file.worktree_id(cx));
let inputs = EditPredictionModelInput {
project: project.clone(),
buffer: active_buffer,
@ -2490,11 +2577,24 @@ impl EditPredictionStore {
is_open_source,
};
let capture_data = (can_collect_data && rand::random_ratio(1, 1000)).then(|| stored_events);
let task = match self.edit_prediction_model {
EditPredictionModel::Zeta => {
zeta::request_prediction_with_zeta(self, inputs, capture_data, cx)
let capture_events = (can_collect_data && rand::random_ratio(1, 10))
.then(|| capture_worktree_id)
.flatten()
.map(|worktree_id| {
(
stored_events.clone(),
self.uncommitted_diffs_for_events(
project.clone(),
worktree_id,
stored_events,
cx,
),
)
});
zeta::request_prediction_with_zeta(self, inputs, capture_events, cx)
}
EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx),
EditPredictionModel::Mercury => {
@ -2815,6 +2915,42 @@ impl EditPredictionStore {
project_state.recent_paths = paths.into_iter().collect();
}
pub fn recent_paths_for_project(
&self,
project: &Entity<Project>,
cx: &App,
) -> (Vec<RecentFile>, Vec<RecentFile>) {
let recently_opened_files = project
.read(cx)
.opened_buffers(cx)
.into_iter()
.filter_map(|buffer| {
let snapshot = buffer.read(cx).snapshot();
let file = snapshot.file()?;
Some(RecentFile {
path: file.path().as_std_path().into(),
cursor_position: None,
})
})
.collect();
let recently_viewed_files = self
.projects
.get(&project.entity_id())
.map(|project_state| {
project_state
.recent_paths
.iter()
.map(|path| RecentFile {
path: path.path.as_std_path().into(),
cursor_position: None,
})
.collect()
})
.unwrap_or_default();
(recently_opened_files, recently_viewed_files)
}
fn is_file_open_source(
&self,
project: &Entity<Project>,
@ -3035,6 +3171,7 @@ fn merge_trailing_events_if_needed(
new_snapshot_version: newest_snapshot.version.clone(),
total_edit_range: newest_snapshot.anchor_before(edit_range.start)
..newest_snapshot.anchor_before(edit_range.end),
uncommitted_diff: None,
},
};
events.truncate(events.len() - mergeable_count);

View file

@ -3603,6 +3603,9 @@ async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disable
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Path::new("foo.md").into(),
cursor_position: "0".to_string(),
edit_history: "sensitive edit history".to_string(),

View file

@ -13,6 +13,13 @@ pub const INLINE_CURSOR_MARKER: &str = "<|user_cursor|>";
/// falling back to git-based loading.
pub const MAX_CURSOR_FILE_SIZE: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RecentFile {
pub path: Arc<Path>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor_position: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)]
pub struct ExampleSpec {
#[serde(default)]
@ -25,6 +32,12 @@ pub struct ExampleSpec {
pub reasoning: Option<String>,
#[serde(default)]
pub uncommitted_diff: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recently_opened_files: Vec<RecentFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recently_viewed_files: Vec<RecentFile>,
#[serde(default, skip_serializing_if = "is_false")]
pub uncommitted_diff_contains_edit_history: bool,
pub cursor_path: Arc<Path>,
pub cursor_position: String,
pub edit_history: String,
@ -56,18 +69,62 @@ pub struct TelemetrySource {
const REASONING_HEADING: &str = "Reasoning";
const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff";
const RECENTLY_OPENED_FILES_HEADING: &str = "Recently Opened Files";
const RECENTLY_VIEWED_FILES_HEADING: &str = "Recently Viewed Files";
const EDIT_HISTORY_HEADING: &str = "Edit History";
const CURSOR_POSITION_HEADING: &str = "Cursor Position";
const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
const REJECTED_PATCH_HEADING: &str = "Rejected Patch";
const ACCEPTED_PREDICTION_MARKER: &str = "// User accepted prediction:";
fn write_path_list(markdown: &mut String, heading: &str, files: &[RecentFile]) {
if files.is_empty() {
return;
}
_ = writeln!(markdown, "## {heading}");
_ = writeln!(markdown);
_ = writeln!(markdown, "```");
for file in files {
_ = write!(markdown, "{}", file.path.display());
if let Some(position) = file.cursor_position {
_ = write!(markdown, "\t{position}");
}
_ = writeln!(markdown);
}
_ = writeln!(markdown, "```");
markdown.push('\n');
}
fn parse_path_list(text: &str) -> Vec<RecentFile> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
let (path, cursor_position) = line
.rsplit_once('\t')
.map(|(path, position)| (path, position.parse().ok()))
.unwrap_or((line, None));
RecentFile {
path: Path::new(path).into(),
cursor_position,
}
})
.collect()
}
#[derive(Serialize, Deserialize)]
struct FrontMatter<'a> {
repository_url: Cow<'a, str>,
revision: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
uncommitted_diff_requires_edit_history_rollback: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
impl ExampleSpec {
@ -91,6 +148,8 @@ impl ExampleSpec {
repository_url: Cow::Borrowed(&self.repository_url),
revision: Cow::Borrowed(&self.revision),
tags: self.tags.clone(),
uncommitted_diff_requires_edit_history_rollback: self
.uncommitted_diff_contains_edit_history,
};
let front_matter_toml =
toml::to_string_pretty(&front_matter).unwrap_or_else(|_| String::new());
@ -130,6 +189,17 @@ impl ExampleSpec {
markdown.push('\n');
}
write_path_list(
&mut markdown,
RECENTLY_OPENED_FILES_HEADING,
&self.recently_opened_files,
);
write_path_list(
&mut markdown,
RECENTLY_VIEWED_FILES_HEADING,
&self.recently_viewed_files,
);
_ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING);
_ = writeln!(markdown);
@ -194,6 +264,9 @@ impl ExampleSpec {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Path::new("").into(),
cursor_position: String::new(),
edit_history: String::new(),
@ -211,6 +284,8 @@ impl ExampleSpec {
spec.repository_url = data.repository_url.into_owned();
spec.revision = data.revision.into_owned();
spec.tags = data.tags;
spec.uncommitted_diff_contains_edit_history =
data.uncommitted_diff_requires_edit_history_rollback;
}
input = rest.trim_start();
}
@ -223,6 +298,8 @@ impl ExampleSpec {
enum Section {
Start,
UncommittedDiff,
RecentlyOpenedFiles,
RecentlyViewedFiles,
EditHistory,
CursorPosition,
ExpectedPatch,
@ -245,6 +322,10 @@ impl ExampleSpec {
let title = mem::take(&mut text);
current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
Section::UncommittedDiff
} else if title.eq_ignore_ascii_case(RECENTLY_OPENED_FILES_HEADING) {
Section::RecentlyOpenedFiles
} else if title.eq_ignore_ascii_case(RECENTLY_VIEWED_FILES_HEADING) {
Section::RecentlyViewedFiles
} else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
Section::EditHistory
} else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
@ -292,6 +373,14 @@ impl ExampleSpec {
Section::UncommittedDiff => {
spec.uncommitted_diff = mem::take(&mut text);
}
Section::RecentlyOpenedFiles => {
spec.recently_opened_files = parse_path_list(&text);
text.clear();
}
Section::RecentlyViewedFiles => {
spec.recently_viewed_files = parse_path_list(&text);
text.clear();
}
Section::EditHistory => {
if next_edit_predicted {
spec.edit_history
@ -481,6 +570,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Path::new("test.rs").into(),
cursor_position: String::new(),
edit_history: String::new(),
@ -617,6 +709,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Path::new("test.rs").into(),
cursor_position: String::new(),
edit_history: String::new(),
@ -689,6 +784,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Path::new("test.rs").into(),
cursor_position: String::new(),
edit_history: String::new(),

View file

@ -1,11 +1,12 @@
use crate::{
CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId,
EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent,
EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore,
ZedUpdateRequiredError, buffer_path_with_id_fallback,
cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges},
prediction::EditPredictionResult,
};
use anyhow::Result;
use buffer_diff::BufferDiff;
use cloud_llm_client::{
AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
};
@ -48,7 +49,10 @@ pub fn request_prediction_with_zeta(
is_open_source,
..
}: EditPredictionModelInput,
capture_data: Option<Vec<StoredEvent>>,
capture_data: Option<(
Vec<crate::StoredEvent>,
Task<Result<collections::HashMap<Arc<Path>, Entity<BufferDiff>>>>,
)>,
cx: &mut Context<EditPredictionStore>,
) -> Task<Result<Option<EditPredictionResult>>> {
let settings = &all_language_settings(None, cx).edit_predictions;
@ -412,17 +416,33 @@ pub fn request_prediction_with_zeta(
let editable_range_in_buffer = editable_range_in_buffer.clone();
let edit_preview = prediction.edit_preview.clone();
let model_version = prediction.model_version.clone();
let example_task = capture_data.and_then(|stored_events| {
cx.update(|cx| {
crate::capture_example(
project.clone(),
edited_buffer.clone(),
position,
stored_events,
false,
cx,
)
})
let example_task = capture_data.and_then(|(events, uncommitted_diffs)| {
let (recently_opened_files, recently_viewed_files) = this
.read_with(cx, |this, cx| this.recent_paths_for_project(&project, cx))
.ok()?;
Some(cx.spawn({
let project = project.clone();
let edited_buffer = edited_buffer.clone();
async move |cx| {
let uncommitted_diffs = uncommitted_diffs.await?;
let Some(task) = cx.update(|cx| {
crate::capture_example::capture_example(
project.clone(),
edited_buffer.clone(),
position,
events,
recently_opened_files,
recently_viewed_files,
uncommitted_diffs,
false,
cx,
)
}) else {
return Err(anyhow::anyhow!("failed to capture example"));
};
task.await
}
}))
});
cx.spawn(async move |cx| {
let example_spec = if let Some(task) = example_task {

View file

@ -854,6 +854,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
cursor_position: "0:0".to_string(),
edit_history: String::new(),
@ -933,6 +936,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
cursor_position: "0:0".to_string(),
edit_history: String::new(),

View file

@ -1754,6 +1754,9 @@ fn build_example_from_snowflake(
tags,
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: input.cursor_path.clone(),
cursor_position: build_cursor_position(cursor_excerpt, cursor_offset),
edit_history,

View file

@ -549,6 +549,9 @@ mod tests {
tags: Vec::new(),
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Arc::from(Path::new("src/main.rs")),
cursor_position: "0:0".to_string(),
edit_history: String::new(),

View file

@ -370,6 +370,9 @@ pub fn generate_evaluation_example_from_ordered_commit(
tags: vec![],
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
rejected_patch: None,
telemetry: None,
@ -1369,6 +1372,9 @@ Date: Mon Jan 1 00:00:00 2024
tags: vec![],
reasoning: None,
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
rejected_patch: None,
telemetry: None,

View file

@ -790,6 +790,9 @@ async fn build_example(
tags: Vec::new(),
reasoning: Some(reasoning_with_source),
uncommitted_diff: String::new(),
recently_opened_files: Vec::new(),
recently_viewed_files: Vec::new(),
uncommitted_diff_contains_edit_history: false,
cursor_path: Arc::from(Path::new(&cursor_file)),
cursor_position: String::new(),
edit_history,

View file

@ -286,7 +286,11 @@ impl RelatedExcerptStore {
let buffer = buffer.upgrade()?;
let definitions = project
.update(cx, |project, cx| {
project.definitions(&buffer, identifier.range.start, cx)
project.workspace_definitions(
&buffer,
identifier.range.start,
cx,
)
})
.ok()?;
let type_definitions = project
@ -296,7 +300,11 @@ impl RelatedExcerptStore {
if is_tombi_lsp_in_toml(project, &buffer, cx) {
return Task::ready(Ok(None));
}
project.type_definitions(&buffer, identifier.range.start, cx)
project.workspace_type_definitions(
&buffer,
identifier.range.start,
cx,
)
})
.ok()?;
Some((definitions, type_definitions))
@ -304,7 +312,6 @@ impl RelatedExcerptStore {
};
let cx = async_cx.clone();
let project = project.clone();
async move {
match task {
DefinitionTask::CacheHit(cache_entry) => {
@ -323,39 +330,39 @@ impl RelatedExcerptStore {
.flatten()
.unwrap_or_default();
Some(cx.update(|cx| {
let definitions: SmallVec<[CachedDefinition; 1]> =
definition_locations
.into_iter()
.filter_map(|location| {
process_definition(location, &project, cx)
})
.collect();
let definitions: SmallVec<[CachedDefinition; 1]> =
definition_locations
.into_iter()
.filter_map(|location| {
let mut cx = cx.clone();
process_definition(location, &mut cx)
})
.collect();
let type_definitions: SmallVec<[CachedDefinition; 1]> =
type_definition_locations
.into_iter()
.filter_map(|location| {
process_definition(location, &project, cx)
let type_definitions: SmallVec<[CachedDefinition; 1]> =
type_definition_locations
.into_iter()
.filter_map(|location| {
let mut cx = cx.clone();
process_definition(location, &mut cx)
})
.filter(|type_def| {
!definitions.iter().any(|def| {
def.buffer.entity_id()
== type_def.buffer.entity_id()
&& def.anchor_range == type_def.anchor_range
})
.filter(|type_def| {
!definitions.iter().any(|def| {
def.buffer.entity_id()
== type_def.buffer.entity_id()
&& def.anchor_range == type_def.anchor_range
})
})
.collect();
})
.collect();
(
identifier,
Arc::new(CacheEntry {
definitions,
type_definitions,
}),
Some(duration),
)
}))
Some((
identifier,
Arc::new(CacheEntry {
definitions,
type_definitions,
}),
Some(duration),
))
}
}
}
@ -581,34 +588,29 @@ use language::ToPoint as _;
const MAX_TARGET_LEN: usize = 128;
fn process_definition(
location: LocationLink,
project: &Entity<Project>,
cx: &mut App,
) -> Option<CachedDefinition> {
let buffer = location.target.buffer.read(cx);
let anchor_range = location.target.range;
let file = buffer.file()?;
let worktree = project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?;
if worktree.read(cx).is_single_file() {
return None;
}
// If the target range is large, it likely means we requested the definition of an entire module.
// For individual definitions, the target range should be small as it only covers the symbol.
let buffer = location.target.buffer.read(cx);
let target_len = anchor_range.to_offset(&buffer).len();
if target_len > MAX_TARGET_LEN {
return None;
}
Some(CachedDefinition {
path: ProjectPath {
fn process_definition(location: LocationLink, cx: &mut AsyncApp) -> Option<CachedDefinition> {
cx.update(|cx| {
let buffer = location.target.buffer;
let buffer_snapshot = buffer.read(cx);
let file = buffer_snapshot.file()?;
let path = ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
},
buffer: location.target.buffer,
anchor_range,
};
let anchor_range = location.target.range;
// If the target range is large, it likely means we requested the definition of an entire module.
// For individual definitions, the target range should be small as it only covers the symbol.
let target_len = anchor_range.to_offset(&buffer_snapshot).len();
if target_len > MAX_TARGET_LEN {
return None;
}
Some(CachedDefinition {
path,
buffer: buffer.clone(),
anchor_range,
})
})
}

View file

@ -42,9 +42,7 @@ use workspace::{
};
use zed_actions::{OpenBrowser, OpenSettingsAt};
use crate::{
CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag,
};
use crate::{RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag};
actions!(
edit_prediction,
@ -982,10 +980,7 @@ impl EditPredictionButton {
.context(editor_focus_handle)
.when(
cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
|this| {
this.action("Capture Prediction Example", CaptureExample.boxed_clone())
.action("Rate Predictions", RatePredictions.boxed_clone())
},
|this| this.action("Rate Predictions", RatePredictions.boxed_clone()),
);
}

View file

@ -3,13 +3,10 @@ mod edit_prediction_context_view;
mod rate_prediction_modal;
use command_palette_hooks::CommandPaletteFilter;
use edit_prediction::{EditPredictionStore, ResetOnboarding, capture_example};
use edit_prediction::ResetOnboarding;
use edit_prediction_context_view::EditPredictionContextView;
use editor::Editor;
use feature_flags::FeatureFlagAppExt as _;
use gpui::TaskExt;
use gpui::actions;
use language::language_settings::AllLanguageSettings;
use project::DisableAiSettings;
use rate_prediction_modal::RatePredictionsModal;
use settings::{Settings as _, SettingsStore};
@ -36,8 +33,6 @@ actions!(
[
/// Opens the rate completions modal.
RatePredictions,
/// Captures an ExampleSpec from the current editing session and opens it as Markdown.
CaptureExample,
]
);
@ -51,9 +46,6 @@ pub fn init(cx: &mut App) {
}
});
workspace.register_action(|workspace, _: &CaptureExample, window, cx| {
capture_example_as_markdown(workspace, window, cx);
});
workspace.register_action_renderer(|div, _, _, cx| {
div.on_action(cx.listener(
move |workspace, _: &OpenEditPredictionContextView, window, cx| {
@ -84,7 +76,6 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
let reset_onboarding_action_types = [TypeId::of::<ResetOnboarding>()];
let all_action_types = [
TypeId::of::<RatePredictions>(),
TypeId::of::<CaptureExample>(),
TypeId::of::<edit_prediction::ResetOnboarding>(),
zed_actions::OpenZedPredictOnboarding.type_id(),
TypeId::of::<edit_prediction::ClearHistory>(),
@ -131,68 +122,3 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
})
.detach();
}
fn capture_example_as_markdown(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Option<()> {
let markdown_language = workspace
.app_state()
.languages
.language_for_name("Markdown");
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
let editor = workspace.active_item_as::<Editor>(cx)?;
let editor = editor.read(cx);
let (buffer, cursor_anchor) = editor
.buffer()
.read(cx)
.text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?;
let ep_store = EditPredictionStore::try_global(cx)?;
let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx));
let example = capture_example(project.clone(), buffer, cursor_anchor, events, true, cx)?;
let examples_dir = AllLanguageSettings::get_global(cx)
.edit_predictions
.examples_dir
.clone();
cx.spawn_in(window, async move |workspace_entity, cx| {
let markdown_language = markdown_language.await?;
let example_spec = example.await?;
let buffer = if let Some(dir) = examples_dir {
fs.create_dir(&dir).await.ok();
let mut path = dir.join(&example_spec.name.replace(' ', "--").replace(':', "-"));
path.set_extension("md");
project
.update(cx, |project, cx| project.open_local_buffer(&path, cx))
.await?
} else {
project
.update(cx, |project, cx| {
project.create_buffer(Some(markdown_language.clone()), false, cx)
})
.await?
};
buffer.update(cx, |buffer, cx| {
buffer.set_text(example_spec.to_markdown(), cx);
buffer.set_language(Some(markdown_language), cx);
});
workspace_entity.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(
cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)),
),
None,
true,
window,
cx,
);
})
})
.detach_and_log_err(cx);
None
}

View file

@ -501,6 +501,9 @@ actions!(
ExpandAllDiffHunks,
/// Collapses all diff hunks in the editor.
CollapseAllDiffHunks,
/// Toggles all diff hunks in the editor. Collapses all hunks if any are
/// currently expanded, otherwise expands all hunks.
ToggleAllDiffHunks,
/// Expands macros recursively at cursor position.
ExpandMacroRecursively,
/// Finds the next match in the search.

View file

@ -1260,6 +1260,7 @@ impl CompletionsMenu {
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url),

View file

@ -6771,7 +6771,6 @@ impl Editor {
let mut new_selections = Vec::new();
let mut edits = Vec::new();
let mut selection_adjustment = 0isize;
for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) {
let selection_is_empty = selection.is_empty();
@ -6786,23 +6785,24 @@ impl Editor {
)
};
let text = buffer.text_for_range(start..end).collect::<String>();
let old_length = text.len() as isize;
let text = callback(&text);
let old_text = buffer.text_for_range(start..end).collect::<String>();
let new_text = callback(&old_text);
new_selections.push(Selection {
start: MultiBufferOffset((start.0 as isize - selection_adjustment) as usize),
end: MultiBufferOffset(
((start.0 + text.len()) as isize - selection_adjustment) as usize,
),
start: buffer.anchor_before(start),
end: buffer.anchor_after(end),
goal: SelectionGoal::None,
id: selection.id,
reversed: selection.reversed,
});
selection_adjustment += old_length - text.len() as isize;
if new_text != old_text {
edits.push((start..end, new_text));
}
}
edits.push((start..end, text));
if edits.is_empty() {
return;
}
self.transact(window, cx, |this, window, cx| {

View file

@ -6898,6 +6898,61 @@ async fn test_convert_to_base64(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
fn test_manipulate_text_handles_cross_excerpt_edit_that_applies_differently(
cx: &mut TestAppContext,
) {
init_test(cx, |_| {});
let buffer_1 = cx.new(|cx| {
let mut buffer = Buffer::local("ab", cx);
// The selected multibuffer range starts in this excerpt, but edits to
// it are skipped because the underlying buffer is read-only.
buffer.set_capability(language::Capability::ReadOnly, cx);
buffer
});
let buffer_2 = cx.new(|cx| Buffer::local("cd", cx));
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(ReadWrite);
multibuffer.set_excerpts_for_path(
PathKey::sorted(0),
buffer_1.clone(),
[Point::new(0, 0)..Point::new(0, 2)],
0,
cx,
);
multibuffer.set_excerpts_for_path(
PathKey::sorted(1),
buffer_2.clone(),
[Point::new(0, 0)..Point::new(0, 2)],
0,
cx,
);
multibuffer
});
cx.add_window(|window, cx| {
let mut editor = build_editor(multibuffer, window, cx);
let len = editor.buffer().read(cx).len(cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(0)..len])
});
// No-op transformations should not be sent through `MultiBuffer::edit`.
editor.manipulate_text(window, cx, |text| text.to_string());
assert_eq!(buffer_1.read(cx).text(), "ab");
assert_eq!(buffer_2.read(cx).text(), "cd");
// A real replacement can apply differently than requested; selection
// remapping should follow the actual edit instead of predicted offsets.
editor.manipulate_text(window, cx, |_| "replacement".to_string());
assert_eq!(buffer_1.read(cx).text(), "ab");
assert_eq!(buffer_2.read(cx).text(), "");
editor
});
}
#[gpui::test]
async fn test_manipulate_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});

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