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 CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1' RUST_BACKTRACE: '1'
CARGO_INCREMENTAL: '0' CARGO_INCREMENTAL: '0'
ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9
on: on:
workflow_call: workflow_call:
inputs: inputs:

View file

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

104
Cargo.lock generated
View file

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

View file

@ -172,6 +172,7 @@ members = [
"crates/rope", "crates/rope",
"crates/rpc", "crates/rpc",
"crates/rules_library", "crates/rules_library",
"crates/skill_creator",
"crates/scheduler", "crates/scheduler",
"crates/schema_generator", "crates/schema_generator",
"crates/search", "crates/search",
@ -432,6 +433,7 @@ rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9
rope = { path = "crates/rope" } rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" } rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" } rules_library = { path = "crates/rules_library" }
skill_creator = { path = "crates/skill_creator" }
scheduler = { path = "crates/scheduler" } scheduler = { path = "crates/scheduler" }
search = { path = "crates/search" } search = { path = "crates/search" }
session = { path = "crates/session" } session = { path = "crates/session" }
@ -500,7 +502,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
# External crates # External crates
# #
agent-client-protocol = { version = "=0.12.0", features = ["unstable"] } agent-client-protocol = { version = "=0.12.1", features = ["unstable"] }
aho-corasick = "1.1" aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
any_vec = "0.14" any_vec = "0.14"
@ -620,6 +622,7 @@ linkify = "0.10.0"
libwebrtc = "0.3.26" libwebrtc = "0.3.26"
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] } livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } 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" } lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" }
mach2 = "0.5" mach2 = "0.5"
markup5ever_rcdom = "0.3.0" markup5ever_rcdom = "0.3.0"
@ -762,7 +765,7 @@ toml_edit = { version = "0.22", default-features = false, features = [
"serde", "serde",
] } ] }
tower-http = "0.4.4" 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-bash = "0.25.1"
tree-sitter-c = "0.24.1" tree-sitter-c = "0.24.1"
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } 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" wasm-bindgen = "0.2.120"
web-time = "1.1.0" web-time = "1.1.0"
webrtc-sys = "0.3.23" 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" windows-core = "0.61"
yaml-rust2 = "0.8" yaml-rust2 = "0.8"
yawc = "0.2.5" 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"> <svg width="16" height="16" viewBox="0 0 16 16" 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="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 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"/> <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> </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"> <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 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.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.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.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.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.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.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="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.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.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.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.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.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="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.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.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.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.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.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="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.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.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.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.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 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> </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"> <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> </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"> <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> </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"> <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> </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"> <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> </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"> <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> </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"> <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)"> <mask id="mask0_4385_22072" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="2" width="12" height="12">
<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"/> <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> </g>
<defs>
<clipPath id="clip0_2716_663">
<rect width="12" height="12" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg> </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"> <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> </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"> <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="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.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="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="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 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> </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"> <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> </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", "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", "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", "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

@ -1056,8 +1056,8 @@
"ctrl-d": "git_graph::ScrollDown", "ctrl-d": "git_graph::ScrollDown",
"ctrl-u": "git_graph::ScrollUp", "ctrl-u": "git_graph::ScrollUp",
"shift-g": "menu::SelectLast", "shift-g": "menu::SelectLast",
"g g": "menu::SelectFirst" "g g": "menu::SelectFirst",
} },
}, },
{ {
"context": "GitPanel && ChangesList && !GitBranchSelector", "context": "GitPanel && ChangesList && !GitBranchSelector",
@ -1205,4 +1205,18 @@
"enter": "editor::Newline", "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, "agent_ui_font_size": null,
// The default font size for user messages in the agent panel. // The default font size for user messages in the agent panel.
"agent_buffer_font_size": 12, "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. // How much to fade out unused code.
"unnecessary_code_fade": 0.3, "unnecessary_code_fade": 0.3,
// Active pane styling settings. // Active pane styling settings.
@ -1133,6 +1135,7 @@
"spawn_agent": true, "spawn_agent": true,
"terminal": true, "terminal": true,
"update_plan": true, "update_plan": true,
"update_title": true,
"search_web": true, "search_web": true,
}, },
}, },
@ -1153,6 +1156,7 @@
"skill": true, "skill": true,
"spawn_agent": true, "spawn_agent": true,
"update_plan": true, "update_plan": true,
"update_title": true,
"search_web": true, "search_web": true,
}, },
}, },

View file

@ -13,7 +13,7 @@ path = "src/acp_thread.rs"
doctest = false doctest = false
[features] [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] [dependencies]
action_log.workspace = true action_log.workspace = true
@ -35,7 +35,7 @@ language_model.workspace = true
log.workspace = true log.workspace = true
markdown.workspace = true markdown.workspace = true
parking_lot = { workspace = true, optional = true } parking_lot = { workspace = true, optional = true }
image = { workspace = true, optional = true } image.workspace = true
portable-pty.workspace = true portable-pty.workspace = true
project.workspace = true project.workspace = true
prompt_store.workspace = true prompt_store.workspace = true

View file

@ -648,9 +648,16 @@ impl Display for ToolCallStatus {
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum ContentBlock { pub enum ContentBlock {
Empty, Empty,
Markdown { markdown: Entity<Markdown> }, Markdown {
ResourceLink { resource_link: acp::ResourceLink }, markdown: Entity<Markdown>,
Image { image: Arc<gpui::Image> }, },
ResourceLink {
resource_link: acp::ResourceLink,
},
Image {
image: Arc<gpui::Image>,
dimensions: Option<gpui::Size<u32>>,
},
} }
impl ContentBlock { impl ContentBlock {
@ -692,8 +699,8 @@ impl ContentBlock {
}; };
} }
(ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => { (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => {
if let Some(image) = Self::decode_image(image_content) { if let Some((image, dimensions)) = Self::decode_image(image_content) {
*self = ContentBlock::Image { image }; *self = ContentBlock::Image { image, dimensions };
} else { } else {
let new_content = Self::image_md(image_content); let new_content = Self::image_md(image_content);
*self = Self::create_markdown_block(new_content, language_registry, cx); *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 _; use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD let bytes = base64::engine::general_purpose::STANDARD
.decode(image_content.data.as_bytes()) .decode(image_content.data.as_bytes())
.ok()?; .ok()?;
let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?; 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( 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 { match self {
ContentBlock::Image { image } => Some(image), ContentBlock::Image { image, dimensions } => Some((image, *dimensions)),
_ => None, _ => 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 { match self {
Self::ContentBlock(content) => content.image(), Self::ContentBlock(content) => content.image(),
_ => None, _ => None,

View file

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

View file

@ -51,6 +51,8 @@ pub enum MentionUri {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
abs_path: Option<PathBuf>, abs_path: Option<PathBuf>,
line_range: RangeInclusive<u32>, line_range: RangeInclusive<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
column: Option<u32>,
}, },
Fetch { Fetch {
url: Url, url: Url,
@ -105,6 +107,17 @@ impl MentionUri {
Ok(start_line..=end_line) 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 parse_absolute_path = |input: &str| -> Result<Self> {
let (path_input, fragment) = input let (path_input, fragment) = input
.split_once('#') .split_once('#')
@ -114,6 +127,7 @@ impl MentionUri {
return Ok(MentionUri::Selection { return Ok(MentionUri::Selection {
abs_path: Some(path_input.into()), abs_path: Some(path_input.into()),
line_range: fragment, line_range: fragment,
column: None,
}); });
} }
@ -123,10 +137,12 @@ impl MentionUri {
let line = row let line = row
.checked_sub(1) .checked_sub(1)
.context("Line numbers should be 1-based")?; .context("Line numbers should be 1-based")?;
// TODO: Preserve column info too.
Ok(MentionUri::Selection { Ok(MentionUri::Selection {
abs_path: Some(abs_path), abs_path: Some(abs_path),
line_range: line..=line, line_range: line..=line,
column: path_with_position
.column
.map(|column| column.saturating_sub(1)),
}) })
} else { } else {
Ok(MentionUri::File { abs_path }) Ok(MentionUri::File { abs_path })
@ -156,8 +172,10 @@ impl MentionUri {
let path = normalized.as_ref(); let path = normalized.as_ref();
if let Some(fragment) = url.fragment() { 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); 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 { Ok(Self::Symbol {
name, name,
abs_path: path.into(), abs_path: path.into(),
@ -167,6 +185,7 @@ impl MentionUri {
Ok(Self::Selection { Ok(Self::Selection {
abs_path: Some(path.into()), abs_path: Some(path.into()),
line_range, line_range,
column,
}) })
} }
} else if input.ends_with("/") { } else if input.ends_with("/") {
@ -216,9 +235,11 @@ impl MentionUri {
.fragment() .fragment()
.context("Missing fragment for untitled buffer selection")?; .context("Missing fragment for untitled buffer selection")?;
let line_range = parse_line_range(fragment)?; let line_range = parse_line_range(fragment)?;
validate_query_params(&url, &["column"])?;
Ok(Self::Selection { Ok(Self::Selection {
abs_path: None, abs_path: None,
line_range, line_range,
column: parse_column(query_param(&url, "column")),
}) })
} else if let Some(name) = path.strip_prefix("/agent/symbol/") { } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
let fragment = url let fragment = url
@ -245,13 +266,15 @@ impl MentionUri {
abs_path: path.into(), abs_path: path.into(),
}) })
} else if path.starts_with("/agent/selection") { } else if path.starts_with("/agent/selection") {
validate_query_params(&url, &["path", "column"])?;
let fragment = url.fragment().context("Missing fragment for selection")?; let fragment = url.fragment().context("Missing fragment for selection")?;
let line_range = parse_line_range(fragment)?; let line_range = parse_line_range(fragment)?;
let path = let column = parse_column(query_param(&url, "column"));
single_query_param(&url, "path")?.context("Missing path for selection")?; let path = query_param(&url, "path").context("Missing path for selection")?;
Ok(Self::Selection { Ok(Self::Selection {
abs_path: Some(path.into()), abs_path: Some(path.into()),
line_range, line_range,
column,
}) })
} else if path.starts_with("/agent/terminal-selection") { } else if path.starts_with("/agent/terminal-selection") {
let line_count = single_query_param(&url, "lines")? let line_count = single_query_param(&url, "lines")?
@ -342,13 +365,33 @@ impl MentionUri {
.. ..
} => selection_name(path.as_deref(), line_range), } => selection_name(path.as_deref(), line_range),
MentionUri::Fetch { url } => url.to_string(), 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, .. } => { MentionUri::Skill { name, source, .. } => {
if source.is_empty() { if source.is_empty() {
// Must match `SkillSource::display_label()` in agent_skills.
format!("{} (global)", name) format!("{} (global)", name)
} else { } else {
format!("{} ({})", name, source) 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, abs_path,
name, name,
line_range, line_range,
..
} => { } => {
let mut url = Url::parse("file:///").unwrap(); let mut url = Url::parse("file:///").unwrap();
url.set_path(&abs_path.to_string_lossy()); url.set_path(&abs_path.to_string_lossy());
@ -454,6 +498,7 @@ impl MentionUri {
MentionUri::Selection { MentionUri::Selection {
abs_path, abs_path,
line_range, line_range,
column,
} => { } => {
let mut url = if let Some(path) = abs_path { let mut url = if let Some(path) = abs_path {
let mut url = Url::parse("file:///").unwrap(); let mut url = Url::parse("file:///").unwrap();
@ -464,6 +509,10 @@ impl MentionUri {
url.set_path("/agent/untitled-buffer"); url.set_path("/agent/untitled-buffer");
url url
}; };
if let Some(column) = column {
url.query_pairs_mut()
.append_pair("column", &(column + 1).to_string());
}
url.set_fragment(Some(&format!( url.set_fragment(Some(&format!(
"L{}:{}", "L{}:{}",
line_range.start() + 1, line_range.start() + 1,
@ -544,6 +593,11 @@ fn default_include_errors() -> bool {
true 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>> { fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
let pairs = url.query_pairs().collect::<Vec<_>>(); let pairs = url.query_pairs().collect::<Vec<_>>();
match pairs.as_slice() { match pairs.as_slice() {
@ -678,6 +732,7 @@ mod tests {
abs_path: path, abs_path: path,
name, name,
line_range, line_range,
..
} => { } => {
assert_eq!(path, Path::new(path!("/path/to/file.rs"))); assert_eq!(path, Path::new(path!("/path/to/file.rs")));
assert_eq!(name, "MySymbol"); assert_eq!(name, "MySymbol");
@ -697,6 +752,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &4); assert_eq!(line_range.start(), &4);
@ -728,6 +784,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: None, abs_path: None,
line_range, line_range,
..
} => { } => {
assert_eq!(line_range.start(), &0); assert_eq!(line_range.start(), &0);
assert_eq!(line_range.end(), &9); assert_eq!(line_range.end(), &9);
@ -875,6 +932,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41); 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] #[test]
fn test_parse_absolute_file_path_with_fragment_line() { fn test_parse_absolute_file_path_with_fragment_line() {
let file_path = "/path/to/file.rs#L42"; let file_path = "/path/to/file.rs#L42";
@ -892,6 +973,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41); assert_eq!(line_range.start(), &41);
@ -921,6 +1003,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!( assert_eq!(
path.as_ref().unwrap(), path.as_ref().unwrap(),
@ -941,6 +1024,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!( assert_eq!(
path.as_ref().unwrap(), path.as_ref().unwrap(),
@ -973,6 +1057,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41); assert_eq!(line_range.start(), &41);
@ -990,6 +1075,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!( assert_eq!(
path.as_ref().unwrap(), path.as_ref().unwrap(),
@ -1011,6 +1097,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &1871); assert_eq!(line_range.start(), &1871);
@ -1028,6 +1115,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9); assert_eq!(line_range.start(), &9);
@ -1043,6 +1131,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: path, abs_path: path,
line_range, line_range,
..
} => { } => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9); assert_eq!(line_range.start(), &9);
@ -1070,4 +1159,68 @@ mod tests {
let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap(); let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
assert_eq!(parsed_single.name(), "Terminal (1 line)"); 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 { } else {
CopyButtonVisibility::Hidden CopyButtonVisibility::Hidden
}, },
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false, border: false,
}, },
), ),

View file

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

View file

@ -83,7 +83,7 @@ mod tests {
let project = prompt_store::ProjectContext::default(); let project = prompt_store::ProjectContext::default();
let template = SystemPromptTemplate { let template = SystemPromptTemplate {
project: &project, 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()), model_name: Some("test-model".to_string()),
date: "2026-01-01".to_string(), date: "2026-01-01".to_string(),
user_agents_md: None, user_agents_md: None,
@ -94,6 +94,7 @@ mod tests {
assert!(rendered.contains("Today's Date: 2026-01-01")); assert!(rendered.contains("Today's Date: 2026-01-01"));
assert!(rendered.contains("## Fixing Diagnostics")); assert!(rendered.contains("## Fixing Diagnostics"));
assert!(rendered.contains("## Planning")); assert!(rendered.contains("## Planning"));
assert!(rendered.contains("## Session Title"));
assert!(rendered.contains("test-model")); 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. - 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. - 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}} {{/if}}
## Searching and Reading ## 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. - 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. - 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}} {{/if}}
## Searching and Reading ## Searching and Reading

View file

@ -26,10 +26,10 @@ use gpui::{
use indoc::indoc; use indoc::indoc;
use language_model::{ use language_model::{
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry, LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason, LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent,
TokenUsage, Role, StopReason, TokenUsage,
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}, fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
}; };
use pretty_assertions::assert_eq; 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(); let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
assert_eq!(tool_call_params.name, "screenshot"); assert_eq!(tool_call_params.name, "screenshot");
let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
tool_call_response tool_call_response
.send(context_server::types::CallToolResponse { .send(context_server::types::CallToolResponse {
content: vec![ content: vec![
@ -1663,7 +1664,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
text: "Some text".into(), text: "Some text".into(),
}, },
context_server::types::ToolResponseContent::Image { context_server::types::ToolResponseContent::Image {
data: "aGVsbG8=".into(), data: image_data.into(),
mime_type: "image/png".into(), mime_type: "image/png".into(),
}, },
context_server::types::ToolResponseContent::Text { 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"); .expect("expected a tool result");
assert_eq!(tool_result.tool_use_id, "tool_1".into()); 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!( assert_eq!(
tool_result.content[0], tool_result.content[0],
language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
); );
assert_eq!( assert_eq!(
tool_result.content[1], 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")) language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text"))
); );
fake_model.end_last_completion_stream(); 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] #[gpui::test]
async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { async fn test_send_no_retry_on_success(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; 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, StreamingFailingEchoTool::NAME: true,
TerminalTool::NAME: true, TerminalTool::NAME: true,
UpdatePlanTool::NAME: true, UpdatePlanTool::NAME: true,
UpdateTitleTool::NAME: true,
} }
} }
} }

View file

@ -4,12 +4,14 @@ use crate::{
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, 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 acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog; use action_log::ActionLog;
use feature_flags::{ use feature_flags::{
FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag,
UpdateTitleToolFeatureFlag,
}; };
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
@ -364,11 +366,7 @@ impl UserMessage {
.ok(); .ok();
} }
MentionUri::Skill { name, source, .. } => { MentionUri::Skill { name, source, .. } => {
let label = if source.is_empty() { let label = format!("{} ({})", name, source);
format!("{} (global)", name)
} else {
format!("{} ({})", name, source)
};
write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok(); write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok();
} }
} }
@ -1219,10 +1217,10 @@ impl Thread {
stream: &ThreadEventStream, stream: &ThreadEventStream,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// Extract saved output and status first, so they're available even if tool is not found
let output = tool_result let output = tool_result
.as_ref() .as_ref()
.and_then(|result| result.output.clone()); .and_then(|result| result.output.clone());
let replay_content = tool_result.and_then(Self::tool_result_content_for_replay);
let status = tool_result let status = tool_result
.as_ref() .as_ref()
.map_or(acp::ToolCallStatus::Failed, |result| { .map_or(acp::ToolCallStatus::Failed, |result| {
@ -1251,21 +1249,25 @@ impl Thread {
// but still display the saved result if available. // but still display the saved result if available.
// We need to send both ToolCall and ToolCallUpdate events because the UI // 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. // only converts raw_output to displayable content in update_fields, not from_acp.
let title = Self::title_for_replayed_tool_use(tool_use);
stream stream
.0 .0
.unbounded_send(Ok(ThreadEvent::ToolCall( .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) .status(status)
.raw_input(tool_use.input.clone()), .raw_input(tool_use.input.clone()),
))) )))
.ok(); .ok();
stream.update_tool_call_fields( let mut fields = acp::ToolCallUpdateFields::new()
&tool_use.id,
acp::ToolCallUpdateFields::new()
.status(status) .status(status)
.raw_output(output), .raw_output(output);
None, 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; return;
}; };
@ -1279,6 +1281,14 @@ impl Thread {
tool_use.input.clone(), 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() { if let Some(output) = output.clone() {
// For replay, we use a dummy cancellation receiver since the tool already completed // For replay, we use a dummy cancellation receiver since the tool already completed
let (_cancellation_tx, cancellation_rx) = watch::channel(false); 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( pub fn from_db(
id: acp::SessionId, id: acp::SessionId,
db_thread: DbThread, db_thread: DbThread,
@ -1627,6 +1686,9 @@ impl Thread {
if cx.has_flag::<UpdatePlanToolFeatureFlag>() { if cx.has_flag::<UpdatePlanToolFeatureFlag>() {
self.add_tool(UpdatePlanTool); self.add_tool(UpdatePlanTool);
} }
if cx.has_flag::<UpdateTitleToolFeatureFlag>() {
self.add_tool(UpdateTitleTool::new(cx.weak_entity()));
}
self.add_tool(ReadFileTool::new( self.add_tool(ReadFileTool::new(
self.project.clone(), self.project.clone(),
self.action_log.clone(), self.action_log.clone(),
@ -2140,7 +2202,7 @@ impl Thread {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.flush_pending_message(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); this.generate_title(cx);
} }
})?; })?;
@ -2669,6 +2731,20 @@ impl Thread {
self.title_generation_failed 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>>> { pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
if let Some(summary) = self.summary.as_ref() { if let Some(summary) = self.summary.as_ref() {
return Task::ready(Some(summary.clone())).shared(); return Task::ready(Some(summary.clone())).shared();
@ -2730,6 +2806,10 @@ impl Thread {
} }
pub fn generate_title(&mut self, cx: &mut Context<Self>) { pub fn generate_title(&mut self, cx: &mut Context<Self>) {
if !self.can_generate_title(cx) {
return;
}
self.title_generation_failed = false; self.title_generation_failed = false;
let Some(model) = self.summarization_model.clone() else { let Some(model) = self.summarization_model.clone() else {
return; 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] #[gpui::test]
async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) {
let (parent, _event_stream) = setup_thread_for_test(cx).await; let (parent, _event_stream) = setup_thread_for_test(cx).await;

View file

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

View file

@ -5,7 +5,7 @@ use collections::{BTreeMap, HashMap};
use context_server::{ContextServerId, client::NotificationSubscription}; use context_server::{ContextServerId, client::NotificationSubscription};
use futures::FutureExt as _; use futures::FutureExt as _;
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; 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 project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc; use std::sync::Arc;
use util::ResultExt; use util::ResultExt;
@ -261,7 +261,8 @@ impl ContextServerRegistry {
} }
ContextServerStatus::Stopped ContextServerStatus::Stopped
| ContextServerStatus::Error(_) | ContextServerStatus::Error(_)
| ContextServerStatus::AuthRequired => { | ContextServerStatus::AuthRequired
| ContextServerStatus::ClientSecretRequired { .. } => {
if let Some(registered_server) = self.registered_servers.remove(server_id) { if let Some(registered_server) = self.registered_servers.remove(server_id) {
if !registered_server.tools.is_empty() { if !registered_server.tools.is_empty() {
cx.emit(ContextServerRegistryEvent::ToolsChanged); cx.emit(ContextServerRegistryEvent::ToolsChanged);
@ -346,7 +347,7 @@ impl AnyAgentTool for ContextServerTool {
let authorize = let authorize =
event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx); 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 let input = input
.recv() .recv()
.await .await
@ -394,15 +395,50 @@ impl AnyAgentTool for ContextServerTool {
} }
let mut llm_output = Vec::new(); let mut llm_output = Vec::new();
let mut tool_call_content = Vec::new();
let mut concatenated_text = String::new(); let mut concatenated_text = String::new();
for content in response.content { for content in response.content {
match content { match content {
context_server::types::ToolResponseContent::Text { text } => { context_server::types::ToolResponseContent::Text { text } => {
concatenated_text.push_str(&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())); llm_output.push(LanguageModelToolResultContent::Text(text.into()));
} }
context_server::types::ToolResponseContent::Image { .. } => { context_server::types::ToolResponseContent::Image { data, mime_type } => {
log::warn!("Ignoring image content from tool response"); 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 { .. } => { context_server::types::ToolResponseContent::Audio { .. } => {
log::warn!("Ignoring audio content from tool response"); 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); let raw_output = serde_json::Value::String(concatenated_text);
Ok(AgentToolOutput { Ok(AgentToolOutput {
raw_output, raw_output,

View file

@ -1,16 +1,18 @@
use crate::{AgentTool, ToolCallEventStream, ToolInput}; use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
use anyhow::Result; use futures::{Future, FutureExt as _};
use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, Task};
use gpui::{App, Entity, Task};
use language::{DiagnosticSeverity, OffsetRangeExt}; use language::{DiagnosticSeverity, OffsetRangeExt};
use project::Project; use project::Project;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{fmt::Write, sync::Arc}; use std::{fmt::Write, sync::Arc};
use ui::SharedString; use ui::SharedString;
use util::markdown::MarkdownInlineCode; 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. /// 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. /// 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 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. /// 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> /// <example>
/// To get diagnostics for a specific file: /// 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 { impl AgentTool for DiagnosticsTool {
type Input = DiagnosticsToolInput; type Input = DiagnosticsToolInput;
type Output = String; type Output = String;
@ -96,21 +168,22 @@ impl AgentTool for DiagnosticsTool {
let input = input.recv().await.map_err(|e| e.to_string())?; let input = input.recv().await.map_err(|e| e.to_string())?;
match input.path { match input.path {
Some(path) if !path.is_empty() => { Some(ref path) if !path.is_empty() => {
let (_project_path, open_buffer_task) = project.update(cx, |project, cx| { let refreshed =
let Some(project_path) = project.find_project_path(&path, cx) else { 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")); return Err(format!("Could not find path {path} in project"));
}; };
let task = project.open_buffer(project_path.clone(), cx); Ok(project.open_buffer(project_path, cx))
Ok((project_path, task))
})?; })?;
let buffer = futures::select! { let buffer = with_cancellation(open_buffer_task, &event_stream)
result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?, .await?
_ = event_stream.cancelled_by_user().fuse() => { .map_err(|e| e.to_string())?;
return Err("Diagnostics cancelled by user".to_string());
}
};
let mut output = String::new(); let mut output = String::new();
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
@ -133,13 +206,18 @@ impl AgentTool for DiagnosticsTool {
.ok(); .ok();
} }
let freshness = freshness_message(refreshed);
if output.is_empty() { 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 { } 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 (output, has_diagnostics) = project.read_with(cx, |project, cx| {
let mut output = String::new(); let mut output = String::new();
let mut has_diagnostics = false; let mut has_diagnostics = false;
@ -165,10 +243,13 @@ impl AgentTool for DiagnosticsTool {
(output, has_diagnostics) (output, has_diagnostics)
}); });
let freshness = freshness_message(refreshed);
if has_diagnostics { if has_diagnostics {
Ok(output) Ok(format!("{freshness}\n\n{output}"))
} else { } 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 { 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. // 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()); let chunk = normalize_done_chunk(old_text[start..].to_string());
state.old_text_done = true; state.old_text_done = true;
state.old_text_emitted_len = old_text.len(); state.old_text_emitted_len = old_text.len();
@ -124,9 +124,10 @@ impl StreamingParser {
}); });
} else { } else {
let safe_end = safe_emit_end_for_edit_text(old_text); 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 { if safe_end > safe_start {
let chunk = old_text[state.old_text_emitted_len..safe_end].to_string(); let chunk = old_text[safe_start..safe_end].to_string();
state.old_text_emitted_len = safe_end; state.old_text_emitted_len = safe_end;
events.push(EditEvent::OldTextChunk { events.push(EditEvent::OldTextChunk {
edit_index: index, edit_index: index,
@ -143,9 +144,10 @@ impl StreamingParser {
&& !state.new_text_done && !state.new_text_done
{ {
let safe_end = safe_emit_end_for_edit_text(new_text); 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 { if safe_end > safe_start {
let chunk = new_text[state.new_text_emitted_len..safe_end].to_string(); let chunk = new_text[safe_start..safe_end].to_string();
state.new_text_emitted_len = safe_end; state.new_text_emitted_len = safe_end;
events.push(EditEvent::NewTextChunk { events.push(EditEvent::NewTextChunk {
edit_index: index, edit_index: index,
@ -343,8 +345,10 @@ impl StreamingParser {
/// held back because it may be an artifact of the partial JSON fixer closing /// 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 `\\`). /// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
/// The next partial will reveal the correct character. /// 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 { fn safe_emit_end(text: &str) -> usize {
if text.as_bytes().last() == Some(&b'\\') { if text.ends_with('\\') {
text.len() - 1 text.len() - 1
} else { } else {
text.len() text.len()
@ -353,13 +357,35 @@ fn safe_emit_end(text: &str) -> usize {
fn safe_emit_end_for_edit_text(text: &str) -> usize { fn safe_emit_end_for_edit_text(text: &str) -> usize {
let safe_end = safe_emit_end(text); 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 safe_end - 1
} else { } else {
safe_end 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 { fn normalize_done_chunk(mut chunk: String) -> String {
if chunk.ends_with('\n') { if chunk.ends_with('\n') {
chunk.pop(); 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 anchor = None;
let mut is_outline_response = false;
// Check if specific line ranges are provided // Check if specific line ranges are provided
let result = if input.start_line.is_some() || input.end_line.is_some() { 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); log.buffer_read(buffer.clone(), cx);
}); });
is_outline_response = buffer_content.is_outline;
if buffer_content.is_outline { if buffer_content.is_outline {
Ok(formatdoc! {" 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. 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 { if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
let text: &str = text; let text: &str = text;
let markdown = MarkdownCodeBlock { // For outline responses, omit the path tag so the markdown renderer
tag: &input.path, // does not invoke tree-sitter syntax highlighting against pseudo-code
text, // outline text. The outline is not valid source for the file's language,
} // so highlighting would be both expensive and incorrect.
.to_string(); 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![ event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
acp::ToolCallContent::Content(acp::Content::new(markdown)), 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", // 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 // 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. // 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 std::sync::Arc;
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
use collections::HashSet;
use gpui::{App, Entity, SharedString, Task}; use gpui::{App, Entity, SharedString, Task};
use project::Project; use project::Project;
use schemars::JsonSchema; 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!( let mut output = format!(
"Renamed `{}` to `{}` in {} file(s):\n", "Renamed `{}` to `{}` in {} file(s):\n",
input.symbol.symbol_name, input.symbol.symbol_name,

View file

@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String {
/// frontmatter), not O(total file size). /// frontmatter), not O(total file size).
pub fn render_skill_envelope(skill: &Skill, body: &str) -> String { pub fn render_skill_envelope(skill: &Skill, body: &str) -> String {
let source = match &skill.source { let source = match &skill.source {
agent_skills::SkillSource::BuiltIn => "built-in",
agent_skills::SkillSource::Global => "global", agent_skills::SkillSource::Global => "global",
agent_skills::SkillSource::ProjectLocal { .. } => "project-local", agent_skills::SkillSource::ProjectLocal { .. } => "project-local",
}; };
let worktree = match &skill.source { let worktree = match &skill.source {
agent_skills::SkillSource::Global => None, agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None,
agent_skills::SkillSource::ProjectLocal { agent_skills::SkillSource::ProjectLocal {
worktree_root_name, .. worktree_root_name, ..
} => Some(worktree_root_name.clone()), } => Some(worktree_root_name.clone()),
@ -200,31 +201,33 @@ impl AgentTool for SkillTool {
(skill.clone(), path_string) (skill.clone(), path_string)
}; };
// Read the body on demand. Bodies are not kept in memory // For built-in skills the body is already in memory (compiled
// between materializations — see `agent_skills::read_skill_body`. // into the binary). For user skills, read on demand from disk.
let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) 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 .await
.map_err(|e| SkillToolOutput::Error { .map_err(|e| SkillToolOutput::Error {
error: e.to_string(), error: e.to_string(),
})?; })?
};
let rendered = render_skill_envelope(&skill, &body); let rendered = render_skill_envelope(&skill, &body);
// Activations go through the standard tool-permission flow so // Built-in skills ship with Zed and are trusted by default,
// they participate in the same Allow-Once / Always-Allow UX as // so they skip the authorization prompt. User-installed skills
// every other built-in tool. The auth context value is the // go through the standard Allow-Once / Always-Allow UX.
// skill's absolute SKILL.md path so that "always allow this let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn;
// specific skill" is keyed to a specific file: editing the if !is_builtin {
// 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 authorize = cx.update(|cx| {
let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); let context =
crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]);
event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) event_stream.authorize(self.initial_title(Ok(input), cx), context, cx)
}); });
authorize.await.map_err(|e| SkillToolOutput::Error { authorize.await.map_err(|e| SkillToolOutput::Error {
error: e.to_string(), error: e.to_string(),
})?; })?;
}
Ok(SkillToolOutput::Found { rendered }) 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 anyhow::anyhow;
use async_channel; use async_channel;
use collections::HashMap; use collections::{HashMap, HashSet};
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::channel::mpsc; use futures::channel::mpsc;
use futures::future::Shared; use futures::future::Shared;
@ -509,6 +509,7 @@ impl AgentSessionList for AcpSessionList {
cx: &mut App, cx: &mut App,
) -> Task<Result<AgentSessionListResponse>> { ) -> Task<Result<AgentSessionListResponse>> {
let conn = self.connection.clone(); let conn = self.connection.clone();
let include_additional_directories = cx.has_flag::<AcpBetaFeatureFlag>();
cx.foreground_executor().spawn(async move { cx.foreground_executor().spawn(async move {
let acp_request = acp::ListSessionsRequest::new() let acp_request = acp::ListSessionsRequest::new()
.cwd(request.cwd) .cwd(request.cwd)
@ -522,7 +523,14 @@ impl AgentSessionList for AcpSessionList {
.into_iter() .into_iter()
.map(|s| AgentSessionInfo { .map(|s| AgentSessionInfo {
session_id: s.session_id, 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), title: s.title.map(Into::into),
updated_at: s.updated_at.and_then(|date_str| { updated_at: s.updated_at.and_then(|date_str| {
chrono::DateTime::parse_from_rfc3339(&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( fn open_or_create_session(
self: Rc<Self>, self: Rc<Self>,
session_id: acp::SessionId, session_id: acp::SessionId,
@ -1062,7 +1079,7 @@ impl AcpConnection {
rpc_call: impl FnOnce( rpc_call: impl FnOnce(
ConnectionTo<Agent>, ConnectionTo<Agent>,
acp::SessionId, acp::SessionId,
PathBuf, SessionDirectories,
) )
-> futures::future::LocalBoxFuture<'static, Result<SessionConfigResponse>> -> futures::future::LocalBoxFuture<'static, Result<SessionConfigResponse>>
+ 'static, + 'static,
@ -1089,9 +1106,9 @@ impl AcpConnection {
} }
} }
// TODO: remove this once ACP supports multiple working directories let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) {
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { Ok(directories) => directories,
return Task::ready(Err(anyhow!("Working directory cannot be empty"))); Err(error) => return Task::ready(Err(error)),
}; };
let shared_task = cx let shared_task = cx
@ -1133,7 +1150,9 @@ impl AcpConnection {
); );
let response = 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, Ok(response) => response,
Err(err) => { Err(err) => {
this.sessions.borrow_mut().remove(&session_id); 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( fn emit_load_error_to_all_sessions(
sessions: &Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>, sessions: &Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
error: LoadError, error: LoadError,
@ -1385,17 +1475,18 @@ impl AgentConnection for AcpConnection {
work_dirs: PathList, work_dirs: PathList,
cx: &mut App, cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> { ) -> Task<Result<Entity<AcpThread>>> {
// TODO: remove this once ACP supports multiple working directories let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) {
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { Ok(directories) => directories,
return Task::ready(Err(anyhow!("Working directory cannot be empty"))); Err(error) => return Task::ready(Err(error)),
}; };
let name = self.id.0.clone(); let name = self.id.0.clone();
let mcp_servers = mcp_servers_for_project(&project, cx); let mcp_servers = mcp_servers_for_project(&project, cx);
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let response = into_foreground_future( let response = into_foreground_future(
self.connection self.connection.send_request(
.send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)), directories.into_new_session_request(mcp_servers),
),
) )
.await .await
.map_err(map_acp_error)?; .map_err(map_acp_error)?;
@ -1550,6 +1641,15 @@ impl AgentConnection for AcpConnection {
.is_some() .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( fn load_session(
self: Rc<Self>, self: Rc<Self>,
session_id: acp::SessionId, session_id: acp::SessionId,
@ -1570,14 +1670,11 @@ impl AgentConnection for AcpConnection {
project, project,
work_dirs, work_dirs,
title, title,
move |connection, session_id, cwd| { move |connection, session_id, directories| {
Box::pin(async move { Box::pin(async move {
let response = into_foreground_future( let response = into_foreground_future(connection.send_request(
connection.send_request( directories.into_load_session_request(session_id.clone(), mcp_servers),
acp::LoadSessionRequest::new(session_id.clone(), cwd) ))
.mcp_servers(mcp_servers),
),
)
.await .await
.map_err(map_acp_error)?; .map_err(map_acp_error)?;
Ok(SessionConfigResponse { Ok(SessionConfigResponse {
@ -1616,14 +1713,11 @@ impl AgentConnection for AcpConnection {
project, project,
work_dirs, work_dirs,
title, title,
move |connection, session_id, cwd| { move |connection, session_id, directories| {
Box::pin(async move { Box::pin(async move {
let response = into_foreground_future( let response = into_foreground_future(connection.send_request(
connection.send_request( directories.into_resume_session_request(session_id.clone(), mcp_servers),
acp::ResumeSessionRequest::new(session_id.clone(), cwd) ))
.mcp_servers(mcp_servers),
),
)
.await .await
.map_err(map_acp_error)?; .map_err(map_acp_error)?;
Ok(SessionConfigResponse { Ok(SessionConfigResponse {
@ -2107,6 +2201,10 @@ pub mod test_support {
self.inner.supports_resume_session() self.inner.supports_resume_session()
} }
fn supports_session_additional_directories(&self, cx: &App) -> bool {
self.inner.supports_session_additional_directories(cx)
}
fn resume_session( fn resume_session(
self: Rc<Self>, self: Rc<Self>,
session_id: acp::SessionId, 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] #[gpui::test]
async fn session_delete_support_requires_beta_flag_and_capability( async fn session_delete_support_requires_beta_flag_and_capability(
cx: &mut gpui::TestAppContext, cx: &mut gpui::TestAppContext,
@ -3407,6 +3844,7 @@ fn mcp_servers_for_project(project: &Entity<Project>, cx: &App) -> Vec<acp::McpS
url, url,
headers, headers,
timeout: _, timeout: _,
oauth: _,
} => Some(acp::McpServer::Http( } => Some(acp::McpServer::Http(
acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers(
headers headers

View file

@ -88,22 +88,18 @@ impl AgentServer for CustomAgentServer {
let config_id = config_id.to_string(); let config_id = config_id.to_string();
let value_id = value_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 let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .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 { match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
favorite_config_option_values, favorite_config_option_values,
.. ..
} }
| settings::CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
favorite_config_option_values, 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) { fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id(); 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 let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .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 { match settings {
settings::CustomAgentServerSettings::Custom { default_mode, .. } settings::CustomAgentServerSettings::Custom { default_mode, .. }
| settings::CustomAgentServerSettings::Extension { default_mode, .. }
| settings::CustomAgentServerSettings::Registry { default_mode, .. } => { | settings::CustomAgentServerSettings::Registry { default_mode, .. } => {
*default_mode = mode_id.map(|m| m.to_string()); *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) { fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id(); 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 let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .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 { match settings {
settings::CustomAgentServerSettings::Custom { default_model, .. } settings::CustomAgentServerSettings::Custom { default_model, .. }
| settings::CustomAgentServerSettings::Extension { default_model, .. }
| settings::CustomAgentServerSettings::Registry { default_model, .. } => { | settings::CustomAgentServerSettings::Registry { default_model, .. } => {
*default_model = model_id.map(|m| m.to_string()); *default_model = model_id.map(|m| m.to_string());
} }
@ -205,20 +199,17 @@ impl AgentServer for CustomAgentServer {
cx: &App, cx: &App,
) { ) {
let agent_id = self.agent_id(); 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 let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .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 { let favorite_models = match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
favorite_models, .. favorite_models, ..
} }
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
favorite_models, .. favorite_models, ..
} => favorite_models, } => favorite_models,
@ -258,22 +249,18 @@ impl AgentServer for CustomAgentServer {
let agent_id = self.agent_id(); let agent_id = self.agent_id();
let config_id = config_id.to_string(); let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.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 let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .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 { match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
default_config_options, default_config_options,
.. ..
} }
| settings::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
default_config_options, default_config_options,
.. ..
@ -307,10 +294,6 @@ impl AgentServer for CustomAgentServer {
default_config_options, default_config_options,
.. ..
} }
| project::agent_server_store::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| project::agent_server_store::CustomAgentServerSettings::Registry { | project::agent_server_store::CustomAgentServerSettings::Registry {
default_config_options, default_config_options,
.. ..
@ -422,11 +405,7 @@ fn is_registry_agent(agent_id: impl Into<AgentId>, cx: &App) -> bool {
is_in_registry || is_settings_registry is_in_registry || is_settings_registry
} }
fn default_settings_for_agent( fn default_settings_for_agent() -> settings::CustomAgentServerSettings {
agent_id: impl Into<AgentId>,
cx: &App,
) -> settings::CustomAgentServerSettings {
if is_registry_agent(agent_id, cx) {
settings::CustomAgentServerSettings::Registry { settings::CustomAgentServerSettings::Registry {
default_model: None, default_model: None,
default_mode: None, default_mode: None,
@ -435,16 +414,6 @@ fn default_settings_for_agent(
default_config_options: Default::default(), default_config_options: Default::default(),
favorite_config_option_values: 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(),
}
}
} }
#[cfg(test)] #[cfg(test)]
@ -547,53 +516,4 @@ mod tests {
assert!(is_registry_agent("agent-from-settings", cx)); 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 anyhow::{Context as _, Result};
use const_format::concatcp; use const_format::{concatcp, formatcp};
use fs::Fs; use fs::Fs;
use futures::StreamExt; use futures::StreamExt;
use gpui::{Global, SharedString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{self, Read}; use std::io::{self, Read};
use std::path::{Path, PathBuf}; 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 /// `skill` tool refuses to load it. The user can still invoke it as a
/// slash command. /// slash command.
pub disable_model_invocation: bool, 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. /// Indicates where a skill was loaded from.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource { 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/ /// From ~/.agents/skills/
Global, Global,
/// From {project}/.agents/skills/ /// From {project}/.agents/skills/
@ -79,6 +88,23 @@ pub enum SkillSource {
} }
impl 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 /// Scope prefix used in the `/<prefix>:<name>` slash-command
/// syntax that the autocomplete popup inserts. Global skills use /// syntax that the autocomplete popup inserts. Global skills use
/// an empty prefix (so the inserted text is `/:<name>`), and /// 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 /// invoked as `/:<name>`, and the worktree's skill is invoked as
/// `/global:<name>`. The two grammars never collide on the /// `/global:<name>`. The two grammars never collide on the
/// inserted text. /// 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 { pub fn scope_prefix(&self) -> &str {
match self { match self {
Self::Global => "", Self::BuiltIn | Self::Global => "",
Self::ProjectLocal { Self::ProjectLocal {
worktree_root_name, .. worktree_root_name, ..
} => worktree_root_name.as_ref(), } => worktree_root_name.as_ref(),
@ -112,7 +150,7 @@ impl SkillSource {
/// strictness only affects users typing by memory. /// strictness only affects users typing by memory.
pub fn matches_scope(&self, scope: &str) -> bool { pub fn matches_scope(&self, scope: &str) -> bool {
match self { match self {
Self::Global => scope.is_empty(), Self::BuiltIn | Self::Global => scope.is_empty(),
Self::ProjectLocal { Self::ProjectLocal {
worktree_root_name, .. worktree_root_name, ..
} => !scope.is_empty() && worktree_root_name.as_ref() == scope, } => !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 /// Just the frontmatter, used for parsing
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata { pub struct SkillMetadata {
@ -196,8 +251,8 @@ pub fn parse_skill_frontmatter(
let (metadata, _body) = extract_frontmatter(content)?; let (metadata, _body) = extract_frontmatter(content)?;
validate_name(&metadata.name)?; validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
validate_description(&metadata.description)?; validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
let directory_path = skill_file_path let directory_path = skill_file_path
.parent() .parent()
@ -211,6 +266,7 @@ pub fn parse_skill_frontmatter(
directory_path, directory_path,
skill_file_path: skill_file_path.to_path_buf(), skill_file_path: skill_file_path.to_path_buf(),
disable_model_invocation: metadata.disable_model_invocation, 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`]. /// by [`validate_name`].
pub const MAX_SKILL_NAME_LEN: usize = 64; 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 /// 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 /// return `None` if no valid name can be produced (e.g. the input contains
/// no ASCII alphanumeric characters at all). /// 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) } 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() { 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 { 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 if !name
.chars() .chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') .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(()) Ok(())
} }
fn validate_description(description: &str) -> Result<()> { /// Validate a skill description against the rules enforced by both the
if description.is_empty() { /// loader and the create-skill UI.
anyhow::bail!("Skill description cannot be empty"); pub fn validate_description(description: &str) -> Result<(), &'static str> {
if description.trim().is_empty() {
return Err("Skill description cannot be empty");
} }
if description.len() > MAX_SKILL_DESCRIPTION_LEN {
if description.len() > 1024 { return Err(formatcp!(
anyhow::bail!("Skill description must be at most 1024 characters"); "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes"
));
} }
Ok(()) Ok(())
} }
@ -600,6 +684,53 @@ pub async fn read_skill_body(
Ok(body.trim().to_string()) 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`. /// Returns the global skills directory: `~/.agents/skills`.
/// ///
/// Other agents (e.g. Claude Code) already write skill files into this /// Other agents (e.g. Claude Code) already write skill files into this
@ -663,6 +794,34 @@ mod tests {
use fs::FakeFs; use fs::FakeFs;
use gpui::TestAppContext; 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] #[test]
fn test_parse_valid_skill() { fn test_parse_valid_skill() {
let content = r#"--- let content = r#"---
@ -873,12 +1032,8 @@ Content.
SkillSource::Global, SkillSource::Global,
); );
assert!(result.is_err()); assert!(result.is_err());
assert!( let expected = format!("at most {MAX_SKILL_NAME_LEN} characters");
result assert!(result.unwrap_err().to_string().contains(&expected));
.unwrap_err()
.to_string()
.contains("at most 64 characters")
);
} }
#[test] #[test]
@ -1154,12 +1309,8 @@ Content.
SkillSource::Global, SkillSource::Global,
); );
assert!(result.is_err()); assert!(result.is_err());
assert!( let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes");
result assert!(result.unwrap_err().to_string().contains(&expected));
.unwrap_err()
.to_string()
.contains("at most 1024 characters")
);
} }
#[test] #[test]
@ -1532,6 +1683,7 @@ description: A skill with no body content
directory_path: PathBuf::from("/skills/test-skill"), directory_path: PathBuf::from("/skills/test-skill"),
skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"), skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"),
disable_model_invocation: false, disable_model_invocation: false,
embedded_body: None,
}; };
let summary = SkillSummary::from(&skill); let summary = SkillSummary::from(&skill);
@ -1759,4 +1911,89 @@ description: A skill with no body content
"project/.AGENTS/SKILLS/foo" "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 action_log.workspace = true
agent-client-protocol.workspace = true agent-client-protocol.workspace = true
agent.workspace = true agent.workspace = true
agent_skills.workspace = true
async-channel.workspace = true async-channel.workspace = true
agent_servers.workspace = true agent_servers.workspace = true
agent_settings.workspace = true agent_settings.workspace = true
agent_skills.workspace = true
ai_onboarding.workspace = true ai_onboarding.workspace = true
anyhow.workspace = true anyhow.workspace = true
heapless.workspace = true heapless.workspace = true
@ -69,6 +69,7 @@ language.workspace = true
language_model.workspace = true language_model.workspace = true
language_models.workspace = true language_models.workspace = true
log.workspace = true log.workspace = true
lru.workspace = true
lsp.workspace = true lsp.workspace = true
markdown.workspace = true markdown.workspace = true
menu.workspace = true menu.workspace = true
@ -89,6 +90,7 @@ remote.workspace = true
remote_connection.workspace = true remote_connection.workspace = true
rope.workspace = true rope.workspace = true
rules_library.workspace = true rules_library.workspace = true
skill_creator.workspace = true
schemars.workspace = true schemars.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View file

@ -664,8 +664,14 @@ impl AgentConfiguration {
None None
}; };
let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); 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 authenticating = matches!(server_status, ContextServerStatus::Authenticating);
let context_server_store = self.context_server_store.clone(); 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 let tool_count = self
.context_server_registry .context_server_registry
@ -685,6 +691,9 @@ impl AgentConfiguration {
ContextServerStatus::Error(_) => AiSettingItemStatus::Error, ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
ContextServerStatus::ClientSecretRequired { .. } => {
AiSettingItemStatus::ClientSecretRequired
}
ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
}; };
@ -886,7 +895,7 @@ impl AgentConfiguration {
), ),
) )
.child( .child(
Button::new("error-logout-server", "Authenticate") Button::new("authenticate-server", "Authenticate")
.style(ButtonStyle::Outlined) .style(ButtonStyle::Outlined)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
.on_click({ .on_click({
@ -900,6 +909,46 @@ impl AgentConfiguration {
) )
.into_any_element(), .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 { } else if authenticating {
Some( Some(
h_flex() h_flex()
@ -1125,7 +1174,6 @@ impl AgentConfiguration {
}; };
let source_kind = match source { let source_kind = match source {
ExternalAgentSource::Extension => AiSettingItemSource::Extension,
ExternalAgentSource::Registry => AiSettingItemSource::Registry, ExternalAgentSource::Registry => AiSettingItemSource::Registry,
ExternalAgentSource::Custom => AiSettingItemSource::Custom, ExternalAgentSource::Custom => AiSettingItemSource::Custom,
}; };
@ -1169,26 +1217,6 @@ impl AgentConfiguration {
}); });
let uninstall_button = match source { 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 => { ExternalAgentSource::Registry => {
let fs = self.fs.clone(); let fs = self.fs.clone();
Some( Some(

View file

@ -17,7 +17,7 @@ use project::{
ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, ContextServerStatus, ContextServerStore, ServerStatusChangedEvent,
registry::ContextServerDescriptorRegistry, registry::ContextServerDescriptorRegistry,
}, },
project_settings::{ContextServerSettings, ProjectSettings}, project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings},
worktree_store::WorktreeStore, worktree_store::WorktreeStore,
}; };
use serde::Deserialize; use serde::Deserialize;
@ -43,7 +43,9 @@ enum ConfigurationTarget {
id: ContextServerId, id: ContextServerId,
url: String, url: String,
headers: HashMap<String, String>, headers: HashMap<String, String>,
oauth: Option<OAuthClientSettings>,
}, },
Extension { Extension {
id: ContextServerId, id: ContextServerId,
repository_url: Option<SharedString>, repository_url: Option<SharedString>,
@ -121,15 +123,17 @@ impl ConfigurationSource {
id, id,
url, url,
headers: auth, headers: auth,
oauth,
} => ConfigurationSource::Existing { } => ConfigurationSource::Existing {
editor: create_editor( editor: create_editor(
context_server_http_input(Some((id, url, auth))), context_server_http_input(Some((id, url, auth, oauth))),
jsonc_language, jsonc_language,
window, window,
cx, cx,
), ),
is_http: true, is_http: true,
}, },
ConfigurationTarget::Extension { ConfigurationTarget::Extension {
id, id,
repository_url, repository_url,
@ -168,7 +172,7 @@ impl ConfigurationSource {
ConfigurationSource::New { editor, is_http } ConfigurationSource::New { editor, is_http }
| ConfigurationSource::Existing { editor, is_http } => { | ConfigurationSource::Existing { editor, is_http } => {
if *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, id,
ContextServerSettings::Http { ContextServerSettings::Http {
@ -176,6 +180,7 @@ impl ConfigurationSource {
url, url,
headers: auth, headers: auth,
timeout: None, timeout: None,
oauth,
}, },
) )
}) })
@ -256,11 +261,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
} }
fn context_server_http_input( fn context_server_http_input(
existing: Option<(ContextServerId, String, HashMap<String, String>)>, existing: Option<(
ContextServerId,
String,
HashMap<String, String>,
Option<OAuthClientSettings>,
)>,
) -> String { ) -> String {
let (name, url, headers) = match existing { let (name, url, headers, oauth) = match existing {
Some((id, url, headers)) => { Some((id, url, headers, oauth)) => {
let header = if headers.is_empty() { let headers = if headers.is_empty() {
r#"// "Authorization": "Bearer <token>"#.to_string() r#"// "Authorization": "Bearer <token>"#.to_string()
} else { } else {
let json = serde_json::to_string_pretty(&headers).unwrap(); let json = serde_json::to_string_pretty(&headers).unwrap();
@ -274,15 +284,48 @@ fn context_server_http_input(
.map(|line| format!(" {}", line)) .map(|line| format!(" {}", line))
.collect::<String>() .collect::<String>()
}; };
(id.0.to_string(), url, header) (id.0.to_string(), url, headers, oauth)
} }
None => ( None => (
"some-remote-server".to_string(), "some-remote-server".to_string(),
"https://example.com/mcp".to_string(), "https://example.com/mcp".to_string(),
r#"// "Authorization": "Bearer <token>"#.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!( format!(
r#"{{ r#"{{
/// Configure an MCP server that you connect to over HTTP /// 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 /// The name of your remote MCP server
"{name}": {{ "{name}": {{
/// The URL of the remote MCP server /// The URL of the remote MCP server
"url": "{url}", "url": "{url}",{oauth}
"headers": {{ "headers": {{
/// Any headers to send along /// Any headers to send along
{headers} {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)] #[derive(Deserialize)]
struct Temp { struct Temp {
url: String, url: String,
#[serde(default)] #[serde(default)]
headers: HashMap<String, String>, headers: HashMap<String, String>,
#[serde(default)]
oauth: Option<OAuthClientSettings>,
} }
let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?; let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?;
if value.len() != 1 { 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(); 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( fn resolve_context_server_extension(
@ -349,8 +406,16 @@ fn resolve_context_server_extension(
enum State { enum State {
Idle, Idle,
Waiting, Waiting,
AuthRequired { server_id: ContextServerId }, AuthRequired {
Authenticating { _server_id: ContextServerId }, server_id: ContextServerId,
},
ClientSecretRequired {
server_id: ContextServerId,
error: Option<SharedString>,
},
Authenticating {
server_id: ContextServerId,
},
Error(SharedString), Error(SharedString),
} }
@ -361,10 +426,47 @@ pub struct ConfigureContextServerModal {
state: State, state: State,
original_server_id: Option<ContextServerId>, original_server_id: Option<ContextServerId>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
secret_editor: Entity<Editor>,
_auth_subscription: Option<Subscription>, _auth_subscription: Option<Subscription>,
} }
impl ConfigureContextServerModal { 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( pub fn register(
workspace: &mut Workspace, workspace: &mut Workspace,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
@ -426,12 +528,14 @@ impl ConfigureContextServerModal {
url, url,
headers, headers,
timeout: _, timeout: _,
.. oauth,
} => Some(ConfigurationTarget::ExistingHttp { } => Some(ConfigurationTarget::ExistingHttp {
id: server_id, id: server_id,
url, url,
headers, headers,
oauth,
}), }),
ContextServerSettings::Extension { .. } => { ContextServerSettings::Extension { .. } => {
match workspace match workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
@ -468,9 +572,10 @@ impl ConfigureContextServerModal {
let workspace_handle = cx.weak_entity(); let workspace_handle = cx.weak_entity();
let context_server_store = workspace.project().read(cx).context_server_store(); let context_server_store = workspace.project().read(cx).context_server_store();
workspace.toggle_modal(window, cx, |window, cx| Self { workspace.toggle_modal(window, cx, |window, cx| Self {
context_server_store, context_server_store: context_server_store.clone(),
workspace: workspace_handle, workspace: workspace_handle,
state: State::Idle, state: Self::initial_state(&context_server_store, &target, cx),
original_server_id: match &target { original_server_id: match &target {
ConfigurationTarget::Existing { id, .. } => Some(id.clone()), ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()),
@ -485,6 +590,16 @@ impl ConfigureContextServerModal {
cx, cx,
), ),
scroll_handle: ScrollHandle::new(), 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, _auth_subscription: None,
}) })
}) })
@ -497,13 +612,12 @@ impl ConfigureContextServerModal {
} }
fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) { fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
if matches!( if matches!(self.state, State::Waiting | State::Authenticating { .. }) {
self.state,
State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
) {
return; return;
} }
self._auth_subscription = None;
self.state = State::Idle; self.state = State::Idle;
let Some(workspace) = self.workspace.upgrade() else { let Some(workspace) = self.workspace.upgrade() else {
return; return;
@ -519,7 +633,7 @@ impl ConfigureContextServerModal {
self.state = State::Waiting; 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() { if existing_server.is_some() {
self.context_server_store.update(cx, |store, cx| { self.context_server_store.update(cx, |store, cx| {
store.stop_server(&id, cx).log_err(); store.stop_server(&id, cx).log_err();
@ -542,6 +656,13 @@ impl ConfigureContextServerModal {
this.state = State::AuthRequired { server_id: id }; this.state = State::AuthRequired { server_id: id };
cx.notify(); cx.notify();
} }
Ok(ContextServerStatus::ClientSecretRequired { error }) => {
this.state = State::ClientSecretRequired {
server_id: id,
error: error.map(SharedString::from),
};
cx.notify();
}
Err(err) => { Err(err) => {
this.set_error(err, cx); this.set_error(err, cx);
} }
@ -581,13 +702,33 @@ impl ConfigureContextServerModal {
cx.emit(DismissEvent); 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>) { fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
self.context_server_store.update(cx, |store, cx| { self.context_server_store.update(cx, |store, cx| {
store.authenticate_server(&server_id, cx).log_err(); 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 { self.state = State::Authenticating {
_server_id: server_id.clone(), server_id: server_id.clone(),
}; };
self._auth_subscription = Some(cx.subscribe( self._auth_subscription = Some(cx.subscribe(
@ -610,6 +751,14 @@ impl ConfigureContextServerModal {
}; };
cx.notify(); 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) => { ContextServerStatus::Error(error) => {
this._auth_subscription = None; this._auth_subscription = None;
this.set_error(error.clone(), cx); this.set_error(error.clone(), cx);
@ -814,10 +963,7 @@ impl ConfigureContextServerModal {
fn render_modal_footer(&self, cx: &mut Context<Self>) -> ModalFooter { fn render_modal_footer(&self, cx: &mut Context<Self>) -> ModalFooter {
let focus_handle = self.focus_handle(cx); let focus_handle = self.focus_handle(cx);
let is_busy = matches!( let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. });
self.state,
State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
);
ModalFooter::new() ModalFooter::new()
.start_slot::<Button>( .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 { fn render_modal_error(error: SharedString) -> Div {
h_flex() h_flex()
.h_8() .h_8()
@ -1003,8 +1255,15 @@ impl Render for ConfigureContextServerModal {
State::AuthRequired { server_id } => { State::AuthRequired { server_id } => {
self.render_auth_required(&server_id.clone(), cx) self.render_auth_required(&server_id.clone(), cx)
} }
State::Authenticating { .. } => { State::ClientSecretRequired { server_id, error } => {
self.render_loading("Authenticating…") 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) => { State::Error(error) => {
Self::render_modal_error(error.clone()) Self::render_modal_error(error.clone())
@ -1040,7 +1299,9 @@ fn wait_for_context_server(
} }
match status { match status {
ContextServerStatus::Running | ContextServerStatus::AuthRequired => { ContextServerStatus::Running
| ContextServerStatus::AuthRequired
| ContextServerStatus::ClientSecretRequired { .. } => {
if let Some(tx) = tx.lock().take() { if let Some(tx) = tx.lock().take() {
let _ = tx.send(Ok(status.clone())); let _ = tx.send(Ok(status.clone()));
} }
@ -1104,3 +1365,52 @@ pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle
..Default::default() ..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, NotInstalled,
InstalledRegistry, InstalledRegistry,
InstalledCustom, InstalledCustom,
InstalledExtension,
} }
#[derive(IntoElement)] #[derive(IntoElement)]
@ -155,9 +154,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledRegistry RegistryInstallStatus::InstalledRegistry
} }
CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom, CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
CustomAgentServerSettings::Extension { .. } => {
RegistryInstallStatus::InstalledExtension
}
}; };
self.installed_statuses.insert(id.clone(), status); self.installed_statuses.insert(id.clone(), status);
} }
@ -560,9 +556,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed") RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
.style(ButtonStyle::OutlinedGhost) .style(ButtonStyle::OutlinedGhost)
.disabled(true), .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_client_protocol::schema as acp;
use agent_settings::{AgentProfileId, AgentSettings}; use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter; 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 fs::Fs;
use gpui::{ 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::{ use language::{
LanguageRegistry, LanguageRegistry,
@ -57,6 +59,7 @@ use language_model::{
}; };
use project::{AgentId, DisableAiSettings}; use project::{AgentId, DisableAiSettings};
use prompt_store::{PromptBuilder, rules_to_skills_migration}; use prompt_store::{PromptBuilder, rules_to_skills_migration};
use rope::Point;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide}; use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
@ -112,6 +115,42 @@ pub(crate) fn resolve_agent_image(
None 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"; pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled"; const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
@ -244,6 +283,8 @@ actions!(
ScrollOutputToNextMessage, ScrollOutputToNextMessage,
/// Import agent threads from other Zed release channels (e.g. Preview, Nightly). /// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
ImportThreadsFromOtherChannels, ImportThreadsFromOtherChannels,
/// Starts a new terminal thread.
NewTerminalThread,
] ]
); );
@ -506,6 +547,7 @@ pub fn init(
) { ) {
agent::ThreadStore::init_global(cx); agent::ThreadStore::init_global(cx);
rules_library::init(cx); rules_library::init(cx);
skill_creator::init(cx);
if !is_eval { if !is_eval {
// Initializing the language model from the user settings messes with the eval, so we only initialize them when // 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. // we're not running inside of the eval.
@ -554,32 +596,6 @@ pub fn init(
); );
}) })
.detach(); .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(ManageProfilesModal::register).detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| { cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action( workspace.register_action(
@ -608,10 +624,16 @@ pub fn init(
}) })
.detach(); .detach();
// Once the `skills` feature flag has resolved, kick off the one-time // Kick off the one-time migration of non-Default Rules to global
// migration of non-Default Rules to global Skills. Idempotent and // Skills, deferred until server feature flags arrive.
// 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). // 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(); let fs = fs.clone();
cx.on_flags_ready(move |_, cx| { cx.on_flags_ready(move |_, cx| {
@ -653,12 +675,6 @@ fn update_command_palette_filter(cx: &mut App) {
.edit_predictions .edit_predictions
.provider; .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, _| { CommandPaletteFilter::update_global(cx, |filter, _| {
use editor::actions::{ use editor::actions::{
AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, 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_rules_library_action = [TypeId::of::<zed_actions::assistant::OpenRulesLibrary>()];
let open_skill_creator_action = [TypeId::of::<zed_actions::assistant::OpenSkillCreator>()];
if disable_ai { if disable_ai {
filter.hide_namespace("agent"); filter.hide_namespace("agent");
@ -726,15 +743,17 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_namespace("multi_workspace"); filter.show_namespace("multi_workspace");
} }
// Hide `assistant: open rules library` when Skills are enabled — // Hide `assistant: open rules library` — Rules are surfaced
// Rules are surfaced through the Skills UI in that case. Applied // through the Skills UI now. Applied after the disable-ai /
// after the disable-ai / agent-enabled branches so it overrides // agent-enabled branches so it overrides the
// the `show_namespace("assistant")` call above without affecting // `show_namespace("assistant")` call above without affecting the
// the rest of that namespace's actions. // rest of that namespace's actions.
if !disable_ai && skills_enabled { if !disable_ai {
filter.hide_action_types(&open_rules_library_action); filter.hide_action_types(&open_rules_library_action);
filter.show_action_types(open_skill_creator_action.iter());
} else { } else {
filter.show_action_types(open_rules_library_action.iter()); 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), !filter.is_hidden(&NewThread),
"NewThread should be visible by default" "NewThread should be visible by default"
); );
assert!(
!filter.is_hidden(&NewTerminalThread),
"NewTerminalThread should be visible by default"
);
}); });
// Disable agent // Disable agent
@ -891,6 +914,10 @@ mod tests {
filter.is_hidden(&NewThread), filter.is_hidden(&NewThread),
"NewThread should be hidden when agent is disabled" "NewThread should be hidden when agent is disabled"
); );
assert!(
filter.is_hidden(&NewTerminalThread),
"NewTerminalThread should be hidden when agent is disabled"
);
}); });
// Test EditPredictionProvider // Test EditPredictionProvider

View file

@ -1868,10 +1868,11 @@ impl MentionCompletion {
offset_to_line: usize, offset_to_line: usize,
supported_modes: &[PromptContextType], supported_modes: &[PromptContextType],
) -> Option<Self> { ) -> 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; let mut last_mention_start = None;
for (idx, _) in line.rmatch_indices('@') { for (idx, _) in line.rmatch_indices('@') {
// No whitespace immediately after '@' // No whitespace immediately after '@'.
if line[idx + 1..] if line[idx + 1..]
.chars() .chars()
.next() .next()
@ -1880,12 +1881,11 @@ impl MentionCompletion {
continue; continue;
} }
// Must be a word boundary before '@'
if idx > 0 if idx > 0
&& line[..idx] && line[..idx]
.chars() .chars()
.last() .last()
.is_some_and(|c| !c.is_whitespace()) .is_some_and(|c| !c.is_whitespace() && !matches!(c, '(' | '[' | '{'))
{ {
continue; continue;
} }
@ -2603,7 +2603,7 @@ fn completion_text_for_terminal_selections(
}; };
mention_set mention_set
.update(cx, |mention_set, _| { .update(cx, |mention_set, cx| {
mention_set.insert_mention( mention_set.insert_mention(
crease_id, crease_id,
mention_uri.clone(), mention_uri.clone(),
@ -2612,6 +2612,8 @@ fn completion_text_for_terminal_selections(
tracked_buffers: vec![], tracked_buffers: vec![],
})) }))
.shared(), .shared(),
None,
cx,
); );
}) })
.ok(); .ok();
@ -2958,6 +2960,39 @@ mod tests {
}), }),
"Should parse URL ending with @ (even if URL is incomplete)" "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] #[gpui::test]

View file

@ -40,14 +40,16 @@ use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
use markdown::{ use markdown::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
}; };
use parking_lot::RwLock; use parking_lot::{Mutex, RwLock};
use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
use prompt_store::{PromptId, PromptStore}; use prompt_store::{PromptId, PromptStore};
use crate::message_editor::SessionCapabilities; use crate::message_editor::SessionCapabilities;
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image}; use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
use lru::LruCache;
use rope::Point; use rope::Point;
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay}; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@ -61,11 +63,17 @@ use ui::{
KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
right_click_menu, right_click_menu,
}; };
use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use util::{
use util::{debug_panic, defer}; ResultExt, debug_panic, defer,
paths::{PathStyle, PathWithPosition},
rel_path::RelPath,
size::format_file_size,
time::duration_alt_display,
};
use workspace::PathList; use workspace::PathList;
use workspace::{ use workspace::{
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
path_link::sanitize_path_text,
}; };
use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary; use zed_actions::assistant::OpenRulesLibrary;
@ -509,6 +517,9 @@ pub struct ConversationView {
/// causes mermaid diagrams to re-render). /// causes mermaid diagrams to re-render).
last_theme_id: Option<String>, last_theme_id: Option<String>,
draft_prompt_persist_task: Option<Task<()>>, 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>, _subscriptions: Vec<Subscription>,
} }
@ -707,7 +718,8 @@ impl ConversationView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let agent_server_store = project.read(cx).agent_server_store().clone(); 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::agent_ui_font_size_changed),
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches), cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed), cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
@ -718,6 +730,20 @@ impl ConversationView {
Self::handle_agent_servers_updated, 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| { cx.on_release(|this, cx| {
if let Some(connected) = this.as_connected() { if let Some(connected) = this.as_connected() {
@ -764,6 +790,7 @@ impl ConversationView {
auth_task: None, auth_task: None,
last_theme_id: Some(cx.theme().id.clone()), last_theme_id: Some(cx.theme().id.clone()),
draft_prompt_persist_task: None, draft_prompt_persist_task: None,
code_span_resolver,
_subscriptions: subscriptions, _subscriptions: subscriptions,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
@ -1218,6 +1245,7 @@ impl ConversationView {
session_capabilities, session_capabilities,
resumed_without_history, resumed_without_history,
self.project.downgrade(), self.project.downgrade(),
self.code_span_resolver.clone(),
self.thread_store.clone(), self.thread_store.clone(),
self.prompt_store.clone(), self.prompt_store.clone(),
initial_content, initial_content,
@ -2511,7 +2539,7 @@ impl ConversationView {
markdown, markdown,
style, style,
&self.workspace, &self.workspace,
&self.project.downgrade(), &self.code_span_resolver,
cx, cx,
) )
} }
@ -2533,16 +2561,22 @@ impl ConversationView {
return false; return false;
}; };
multi_workspace.read(cx).sidebar_open() let multi_workspace = multi_workspace.read(cx);
|| multi_workspace.read(cx).workspace() == &workspace multi_workspace.sidebar_open() && multi_workspace.is_threads_list_view_active(cx)
&& AgentPanel::is_visible(&workspace, cx) || multi_workspace.workspace() == &workspace
&& multi_workspace && self.is_visible_in_agent_panel(&workspace, cx)
.read(cx) }
.workspace()
fn is_visible_in_agent_panel(&self, workspace: &Entity<Workspace>, cx: &Context<Self>) -> bool {
AgentPanel::is_visible(workspace, cx)
&& workspace
.read(cx) .read(cx)
.panel::<AgentPanel>(cx) .panel::<AgentPanel>(cx)
.map_or(false, |p| { .is_some_and(|panel| {
p.read(cx).active_conversation_view().map(|c| c.entity_id()) panel
.read(cx)
.visible_conversation_view()
.map(|conversation_view| conversation_view.entity_id())
== Some(cx.entity_id()) == Some(cx.entity_id())
}) })
} }
@ -2557,7 +2591,7 @@ impl ConversationView {
} else { } else {
self.workspace self.workspace
.upgrade() .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 { } else {
self.workspace self.workspace
.upgrade() .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); let settings = AgentSettings::get_global(cx);
if settings.play_sound_when_agent_done.should_play(visible) { if settings.play_sound_when_agent_done.should_play(visible) {
@ -3112,11 +3146,62 @@ fn render_agent_markdown(
markdown: Entity<Markdown>, markdown: Entity<Markdown>,
style: MarkdownStyle, style: MarkdownStyle,
workspace: &WeakEntity<Workspace>, workspace: &WeakEntity<Workspace>,
project: &WeakEntity<Project>, code_span_resolver: &AgentCodeSpanResolver,
cx: &App, cx: &App,
) -> MarkdownElement { ) -> MarkdownElement {
let workspace = workspace.clone(); let workspace = workspace.clone();
let worktree_roots: Vec<PathBuf> = project 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() .upgrade()
.map(|project| { .map(|project| {
project project
@ -3125,12 +3210,127 @@ fn render_agent_markdown(
.map(|worktree| worktree.read(cx).abs_path().to_path_buf()) .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default()
MarkdownElement::new(markdown, style) }
.image_resolver(move |dest_url| resolve_agent_image(dest_url, &worktree_roots))
.on_url_click(move |text, window, cx| { fn try_resolve(&self, text: &str, cx: &App) -> Option<SharedString> {
thread_view::open_link(text, &workspace, window, cx); 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( fn plan_label_markdown_style(
@ -3179,6 +3379,7 @@ pub(crate) mod tests {
use crate::agent_panel; use crate::agent_panel;
use crate::completion_provider::AgentContextSource; use crate::completion_provider::AgentContextSource;
use crate::test_support::register_test_sidebar;
use crate::thread_metadata_store::ThreadMetadataStore; use crate::thread_metadata_store::ThreadMetadataStore;
use super::*; 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] #[gpui::test]
async fn test_notification_for_stop_event(cx: &mut TestAppContext) { async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
@ -4164,6 +4441,7 @@ pub(crate) mod tests {
.unwrap(); .unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); 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. // Open the sidebar so that sidebar_open() returns true.
multi_workspace_handle 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] #[gpui::test]
async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) { async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
@ -4250,6 +4602,7 @@ pub(crate) mod tests {
.unwrap(); .unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); 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 thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
let connection_store = let connection_store =

View file

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

View file

@ -12,6 +12,7 @@ use agent_client_protocol::schema as acp;
use anyhow::Context as _; use anyhow::Context as _;
use db::kvp::KeyValueStore; use db::kvp::KeyValueStore;
use gpui::{App, AppContext as _, Entity, Task}; use gpui::{App, AppContext as _, Entity, Task};
use itertools::Itertools;
use ui::SharedString; use ui::SharedString;
use util::ResultExt as _; use util::ResultExt as _;
use workspace::Workspace; 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 kvp = KeyValueStore::global(cx);
let raw = kvp let raw = kvp
.scoped(NAMESPACE) .scoped(NAMESPACE)
.read(&thread_id_key(thread_id)) .read(&thread_id.to_key_string())
.log_err() .log_err()
.flatten()?; .flatten()?;
serde_json::from_str(&raw).log_err() serde_json::from_str(&raw).log_err()
@ -40,7 +41,7 @@ pub fn write(
cx: &App, cx: &App,
) -> Task<anyhow::Result<()>> { ) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx); 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") { let payload = match serde_json::to_string(prompt).context("serializing draft prompt") {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => return Task::ready(Err(err)), 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<()>> { pub fn delete(thread_id: ThreadId, cx: &App) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx); 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 }) cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await })
} }
fn thread_id_key(thread_id: ThreadId) -> String { pub fn draft_has_user_content<'a>(
thread_id.to_key_string() 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 /// 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()), acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()),
_ => None, _ => None,
}) })
.collect::<Vec<_>>()
.join(" "); .join(" ");
truncate_draft_label(&raw) truncate_draft_label(&raw)
} }

View file

@ -63,6 +63,7 @@ pub struct MentionSet {
thread_store: Option<Entity<ThreadStore>>, thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>, prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
crease_entities: HashMap<CreaseId, Entity<LoadingContext>>,
} }
impl MentionSet { impl MentionSet {
@ -76,6 +77,7 @@ impl MentionSet {
thread_store, thread_store,
prompt_store, prompt_store,
mentions: HashMap::default(), mentions: HashMap::default(),
crease_entities: HashMap::default(),
} }
} }
@ -110,12 +112,24 @@ impl MentionSet {
for (crease_id, crease) in snapshot.crease_snapshot.creases() { for (crease_id, crease) in snapshot.crease_snapshot.creases() {
if !crease.range().start.is_valid(snapshot.buffer_snapshot()) { if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
self.mentions.remove(&crease_id); 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)); 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. /// Creates the appropriate confirmation task for a mention based on its URI type.
@ -153,6 +167,7 @@ impl MentionSet {
MentionUri::Selection { MentionUri::Selection {
abs_path: Some(abs_path), abs_path: Some(abs_path),
line_range, line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx), } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!( MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
"Untitled buffer selection mentions are not supported for paste" "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.mentions.remove(crease_id);
self.crease_entities.remove(crease_id);
self.recompute_disambiguation(cx);
} }
pub fn creases(&self) -> HashSet<CreaseId> { pub fn creases(&self) -> HashSet<CreaseId> {
@ -196,13 +213,32 @@ impl MentionSet {
} }
pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) { pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
self.crease_entities
.retain(|id, _| mentions.contains_key(id));
self.mentions = mentions; self.mentions = mentions;
} }
pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> { pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
self.crease_entities.clear();
self.mentions.drain() 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( pub fn confirm_mention_completion(
&mut self, &mut self,
crease_text: SharedString, crease_text: SharedString,
@ -273,7 +309,7 @@ impl MentionSet {
cx, cx,
) )
}; };
let Some((crease_id, tx)) = crease else { let Some((crease_id, tx, crease_entity)) = crease else {
return Task::ready(()); return Task::ready(());
}; };
@ -325,6 +361,10 @@ impl MentionSet {
.spawn(async move |_, _| task.await.map_err(|e| e.to_string())) .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
.shared(); .shared();
self.mentions.insert(crease_id, (mention_uri, task.clone())); 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 // Notify the user if we failed to load the mentioned context
let workspace = workspace.downgrade(); let workspace = workspace.downgrade();
@ -338,6 +378,7 @@ impl MentionSet {
editor.edit([(start_anchor..end_anchor, "")], cx); editor.edit([(start_anchor..end_anchor, "")], cx);
}); });
this.mentions.remove(&crease_id); this.mentions.remove(&crease_id);
this.crease_entities.remove(&crease_id);
}) })
.ok(); .ok();
} }
@ -451,6 +492,14 @@ impl MentionSet {
skill_file_path: PathBuf, skill_file_path: PathBuf,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<Mention>> { ) -> 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 { cx.background_spawn(async move {
let content = std::fs::read_to_string(&skill_file_path).map_err(|e| { let content = std::fs::read_to_string(&skill_file_path).map_err(|e| {
anyhow!( anyhow!(
@ -522,6 +571,7 @@ impl MentionSet {
let uri = MentionUri::Selection { let uri = MentionUri::Selection {
abs_path: abs_path.clone(), abs_path: abs_path.clone(),
line_range: line_range.clone(), line_range: line_range.clone(),
column: None,
}; };
let crease = crease_for_mention( let crease = crease_for_mention(
selection_name(abs_path.as_deref(), &line_range).into(), 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -737,6 +807,7 @@ mod tests {
MentionUri::Selection { MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()), abs_path: Some(path!("/project/file.rs").into()),
line_range: 1..=2, line_range: 1..=2,
column: None,
}, },
false, false,
http_client, http_client,
@ -821,7 +892,7 @@ pub(crate) async fn insert_images_as_context(
snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
}); });
let image = Arc::new(image); 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( insert_crease_for_mention(
text_anchor, text_anchor,
content_len, content_len,
@ -856,13 +927,15 @@ pub(crate) async fn insert_images_as_context(
}) })
.shared(); .shared();
mention_set.update(cx, |mention_set, _cx| { mention_set.update(cx, |mention_set, cx| {
mention_set.insert_mention( mention_set.insert_mention(
crease_id, crease_id,
MentionUri::PastedImage { MentionUri::PastedImage {
name: name.to_string(), name: name.to_string(),
}, },
task.clone(), task.clone(),
crease_entity,
cx,
) )
}); });
@ -874,8 +947,8 @@ pub(crate) async fn insert_images_as_context(
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
editor.edit([(start_anchor..end_anchor, "")], cx); editor.edit([(start_anchor..end_anchor, "")], cx);
}); });
mention_set.update(cx, |mention_set, _cx| { mention_set.update(cx, |mention_set, cx| {
mention_set.remove_mention(&crease_id) mention_set.remove_mention(&crease_id, cx)
}); });
} }
} }
@ -991,7 +1064,11 @@ pub(crate) fn insert_crease_for_mention(
editor: Entity<Editor>, editor: Entity<Editor>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<(CreaseId, postage::barrier::Sender)> { ) -> Option<(
CreaseId,
postage::barrier::Sender,
Option<Entity<LoadingContext>>,
)> {
let (tx, rx) = postage::barrier::channel(); let (tx, rx) = postage::barrier::channel();
let crease_id = editor.update(cx, |editor, cx| { let crease_id = editor.update(cx, |editor, cx| {
@ -1002,8 +1079,7 @@ pub(crate) fn insert_crease_for_mention(
let start = start.bias_right(&snapshot); let start = start.bias_right(&snapshot);
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder { let (render, crease_entity) = render_mention_fold_button(
render: render_mention_fold_button(
crease_label.clone(), crease_label.clone(),
crease_icon.clone(), crease_icon.clone(),
crease_tooltip, crease_tooltip,
@ -1014,7 +1090,9 @@ pub(crate) fn insert_crease_for_mention(
image, image,
cx.weak_entity(), cx.weak_entity(),
cx, cx,
), );
let placeholder = FoldPlaceholder {
render,
merge_adjacent: false, merge_adjacent: false,
..Default::default() ..Default::default()
}; };
@ -1033,10 +1111,11 @@ pub(crate) fn insert_crease_for_mention(
let ids = editor.insert_creases(vec![crease.clone()], cx); let ids = editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, 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( pub(crate) fn crease_for_mention(
@ -1215,7 +1294,10 @@ fn render_mention_fold_button(
image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>, image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
editor: WeakEntity<Editor>, editor: WeakEntity<Editor>,
cx: &mut App, 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.new(|cx| {
let loading = cx.spawn(async move |this, cx| { let loading = cx.spawn(async move |this, cx| {
loading_finished.recv().await; loading_finished.recv().await;
@ -1238,10 +1320,13 @@ fn render_mention_fold_button(
image: image_task.clone(), 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, id: EntityId,
label: SharedString, label: SharedString,
icon: SharedString, icon: SharedString,

View file

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

View file

@ -10,6 +10,7 @@ use db::{
}, },
sqlez_macros::sql, sqlez_macros::sql,
}; };
use futures::{FutureExt, future::Shared};
use gpui::{AppContext as _, Entity, Global, Task}; use gpui::{AppContext as _, Entity, Global, Task};
use remote::{RemoteConnectionOptions, same_remote_connection_identity}; use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use ui::{App, Context, SharedString}; use ui::{App, Context, SharedString};
@ -69,6 +70,7 @@ pub struct TerminalThreadMetadataStore {
terminals: HashMap<TerminalId, TerminalThreadMetadata>, terminals: HashMap<TerminalId, TerminalThreadMetadata>,
terminals_by_paths: HashMap<PathList, HashSet<TerminalId>>, terminals_by_paths: HashMap<PathList, HashSet<TerminalId>>,
terminals_by_main_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>, pending_terminal_ops_tx: async_channel::Sender<DbOperation>,
_db_operations_task: Task<()>, _db_operations_task: Task<()>,
} }
@ -125,6 +127,12 @@ impl TerminalThreadMetadataStore {
self.terminals.values() 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>( pub fn entries_for_path<'a>(
&'a self, &'a self,
path_list: &PathList, path_list: &PathList,
@ -312,6 +320,7 @@ impl TerminalThreadMetadataStore {
terminals: HashMap::default(), terminals: HashMap::default(),
terminals_by_paths: HashMap::default(), terminals_by_paths: HashMap::default(),
terminals_by_main_paths: HashMap::default(), terminals_by_main_paths: HashMap::default(),
reload_task: None,
pending_terminal_ops_tx: tx, pending_terminal_ops_tx: tx,
_db_operations_task, _db_operations_task,
}; };
@ -332,6 +341,7 @@ impl TerminalThreadMetadataStore {
fn reload(&mut self, cx: &mut Context<Self>) { fn reload(&mut self, cx: &mut Context<Self>) {
let db = self.db.clone(); let db = self.db.clone();
self.reload_task = Some(
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let rows = cx let rows = cx
.background_spawn(async move { .background_spawn(async move {
@ -355,7 +365,8 @@ impl TerminalThreadMetadataStore {
}) })
.ok(); .ok();
}) })
.detach(); .shared(),
);
} }
} }

View file

@ -1,13 +1,17 @@
use acp_thread::{AgentConnection, StubAgentConnection}; use acp_thread::{AgentConnection, StubAgentConnection};
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
use agent_servers::{AgentServer, AgentServerDelegate}; 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::AgentId;
use project::Project; use project::Project;
use settings::SettingsStore; use settings::SettingsStore;
use std::any::Any; use std::any::Any;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use workspace::{MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent, SidebarSide};
use crate::AgentPanel; use crate::AgentPanel;
use crate::agent_panel; 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( pub fn open_thread_with_connection(
panel: &Entity<AgentPanel>, panel: &Entity<AgentPanel>,
connection: StubAgentConnection, connection: StubAgentConnection,

View file

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

View file

@ -831,6 +831,27 @@ pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
.collect() .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>> { fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
cx.update(|cx| { cx.update(|cx| {
all_open_workspaces(cx) all_open_workspaces(cx)

View file

@ -3,7 +3,6 @@ mod end_trial_upsell;
mod hold_for_default; mod hold_for_default;
mod mention_crease; mod mention_crease;
mod model_selector_components; mod model_selector_components;
mod rules_to_skills_modal;
mod undo_reject_toast; mod undo_reject_toast;
pub use agent_notification::*; pub use agent_notification::*;
@ -11,7 +10,6 @@ pub use end_trial_upsell::*;
pub use hold_for_default::*; pub use hold_for_default::*;
pub use mention_crease::*; pub use mention_crease::*;
pub use model_selector_components::*; pub use model_selector_components::*;
pub use rules_to_skills_modal::*;
pub use undo_reject_toast::*; pub use undo_reject_toast::*;
/// Returns the appropriate [`DocumentationSide`] for documentation asides /// 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 acp_thread::MentionUri;
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use editor::Editor;
use gpui::{ use gpui::{
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window, Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
pulsating_between, pulsating_between,
}; };
use language::Buffer;
use prompt_store::PromptId; use prompt_store::PromptId;
use rope::Point; use rope::Point;
use settings::Settings; use settings::Settings;
@ -14,6 +15,8 @@ use theme_settings::ThemeSettings;
use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
use workspace::{OpenOptions, Workspace}; use workspace::{OpenOptions, Workspace};
use crate::open_abs_path_at_point;
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct MentionCrease { pub struct MentionCrease {
id: ElementId, id: ElementId,
@ -164,12 +167,27 @@ fn open_mention_uri(
abs_path, abs_path,
line_range, 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), abs_path: Some(abs_path),
line_range, 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 } => { MentionUri::Directory { abs_path } => {
reveal_in_project_panel(workspace, abs_path, cx); reveal_in_project_panel(workspace, abs_path, cx);
@ -203,6 +221,46 @@ fn open_skill_file(
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, 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 workspace
.open_abs_path( .open_abs_path(
skill_file_path, skill_file_path,
@ -219,40 +277,23 @@ fn open_skill_file(
fn open_file( fn open_file(
workspace: &mut Workspace, workspace: &mut Workspace,
abs_path: PathBuf, abs_path: PathBuf,
line_range: Option<RangeInclusive<u32>>, point: Option<Point>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, 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) = if let Some(project_path) =
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx)) project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
{ {
let item = workspace.open_path(project_path, None, true, window, cx); workspace
if let Some(line_range) = line_range { .open_path(project_path, None, true, window, cx)
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); .detach_and_log_err(cx);
} else {
item.detach_and_log_err(cx);
}
} else if abs_path.exists() { } else if abs_path.exists() {
workspace workspace
.open_abs_path( .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" path = "src/auto_update_ui.rs"
[dependencies] [dependencies]
agent_settings.workspace = true agent_skills.workspace = true
anyhow.workspace = true anyhow.workspace = true
auto_update.workspace = true auto_update.workspace = true
client.workspace = true client.workspace = true
db.workspace = true db.workspace = true
editor.workspace = true editor.workspace = true
fs.workspace = true
gpui.workspace = true gpui.workspace = true
markdown_preview.workspace = true markdown_preview.workspace = true
notifications.workspace = true notifications.workspace = true
project.workspace = true prompt_store.workspace = true
release_channel.workspace = true release_channel.workspace = true
semver.workspace = true semver.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
settings.workspace = true
smol.workspace = true smol.workspace = true
telemetry.workspace = true telemetry.workspace = true
ui.workspace = true ui.workspace = true

View file

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

View file

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

View file

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

View file

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

View file

@ -334,6 +334,7 @@ struct ClientState {
credentials: Option<Credentials>, credentials: Option<Credentials>,
status: (watch::Sender<Status>, watch::Receiver<Status>), status: (watch::Sender<Status>, watch::Receiver<Status>),
_reconnect_task: Option<Task<()>>, _reconnect_task: Option<Task<()>>,
_cloud_connection_task: Option<Task<()>>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@ -435,6 +436,7 @@ impl Default for ClientState {
credentials: None, credentials: None,
status: watch::channel_with(Status::SignedOut), status: watch::channel_with(Status::SignedOut),
_reconnect_task: None, _reconnect_task: None,
_cloud_connection_task: None,
} }
} }
} }
@ -607,6 +609,7 @@ impl Client {
pub fn teardown(&self) { pub fn teardown(&self) {
let mut state = self.state.write(); let mut state = self.state.write();
state._reconnect_task.take(); state._reconnect_task.take();
state._cloud_connection_task.take();
self.handler_set.lock().clear(); self.handler_set.lock().clear();
self.peer.teardown(); self.peer.teardown();
} }
@ -724,6 +727,7 @@ impl Client {
Status::SignedOut | Status::UpgradeRequired => { Status::SignedOut | Status::UpgradeRequired => {
self.telemetry.set_authenticated_user_info(None, false); self.telemetry.set_authenticated_user_info(None, false);
state._reconnect_task.take(); 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. /// Maintains a WebSocket connection with Cloud for receiving updates from the server.
async fn connect_to_cloud(self: &Arc<Self>, cx: &AsyncApp) -> Result<()> { ///
/// 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 connect_task = cx.update({
let cloud_client = self.cloud_client.clone(); let cloud_client = self.cloud_client.clone();
move |cx| cloud_client.connect(cx) move |cx| cloud_client.connect(cx)
})?; })?;
let connection = connect_task.await?; let connection = connect_task.await?;
let (mut messages, task) = cx.update(|cx| connection.spawn(cx)); let (mut messages, _cloud_io_task) = cx.update(|cx| connection.spawn(cx));
task.detach();
cx.spawn({
let this = self.clone();
async move |cx| {
while let Some(message) = messages.next().await { while let Some(message) = messages.next().await {
if let Some(message) = message.log_err() { if let Some(message) = message.log_err() {
this.handle_message_to_client(message, cx); self.handle_message_to_client(message, cx);
} }
} }
}
})
.detach();
Ok(()) Ok(())
} }
@ -1009,7 +1041,7 @@ impl Client {
let credentials = self.sign_in(try_provider, cx).await?; 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.update(move |cx| {
cx.spawn({ cx.spawn({

View file

@ -4,19 +4,19 @@ use chrono::{DateTime, Utc};
use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{ use cloud_api_client::{
GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo, GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo,
UpdateSystemSettingsBody,
}; };
use cloud_api_types::OrganizationConfiguration; use cloud_api_types::OrganizationConfiguration;
use cloud_llm_client::{ use cloud_llm_client::{
EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
}; };
use collections::{HashMap, HashSet, hash_map::Entry}; use collections::{HashMap, HashSet, hash_map::Entry};
use db::kvp::KeyValueStore;
use derive_more::Deref; use derive_more::Deref;
use feature_flags::FeatureFlagAppExt; use feature_flags::FeatureFlagAppExt;
use futures::{Future, StreamExt, channel::mpsc}; use futures::{Future, StreamExt, channel::mpsc};
use gpui::{ use gpui::{
App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, TaskExt, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task,
WeakEntity, TaskExt, WeakEntity,
}; };
use http_client::http::{HeaderMap, HeaderValue}; use http_client::http::{HeaderMap, HeaderValue};
use postage::{sink::Sink, watch}; use postage::{sink::Sink, watch};
@ -28,8 +28,6 @@ use std::{
use text::ReplicaId; use text::ReplicaId;
use util::{ResultExt, TryFutureExt as _}; use util::{ResultExt, TryFutureExt as _};
const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id";
pub type LegacyUserId = u64; pub type LegacyUserId = u64;
#[derive( #[derive(
@ -708,24 +706,41 @@ impl UserStore {
&mut self, &mut self,
organization: Arc<Organization>, organization: Arc<Organization>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) -> Task<Result<()>> {
let is_same_organization = self let is_same_organization = self
.current_organization .current_organization
.as_ref() .as_ref()
.is_some_and(|current| current.id == organization.id); .is_some_and(|current| current.id == organization.id);
if !is_same_organization { if is_same_organization {
let organization_id = organization.id.0.to_string(); return Task::ready(Ok(()));
}
let organization_id = organization.id.clone();
self.current_organization.replace(organization); self.current_organization.replace(organization);
cx.emit(Event::OrganizationChanged); cx.emit(Event::OrganizationChanged);
cx.notify(); cx.notify();
let kvp = KeyValueStore::global(cx); let Some(client) = self.client.upgrade() else {
db::write_and_log(cx, move || async move { return Task::ready(Ok(()));
kvp.write_kvp(CURRENT_ORGANIZATION_ID_KEY.into(), organization_id) };
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 .await
}); .context("failed to persist selected organization")?;
} Ok(())
})
} }
pub fn organizations(&self) -> &Vec<Arc<Organization>> { pub fn organizations(&self) -> &Vec<Arc<Organization>> {
@ -861,21 +876,8 @@ impl UserStore {
} }
self.organizations = response.organizations.into_iter().map(Arc::new).collect(); 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 self.current_organization = response
.and_then(|persisted_id| {
self.organizations
.iter()
.find(|org| org.id == persisted_id)
.cloned()
})
.or_else(|| {
response
.default_organization_id .default_organization_id
.and_then(|default_organization_id| { .and_then(|default_organization_id| {
self.organizations self.organizations
@ -883,7 +885,6 @@ impl UserStore {
.find(|organization| organization.id == default_organization_id) .find(|organization| organization.id == default_organization_id)
.cloned() .cloned()
}) })
})
.or_else(|| self.organizations.first().cloned()); .or_else(|| self.organizations.first().cloned());
self.plans_by_organization = response self.plans_by_organization = response
.plans_by_organization .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. /// Returns the URL to Zed's ACP registry blog post.
pub fn acp_registry_blog(cx: &App) -> String { pub fn acp_registry_blog(cx: &App) -> String {
format!( 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 { pub fn shared_agent_thread_url(session_id: &str) -> String {
format!("zed://agent/shared/{}", session_id) 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())) 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> { pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result<bool> {
let request = build_request( let request = build_request(
Request::builder().method(Method::GET).uri( Request::builder().method(Method::GET).uri(

View file

@ -87,6 +87,16 @@ pub struct CreateLlmTokenResponse {
pub token: LlmToken, 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)] #[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct SubmitAgentThreadFeedbackBody { pub struct SubmitAgentThreadFeedbackBody {
pub organization_id: Option<OrganizationId>, pub organization_id: Option<OrganizationId>,

View file

@ -42,8 +42,11 @@ pub enum ExtensionProvides {
Grammars, Grammars,
LanguageServers, LanguageServers,
ContextServers, ContextServers,
/// Deprecated
AgentServers, AgentServers,
/// Deprecated
SlashCommands, SlashCommands,
/// Deprecated
IndexedDocsProviders, IndexedDocsProviders,
Snippets, Snippets,
DebugAdapters, 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] #[track_caller]
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) { fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
let has_shared_screen_tab = workspace 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. /// Build the form-encoded body for an authorization code token exchange.
pub fn token_exchange_params( pub fn token_exchange_params(
code: &str, code: &str,
@ -640,15 +660,20 @@ pub fn token_exchange_params(
redirect_uri: &str, redirect_uri: &str,
code_verifier: &str, code_verifier: &str,
resource: &str, resource: &str,
client_secret: Option<&str>,
) -> Vec<(&'static str, String)> { ) -> Vec<(&'static str, String)> {
vec![ let mut params = vec![
("grant_type", "authorization_code".to_string()), ("grant_type", "authorization_code".to_string()),
("code", code.to_string()), ("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()), ("redirect_uri", redirect_uri.to_string()),
("client_id", client_id.to_string()), ("client_id", client_id.to_string()),
("code_verifier", code_verifier.to_string()), ("code_verifier", code_verifier.to_string()),
("resource", resource.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. /// Build the form-encoded body for a token refresh request.
@ -656,13 +681,18 @@ pub fn token_refresh_params(
refresh_token: &str, refresh_token: &str,
client_id: &str, client_id: &str,
resource: &str, resource: &str,
client_secret: Option<&str>,
) -> Vec<(&'static str, String)> { ) -> Vec<(&'static str, String)> {
vec![ let mut params = vec![
("grant_type", "refresh_token".to_string()), ("grant_type", "refresh_token".to_string()),
("refresh_token", refresh_token.to_string()), ("refresh_token", refresh_token.to_string()),
("client_id", client_id.to_string()), ("client_id", client_id.to_string()),
("resource", resource.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) --------------------------------------------- // -- DCR request body (RFC 7591) ---------------------------------------------
@ -782,6 +812,7 @@ pub async fn fetch_auth_server_metadata(
match fetch_json::<AuthServerMetadataResponse>(http_client, url).await { match fetch_json::<AuthServerMetadataResponse>(http_client, url).await {
Ok(response) => { Ok(response) => {
let reported_issuer = response.issuer.unwrap_or_else(|| issuer.clone()); let reported_issuer = response.issuer.unwrap_or_else(|| issuer.clone());
if reported_issuer != *issuer { if reported_issuer != *issuer {
bail!( bail!(
"Auth server metadata issuer mismatch: expected {}, got {}", "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"), 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); let scopes = select_scopes(www_authenticate, &resource_metadata);
Ok(OAuthDiscovery { Ok(OAuthDiscovery {
@ -956,8 +978,16 @@ pub async fn exchange_code(
redirect_uri: &str, redirect_uri: &str,
code_verifier: &str, code_verifier: &str,
resource: &str, resource: &str,
client_secret: Option<&str>,
) -> Result<OAuthTokens> { ) -> 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 post_token_request(http_client, &auth_server_metadata.token_endpoint, &params).await
} }
@ -968,8 +998,9 @@ pub async fn refresh_tokens(
refresh_token: &str, refresh_token: &str,
client_id: &str, client_id: &str,
resource: &str, resource: &str,
client_secret: Option<&str>,
) -> Result<OAuthTokens> { ) -> 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 post_token_request(http_client, token_endpoint, &params).await
} }
@ -997,11 +1028,12 @@ async fn post_token_request(
if !response.status().is_success() { if !response.status().is_success() {
let mut error_body = String::new(); let mut error_body = String::new();
response.body_mut().read_to_string(&mut error_body).await?; response.body_mut().read_to_string(&mut error_body).await?;
bail!( let status = response.status();
"token request failed with status {}: {}", // Try to parse as an OAuth error response (RFC 6749 Section 5.2).
response.status(), if let Ok(token_error) = serde_json::from_str::<OAuthTokenError>(&error_body) {
error_body return Err(token_error.into());
); }
bail!("token request failed with status {status}: {error_body}");
} }
let mut response_body = String::new(); let mut response_body = String::new();
@ -1198,7 +1230,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
} }
async fn try_refresh(&self) -> Result<bool> { 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(); let session = self.session.lock();
match session.tokens.refresh_token.clone() { match session.tokens.refresh_token.clone() {
Some(refresh_token) => ( Some(refresh_token) => (
@ -1206,6 +1238,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
session.token_endpoint.clone(), session.token_endpoint.clone(),
session.resource.clone(), session.resource.clone(),
session.client_registration.client_id.clone(), session.client_registration.client_id.clone(),
session.client_registration.client_secret.clone(),
), ),
None => return Ok(false), None => return Ok(false),
} }
@ -1219,6 +1252,7 @@ impl OAuthTokenProvider for McpOAuthTokenProvider {
&refresh_token, &refresh_token,
&client_id, &client_id,
&resource_str, &resource_str,
client_secret.as_deref(),
) )
.await .await
{ {
@ -1801,6 +1835,7 @@ mod tests {
"http://127.0.0.1:5555/callback", "http://127.0.0.1:5555/callback",
"verifier_123", "verifier_123",
"https://mcp.example.com", "https://mcp.example.com",
None,
); );
let map: std::collections::HashMap<&str, &str> = let map: std::collections::HashMap<&str, &str> =
params.iter().map(|(k, v)| (*k, v.as_str())).collect(); params.iter().map(|(k, v)| (*k, v.as_str())).collect();
@ -1815,8 +1850,12 @@ mod tests {
#[test] #[test]
fn test_token_refresh_params() { fn test_token_refresh_params() {
let params = let params = token_refresh_params(
token_refresh_params("refresh_token_abc", "client_xyz", "https://mcp.example.com"); "refresh_token_abc",
"client_xyz",
"https://mcp.example.com",
None,
);
let map: std::collections::HashMap<&str, &str> = let map: std::collections::HashMap<&str, &str> =
params.iter().map(|(k, v)| (*k, v.as_str())).collect(); params.iter().map(|(k, v)| (*k, v.as_str())).collect();
@ -2422,6 +2461,7 @@ mod tests {
"http://127.0.0.1:9999/callback", "http://127.0.0.1:9999/callback",
"verifier_abc", "verifier_abc",
"https://mcp.example.com", "https://mcp.example.com",
None,
) )
.await .await
.unwrap(); .unwrap();
@ -2461,6 +2501,7 @@ mod tests {
"old_refresh_token", "old_refresh_token",
CIMD_URL, CIMD_URL,
"https://mcp.example.com", "https://mcp.example.com",
None,
) )
.await .await
.unwrap(); .unwrap();
@ -2497,11 +2538,21 @@ mod tests {
"http://127.0.0.1:1/callback", "http://127.0.0.1:1/callback",
"verifier", "verifier",
"https://mcp.example.com", "https://mcp.example.com",
None,
) )
.await; .await;
assert!(result.is_err()); let err = result.unwrap_err();
assert!(result.unwrap_err().to_string().contains("400")); 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; .await;
if should_install { if should_install {
node_runtime node_runtime
.npm_install_packages( .npm_install_latest_packages(paths::copilot_dir(), &[PACKAGE_NAME])
paths::copilot_dir(),
&[(PACKAGE_NAME, &latest_version.to_string())],
)
.await?; .await?;
} }

View file

@ -240,6 +240,7 @@ impl DiagnosticBlock {
) )
.code_block_renderer(markdown::CodeBlockRenderer::Default { .code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden, copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false, border: false,
}) })
.on_url_click({ .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 anyhow::Result;
use buffer_diff::BufferDiffSnapshot; use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use collections::HashMap; use collections::HashMap;
use gpui::{App, Entity, Task}; use gpui::{App, Entity, Task};
use language::Buffer; 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 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( pub fn capture_example(
project: Entity<Project>, project: Entity<Project>,
buffer: Entity<Buffer>, buffer: Entity<Buffer>,
cursor_anchor: language::Anchor, 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, populate_expected_patch: bool,
cx: &mut App, cx: &mut App,
) -> Option<Task<Result<ExampleSpec>>> { ) -> Option<Task<Result<ExampleSpec>>> {
@ -34,16 +40,39 @@ pub fn capture_example(
.or_else(|| repository_snapshot.remote_upstream_url.clone())?; .or_else(|| repository_snapshot.remote_upstream_url.clone())?;
let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string(); 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| { if let hash_map::Entry::Vacant(entry) = diff_buffers_by_path.entry(relative_path) {
let snapshots_by_path = let Some(diff) = uncommitted_diffs_by_path.get(entry.key()).cloned() else {
collect_snapshots(&project, &git_store, worktree_id, &events, &mut cx).await?; continue;
};
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})
.await?;
entry.insert((buffer, diff));
}
}
events.retain(|stored_event| { events.retain(|stored_event| {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref(); let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
let relative_path = strip_root_name(path, &root_name); 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 let line_comment_prefix = snapshot
@ -56,9 +85,17 @@ pub fn capture_example(
.background_executor() .background_executor()
.spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) }) .spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) })
.await; .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 let uncommitted_diff = cx
.background_executor() .background_executor()
.spawn(async move { compute_uncommitted_diff(snapshots_by_path) }) .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshots) })
.await; .await;
let mut edit_history = String::new(); let mut edit_history = String::new();
@ -68,6 +105,7 @@ pub fn capture_example(
edit_history.push('\n'); 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 // Initialize an empty patch with context lines, to make it easy
// to write the expected patch by hand. // to write the expected patch by hand.
@ -100,6 +138,9 @@ pub fn capture_example(
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff, uncommitted_diff,
recently_opened_files,
recently_viewed_files,
uncommitted_diff_contains_edit_history,
cursor_path, cursor_path,
cursor_position: String::new(), cursor_position: String::new(),
edit_history, 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( fn compute_uncommitted_diff(
snapshots_by_path: HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>, snapshots_by_path: HashMap<Arc<Path>, (language::BufferSnapshot, BufferDiffSnapshot)>,
) -> String { ) -> String {
let mut uncommitted_diff = String::new(); let mut uncommitted_diff = String::new();
for (relative_path, (before_text, diff_snapshot)) in snapshots_by_path { let mut snapshots_by_path = snapshots_by_path.into_iter().collect::<Vec<_>>();
if let Some(head_text) = &diff_snapshot.base_text_string() { snapshots_by_path.sort_by(|(left_path, _), (right_path, _)| left_path.cmp(right_path));
let file_diff = language::unified_diff(head_text, &before_text.text()); for (relative_path, (buffer_snapshot, diff_snapshot)) in snapshots_by_path {
if !file_diff.is_empty() { let base_snapshot = diff_snapshot.base_text();
let path_str = relative_path.to_string_lossy(); let is_existing_file = diff_snapshot.base_text_exists();
writeln!(uncommitted_diff, "--- a/{path_str}").ok();
writeln!(uncommitted_diff, "+++ b/{path_str}").ok(); let new_path_str = relative_path.to_string_lossy();
uncommitted_diff.push_str(&file_diff); 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') { if !uncommitted_diff.ends_with('\n') {
uncommitted_diff.push('\n'); uncommitted_diff.push('\n');
} }
} }
}
}
uncommitted_diff 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 { fn generate_timestamp_name() -> String {
let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
match format { match format {
@ -309,7 +394,9 @@ mod tests {
json!({ json!({
".git": {}, ".git": {},
"src": { "src": {
"deleted.rs": "pub fn deleted_file() {\n deleted();\n}\n",
"main.rs": disk_contents, "main.rs": disk_contents,
"new.rs": "pub fn new_file() {\n}\n",
} }
}), }),
) )
@ -326,7 +413,13 @@ mod tests {
fs.set_head_for_repo( fs.set_head_for_repo(
Path::new("/project/.git"), 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", "abc123def456",
); );
fs.set_remote_for_repo( fs.set_remote_for_repo(
@ -350,6 +443,21 @@ mod tests {
}); });
cx.run_until_parked(); 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| { buffer.update(cx, |buffer, cx| {
let point = Point::new(6, 0); let point = Point::new(6, 0);
buffer.edit([(point..point, " // comment 3\n")], None, cx); buffer.edit([(point..point, " // comment 3\n")], None, cx);
@ -379,6 +487,22 @@ mod tests {
}); });
cx.run_until_parked(); 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) // Open and edit an external file (outside the main project's worktree)
let external_buffer = project let external_buffer = project
.update(cx, |project, cx| { .update(cx, |project, cx| {
@ -410,6 +534,13 @@ mod tests {
"external file edit should be in events" "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 let mut example = cx
.update(|cx| { .update(|cx| {
capture_example( capture_example(
@ -417,6 +548,9 @@ mod tests {
buffer.clone(), buffer.clone(),
Anchor::min_for_buffer(buffer.read(cx).remote_id()), Anchor::min_for_buffer(buffer.read(cx).remote_id()),
events, events,
Vec::new(),
Vec::new(),
uncommitted_diffs_by_path,
true, true,
cx, cx,
) )
@ -435,23 +569,41 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: indoc! {" uncommitted_diff: indoc! {"
--- a/src/deleted.rs
+++ b/src/deleted.rs
@@ -1,3 +1,0 @@
-pub fn deleted_file() {
- deleted();
-}
--- a/src/main.rs --- a/src/main.rs
+++ b/src/main.rs +++ b/src/main.rs
@@ -1,4 +1,5 @@ @@ -1,11 +1,15 @@
fn main() { fn main() {
+ // comment 1 + // comment 1
one(); one();
two(); two();
+ // comment 4
three(); three();
@@ -7,5 +8,6 @@ four();
+ // comment 3
five();
six(); six();
seven(); seven();
eight(); eight();
+ // comment 2 + // comment 2
nine(); nine();
} }
--- /dev/null
+++ b/src/new.rs
@@ -0,0 +1,3 @@
+pub fn new_file() {
+ created();
+}
"} "}
.to_string(), .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_path: Path::new("src/main.rs").into(),
cursor_position: indoc! {" cursor_position: indoc! {"
fn main() { fn main() {
@ -473,6 +625,12 @@ mod tests {
"} "}
.to_string(), .to_string(),
edit_history: indoc! {" edit_history: indoc! {"
--- a/src/deleted.rs
+++ b/src/deleted.rs
@@ -1,3 +1,0 @@
-pub fn deleted_file() {
- deleted();
-}
--- a/src/main.rs --- a/src/main.rs
+++ b/src/main.rs +++ b/src/main.rs
@@ -2,8 +2,10 @@ @@ -2,8 +2,10 @@
@ -486,6 +644,12 @@ mod tests {
five(); five();
six(); six();
seven(); seven();
--- a/src/new.rs
+++ b/src/new.rs
@@ -1,2 +1,3 @@
pub fn new_file() {
+ created();
}
"} "}
.to_string(), .to_string(),
expected_patches: vec![ expected_patches: vec![

View file

@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use buffer_diff::BufferDiff;
use client::{Client, EditPredictionUsage, UserStore, global_llm_token}; use client::{Client, EditPredictionUsage, UserStore, global_llm_token};
use cloud_api_client::LlmApiToken; use cloud_api_client::LlmApiToken;
use cloud_api_types::{ use cloud_api_types::{
@ -88,6 +89,7 @@ mod edit_prediction_tests;
use crate::cursor_excerpt::expand_context_syntactically_then_linewise; use crate::cursor_excerpt::expand_context_syntactically_then_linewise;
use crate::example_spec::ExampleSpec; use crate::example_spec::ExampleSpec;
use crate::example_spec::RecentFile;
use crate::license_detection::LicenseDetectionWatcher; use crate::license_detection::LicenseDetectionWatcher;
use crate::mercury::Mercury; use crate::mercury::Mercury;
pub use crate::metrics::{KeptRateResult, compute_kept_rate}; 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::EditPrediction;
pub use crate::prediction::EditPredictionId; pub use crate::prediction::EditPredictionId;
use crate::prediction::EditPredictionResult; use crate::prediction::EditPredictionResult;
pub use capture_example::capture_example;
pub use language_model::ApiKeyState; pub use language_model::ApiKeyState;
pub use telemetry_events::EditPredictionRating; pub use telemetry_events::EditPredictionRating;
pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@ -238,6 +239,7 @@ pub struct StoredEvent {
pub old_snapshot: TextBufferSnapshot, pub old_snapshot: TextBufferSnapshot,
pub new_snapshot_version: clock::Global, pub new_snapshot_version: clock::Global,
pub total_edit_range: Range<Anchor>, pub total_edit_range: Range<Anchor>,
pub uncommitted_diff: Option<Entity<BufferDiff>>,
} }
impl StoredEvent { impl StoredEvent {
@ -572,6 +574,7 @@ impl LastEvent {
new_snapshot_version: self.new_snapshot.version.clone(), new_snapshot_version: self.new_snapshot.version.clone(),
total_edit_range: self.new_snapshot.anchor_before(edit_range.start) total_edit_range: self.new_snapshot.anchor_before(edit_range.start)
..self.new_snapshot.anchor_before(edit_range.end), ..self.new_snapshot.anchor_before(edit_range.end),
uncommitted_diff: None,
}) })
} }
} }
@ -1007,6 +1010,89 @@ impl EditPredictionStore {
.unwrap_or_default() .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>( pub fn context_for_project<'a>(
&'a self, &'a self,
project: &Entity<Project>, project: &Entity<Project>,
@ -2475,6 +2561,7 @@ impl EditPredictionStore {
&& is_open_source && is_open_source
&& self.is_data_collection_enabled(cx) && self.is_data_collection_enabled(cx)
&& matches!(self.edit_prediction_model, EditPredictionModel::Zeta); && matches!(self.edit_prediction_model, EditPredictionModel::Zeta);
let capture_worktree_id = snapshot.file().map(|file| file.worktree_id(cx));
let inputs = EditPredictionModelInput { let inputs = EditPredictionModelInput {
project: project.clone(), project: project.clone(),
buffer: active_buffer, buffer: active_buffer,
@ -2490,11 +2577,24 @@ impl EditPredictionStore {
is_open_source, is_open_source,
}; };
let capture_data = (can_collect_data && rand::random_ratio(1, 1000)).then(|| stored_events);
let task = match self.edit_prediction_model { let task = match self.edit_prediction_model {
EditPredictionModel::Zeta => { 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::Fim { format } => fim::request_prediction(inputs, format, cx),
EditPredictionModel::Mercury => { EditPredictionModel::Mercury => {
@ -2815,6 +2915,42 @@ impl EditPredictionStore {
project_state.recent_paths = paths.into_iter().collect(); 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( fn is_file_open_source(
&self, &self,
project: &Entity<Project>, project: &Entity<Project>,
@ -3035,6 +3171,7 @@ fn merge_trailing_events_if_needed(
new_snapshot_version: newest_snapshot.version.clone(), new_snapshot_version: newest_snapshot.version.clone(),
total_edit_range: newest_snapshot.anchor_before(edit_range.start) total_edit_range: newest_snapshot.anchor_before(edit_range.start)
..newest_snapshot.anchor_before(edit_range.end), ..newest_snapshot.anchor_before(edit_range.end),
uncommitted_diff: None,
}, },
}; };
events.truncate(events.len() - mergeable_count); 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(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Path::new("foo.md").into(),
cursor_position: "0".to_string(), cursor_position: "0".to_string(),
edit_history: "sensitive edit history".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. /// falling back to git-based loading.
pub const MAX_CURSOR_FILE_SIZE: usize = 64 * 1024; 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)] #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)]
pub struct ExampleSpec { pub struct ExampleSpec {
#[serde(default)] #[serde(default)]
@ -25,6 +32,12 @@ pub struct ExampleSpec {
pub reasoning: Option<String>, pub reasoning: Option<String>,
#[serde(default)] #[serde(default)]
pub uncommitted_diff: String, 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_path: Arc<Path>,
pub cursor_position: String, pub cursor_position: String,
pub edit_history: String, pub edit_history: String,
@ -56,18 +69,62 @@ pub struct TelemetrySource {
const REASONING_HEADING: &str = "Reasoning"; const REASONING_HEADING: &str = "Reasoning";
const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; 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 EDIT_HISTORY_HEADING: &str = "Edit History";
const CURSOR_POSITION_HEADING: &str = "Cursor Position"; const CURSOR_POSITION_HEADING: &str = "Cursor Position";
const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
const REJECTED_PATCH_HEADING: &str = "Rejected Patch"; const REJECTED_PATCH_HEADING: &str = "Rejected Patch";
const ACCEPTED_PREDICTION_MARKER: &str = "// User accepted prediction:"; 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)] #[derive(Serialize, Deserialize)]
struct FrontMatter<'a> { struct FrontMatter<'a> {
repository_url: Cow<'a, str>, repository_url: Cow<'a, str>,
revision: Cow<'a, str>, revision: Cow<'a, str>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>, 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 { impl ExampleSpec {
@ -91,6 +148,8 @@ impl ExampleSpec {
repository_url: Cow::Borrowed(&self.repository_url), repository_url: Cow::Borrowed(&self.repository_url),
revision: Cow::Borrowed(&self.revision), revision: Cow::Borrowed(&self.revision),
tags: self.tags.clone(), tags: self.tags.clone(),
uncommitted_diff_requires_edit_history_rollback: self
.uncommitted_diff_contains_edit_history,
}; };
let front_matter_toml = let front_matter_toml =
toml::to_string_pretty(&front_matter).unwrap_or_else(|_| String::new()); toml::to_string_pretty(&front_matter).unwrap_or_else(|_| String::new());
@ -130,6 +189,17 @@ impl ExampleSpec {
markdown.push('\n'); 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, "## {}", EDIT_HISTORY_HEADING);
_ = writeln!(markdown); _ = writeln!(markdown);
@ -194,6 +264,9 @@ impl ExampleSpec {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Path::new("").into(),
cursor_position: String::new(), cursor_position: String::new(),
edit_history: String::new(), edit_history: String::new(),
@ -211,6 +284,8 @@ impl ExampleSpec {
spec.repository_url = data.repository_url.into_owned(); spec.repository_url = data.repository_url.into_owned();
spec.revision = data.revision.into_owned(); spec.revision = data.revision.into_owned();
spec.tags = data.tags; spec.tags = data.tags;
spec.uncommitted_diff_contains_edit_history =
data.uncommitted_diff_requires_edit_history_rollback;
} }
input = rest.trim_start(); input = rest.trim_start();
} }
@ -223,6 +298,8 @@ impl ExampleSpec {
enum Section { enum Section {
Start, Start,
UncommittedDiff, UncommittedDiff,
RecentlyOpenedFiles,
RecentlyViewedFiles,
EditHistory, EditHistory,
CursorPosition, CursorPosition,
ExpectedPatch, ExpectedPatch,
@ -245,6 +322,10 @@ impl ExampleSpec {
let title = mem::take(&mut text); let title = mem::take(&mut text);
current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
Section::UncommittedDiff 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) { } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
Section::EditHistory Section::EditHistory
} else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
@ -292,6 +373,14 @@ impl ExampleSpec {
Section::UncommittedDiff => { Section::UncommittedDiff => {
spec.uncommitted_diff = mem::take(&mut text); 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 => { Section::EditHistory => {
if next_edit_predicted { if next_edit_predicted {
spec.edit_history spec.edit_history
@ -481,6 +570,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Path::new("test.rs").into(),
cursor_position: String::new(), cursor_position: String::new(),
edit_history: String::new(), edit_history: String::new(),
@ -617,6 +709,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Path::new("test.rs").into(),
cursor_position: String::new(), cursor_position: String::new(),
edit_history: String::new(), edit_history: String::new(),
@ -689,6 +784,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Path::new("test.rs").into(),
cursor_position: String::new(), cursor_position: String::new(),
edit_history: String::new(), edit_history: String::new(),

View file

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

View file

@ -854,6 +854,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
cursor_position: "0:0".to_string(), cursor_position: "0:0".to_string(),
edit_history: String::new(), edit_history: String::new(),
@ -933,6 +936,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
cursor_position: "0:0".to_string(), cursor_position: "0:0".to_string(),
edit_history: String::new(), edit_history: String::new(),

View file

@ -1754,6 +1754,9 @@ fn build_example_from_snowflake(
tags, tags,
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: input.cursor_path.clone(),
cursor_position: build_cursor_position(cursor_excerpt, cursor_offset), cursor_position: build_cursor_position(cursor_excerpt, cursor_offset),
edit_history, edit_history,

View file

@ -549,6 +549,9 @@ mod tests {
tags: Vec::new(), tags: Vec::new(),
reasoning: None, reasoning: None,
uncommitted_diff: String::new(), 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_path: Arc::from(Path::new("src/main.rs")),
cursor_position: "0:0".to_string(), cursor_position: "0:0".to_string(),
edit_history: String::new(), edit_history: String::new(),

View file

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

View file

@ -790,6 +790,9 @@ async fn build_example(
tags: Vec::new(), tags: Vec::new(),
reasoning: Some(reasoning_with_source), reasoning: Some(reasoning_with_source),
uncommitted_diff: String::new(), 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_path: Arc::from(Path::new(&cursor_file)),
cursor_position: String::new(), cursor_position: String::new(),
edit_history, edit_history,

View file

@ -286,7 +286,11 @@ impl RelatedExcerptStore {
let buffer = buffer.upgrade()?; let buffer = buffer.upgrade()?;
let definitions = project let definitions = project
.update(cx, |project, cx| { .update(cx, |project, cx| {
project.definitions(&buffer, identifier.range.start, cx) project.workspace_definitions(
&buffer,
identifier.range.start,
cx,
)
}) })
.ok()?; .ok()?;
let type_definitions = project let type_definitions = project
@ -296,7 +300,11 @@ impl RelatedExcerptStore {
if is_tombi_lsp_in_toml(project, &buffer, cx) { if is_tombi_lsp_in_toml(project, &buffer, cx) {
return Task::ready(Ok(None)); return Task::ready(Ok(None));
} }
project.type_definitions(&buffer, identifier.range.start, cx) project.workspace_type_definitions(
&buffer,
identifier.range.start,
cx,
)
}) })
.ok()?; .ok()?;
Some((definitions, type_definitions)) Some((definitions, type_definitions))
@ -304,7 +312,6 @@ impl RelatedExcerptStore {
}; };
let cx = async_cx.clone(); let cx = async_cx.clone();
let project = project.clone();
async move { async move {
match task { match task {
DefinitionTask::CacheHit(cache_entry) => { DefinitionTask::CacheHit(cache_entry) => {
@ -323,12 +330,12 @@ impl RelatedExcerptStore {
.flatten() .flatten()
.unwrap_or_default(); .unwrap_or_default();
Some(cx.update(|cx| {
let definitions: SmallVec<[CachedDefinition; 1]> = let definitions: SmallVec<[CachedDefinition; 1]> =
definition_locations definition_locations
.into_iter() .into_iter()
.filter_map(|location| { .filter_map(|location| {
process_definition(location, &project, cx) let mut cx = cx.clone();
process_definition(location, &mut cx)
}) })
.collect(); .collect();
@ -336,7 +343,8 @@ impl RelatedExcerptStore {
type_definition_locations type_definition_locations
.into_iter() .into_iter()
.filter_map(|location| { .filter_map(|location| {
process_definition(location, &project, cx) let mut cx = cx.clone();
process_definition(location, &mut cx)
}) })
.filter(|type_def| { .filter(|type_def| {
!definitions.iter().any(|def| { !definitions.iter().any(|def| {
@ -347,15 +355,14 @@ impl RelatedExcerptStore {
}) })
.collect(); .collect();
( Some((
identifier, identifier,
Arc::new(CacheEntry { Arc::new(CacheEntry {
definitions, definitions,
type_definitions, type_definitions,
}), }),
Some(duration), Some(duration),
) ))
}))
} }
} }
} }
@ -581,35 +588,30 @@ use language::ToPoint as _;
const MAX_TARGET_LEN: usize = 128; const MAX_TARGET_LEN: usize = 128;
fn process_definition( fn process_definition(location: LocationLink, cx: &mut AsyncApp) -> Option<CachedDefinition> {
location: LocationLink, cx.update(|cx| {
project: &Entity<Project>, let buffer = location.target.buffer;
cx: &mut App, let buffer_snapshot = buffer.read(cx);
) -> Option<CachedDefinition> { let file = buffer_snapshot.file()?;
let buffer = location.target.buffer.read(cx); let path = ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
};
let anchor_range = location.target.range; 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. // 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. // 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_snapshot).len();
let target_len = anchor_range.to_offset(&buffer).len();
if target_len > MAX_TARGET_LEN { if target_len > MAX_TARGET_LEN {
return None; return None;
} }
Some(CachedDefinition { Some(CachedDefinition {
path: ProjectPath { path,
worktree_id: file.worktree_id(cx), buffer: buffer.clone(),
path: file.path().clone(),
},
buffer: location.target.buffer,
anchor_range, anchor_range,
}) })
})
} }
/// Gets all of the identifiers that are present in the given line, and its containing /// Gets all of the identifiers that are present in the given line, and its containing

View file

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

View file

@ -3,13 +3,10 @@ mod edit_prediction_context_view;
mod rate_prediction_modal; mod rate_prediction_modal;
use command_palette_hooks::CommandPaletteFilter; use command_palette_hooks::CommandPaletteFilter;
use edit_prediction::{EditPredictionStore, ResetOnboarding, capture_example}; use edit_prediction::ResetOnboarding;
use edit_prediction_context_view::EditPredictionContextView; use edit_prediction_context_view::EditPredictionContextView;
use editor::Editor;
use feature_flags::FeatureFlagAppExt as _; use feature_flags::FeatureFlagAppExt as _;
use gpui::TaskExt;
use gpui::actions; use gpui::actions;
use language::language_settings::AllLanguageSettings;
use project::DisableAiSettings; use project::DisableAiSettings;
use rate_prediction_modal::RatePredictionsModal; use rate_prediction_modal::RatePredictionsModal;
use settings::{Settings as _, SettingsStore}; use settings::{Settings as _, SettingsStore};
@ -36,8 +33,6 @@ actions!(
[ [
/// Opens the rate completions modal. /// Opens the rate completions modal.
RatePredictions, 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| { workspace.register_action_renderer(|div, _, _, cx| {
div.on_action(cx.listener( div.on_action(cx.listener(
move |workspace, _: &OpenEditPredictionContextView, window, cx| { 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 reset_onboarding_action_types = [TypeId::of::<ResetOnboarding>()];
let all_action_types = [ let all_action_types = [
TypeId::of::<RatePredictions>(), TypeId::of::<RatePredictions>(),
TypeId::of::<CaptureExample>(),
TypeId::of::<edit_prediction::ResetOnboarding>(), TypeId::of::<edit_prediction::ResetOnboarding>(),
zed_actions::OpenZedPredictOnboarding.type_id(), zed_actions::OpenZedPredictOnboarding.type_id(),
TypeId::of::<edit_prediction::ClearHistory>(), TypeId::of::<edit_prediction::ClearHistory>(),
@ -131,68 +122,3 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
}) })
.detach(); .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, ExpandAllDiffHunks,
/// Collapses all diff hunks in the editor. /// Collapses all diff hunks in the editor.
CollapseAllDiffHunks, 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. /// Expands macros recursively at cursor position.
ExpandMacroRecursively, ExpandMacroRecursively,
/// Finds the next match in the search. /// Finds the next match in the search.

View file

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

View file

@ -6771,7 +6771,6 @@ impl Editor {
let mut new_selections = Vec::new(); let mut new_selections = Vec::new();
let mut edits = Vec::new(); let mut edits = Vec::new();
let mut selection_adjustment = 0isize;
for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) {
let selection_is_empty = selection.is_empty(); 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_text = buffer.text_for_range(start..end).collect::<String>();
let old_length = text.len() as isize; let new_text = callback(&old_text);
let text = callback(&text);
new_selections.push(Selection { new_selections.push(Selection {
start: MultiBufferOffset((start.0 as isize - selection_adjustment) as usize), start: buffer.anchor_before(start),
end: MultiBufferOffset( end: buffer.anchor_after(end),
((start.0 + text.len()) as isize - selection_adjustment) as usize,
),
goal: SelectionGoal::None, goal: SelectionGoal::None,
id: selection.id, id: selection.id,
reversed: selection.reversed, 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| { 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] #[gpui::test]
async fn test_manipulate_text(cx: &mut TestAppContext) { async fn test_manipulate_text(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});

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