diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 4757db43437..11a3a709022 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 on: workflow_call: inputs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 4003f41c273..c3503590e60 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: diff --git a/Cargo.lock b/Cargo.lock index cfe21109573..43c2b961b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,9 +224,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1084cabbc2b00d353bad7e54750b0ef0f0bba9204c5884240c83a628704db86c" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -259,9 +259,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984583e634f3f4d479b585aaa76de4a633255dcdf2be6489c6a8486f758af04" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" dependencies = [ "anyhow", "derive_more", @@ -407,6 +407,7 @@ dependencies = [ "language_models", "languages", "log", + "lru", "lsp", "markdown", "menu", @@ -438,6 +439,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "skill_creator", "streaming_diff", "task", "telemetry", @@ -1293,22 +1295,20 @@ dependencies = [ name = "auto_update_ui" version = "0.1.0" dependencies = [ - "agent_settings", + "agent_skills", "anyhow", "auto_update", "client", "db", "editor", - "fs", "gpui", "markdown_preview", "notifications", - "project", + "prompt_store", "release_channel", "semver", "serde", "serde_json", - "settings", "smol", "telemetry", "ui", @@ -3007,7 +3007,6 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", - "db", "derive_more", "feature_flags", "fs", @@ -5964,6 +5963,8 @@ dependencies = [ "settings", "shellexpand", "terminal_view", + "theme", + "theme_settings", "util", "watch", ] @@ -6166,7 +6167,6 @@ name = "extensions_ui" version = "0.1.0" dependencies = [ "anyhow", - "client", "cloud_api_types", "collections", "db", @@ -7461,6 +7461,7 @@ dependencies = [ "settings", "smallvec", "strum 0.27.2", + "sysinfo 0.37.2", "task", "telemetry", "theme", @@ -9649,6 +9650,7 @@ dependencies = [ "futures 0.3.32", "gpui_shared_string", "http_client", + "log", "partial-json-fixer", "schemars 1.0.4", "serde", @@ -11004,8 +11006,8 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -11178,6 +11180,7 @@ dependencies = [ "async-std", "async-tar", "async-trait", + "chrono", "futures 0.3.32", "http_client", "log", @@ -13684,7 +13687,6 @@ dependencies = [ "chrono", "collections", "db", - "feature_flags", "fs", "futures 0.3.32", "fuzzy", @@ -16231,6 +16233,7 @@ version = "0.1.0" dependencies = [ "agent", "agent_settings", + "agent_skills", "anyhow", "audio", "codestral", @@ -16530,6 +16533,30 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skill_creator" +version = "0.1.0" +dependencies = [ + "agent_skills", + "anyhow", + "editor", + "fs", + "gpui", + "language", + "menu", + "platform_title_bar", + "release_channel", + "serde_json", + "serde_yaml_ng", + "settings", + "theme_settings", + "ui", + "ui_input", + "util", + "workspace", + "worktree", +] + [[package]] name = "skrifa" version = "0.37.0" @@ -18246,7 +18273,6 @@ dependencies = [ "client", "cloud_api_types", "db", - "feature_flags", "fs", "git_ui", "gpui", @@ -18768,9 +18794,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.8" +version = "0.26.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" dependencies = [ "cc", "regex", @@ -19171,7 +19197,9 @@ dependencies = [ "gpui_util", "icons", "itertools 0.14.0", + "log", "menu", + "num-format", "schemars 1.0.4", "serde", "smallvec", @@ -20054,9 +20082,9 @@ dependencies = [ [[package]] name = "wasmtime-c-api-impl" -version = "36.0.6" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c62ea3fa30e6b0cf61116b3035121b8f515c60ac118ebfdab2ee56d028ed1e" +checksum = "e5e71e971a27df819171b79597c0f1826fc7cf2c168111c64dbc5505a1ffbda7" dependencies = [ "anyhow", "log", @@ -20103,9 +20131,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-c-api-macros" -version = "36.0.6" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8c61294155a6d23c202f08cf7a2f9392a866edd50517508208818be626ce9f" +checksum = "20b9553165039d365931a998d9b60278cc968ba9d81531cecde8ffc3effa1fe3" dependencies = [ "proc-macro2", "quote", @@ -20559,8 +20587,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -20588,8 +20616,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -20620,32 +20648,32 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", @@ -20692,12 +20720,13 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "naga", "wgpu-types", @@ -20705,8 +20734,8 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -21990,6 +22019,7 @@ dependencies = [ "collections", "component", "db", + "dirs", "fs", "futures 0.3.32", "futures-lite 1.13.0", @@ -22449,7 +22479,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.4.0" +version = "1.5.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/Cargo.toml b/Cargo.toml index 7303d4ebe0a..6db9f292287 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,6 +172,7 @@ members = [ "crates/rope", "crates/rpc", "crates/rules_library", + "crates/skill_creator", "crates/scheduler", "crates/schema_generator", "crates/search", @@ -432,6 +433,7 @@ rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9 rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } +skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } @@ -500,7 +502,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.12.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" @@ -620,6 +622,7 @@ linkify = "0.10.0" libwebrtc = "0.3.26" livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] } log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } +lru = "0.16" lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" @@ -762,7 +765,7 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = { version = "0.26.8", features = ["wasm"] } +tree-sitter = { version = "0.26.9", features = ["wasm"] } tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.1" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } @@ -812,7 +815,7 @@ which = "6.0.0" wasm-bindgen = "0.2.120" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } windows-core = "0.61" yaml-rust2 = "0.8" yawc = "0.2.5" diff --git a/assets/icons/acp_registry.svg b/assets/icons/acp_registry.svg index fb64ea6fbcf..d98728fbbd0 100644 --- a/assets/icons/acp_registry.svg +++ b/assets/icons/acp_registry.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/ai_lm_studio.svg b/assets/icons/ai_lm_studio.svg index 5cfdeb5578c..eef6bfcdb86 100644 --- a/assets/icons/ai_lm_studio.svg +++ b/assets/icons/ai_lm_studio.svg @@ -1,15 +1,15 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/assets/icons/ai_ollama.svg b/assets/icons/ai_ollama.svg index 36a88c1ad6d..93071a78730 100644 --- a/assets/icons/ai_ollama.svg +++ b/assets/icons/ai_ollama.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/ai_open_ai.svg b/assets/icons/ai_open_ai.svg index e45ac315a01..857a03091bd 100644 --- a/assets/icons/ai_open_ai.svg +++ b/assets/icons/ai_open_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_x_ai.svg b/assets/icons/ai_x_ai.svg index d3400fbe9cd..dabee6f54df 100644 --- a/assets/icons/ai_x_ai.svg +++ b/assets/icons/ai_x_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_zed.svg b/assets/icons/ai_zed.svg index 6d78efacd5f..5ba2dbed183 100644 --- a/assets/icons/ai_zed.svg +++ b/assets/icons/ai_zed.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/circle.svg b/assets/icons/circle.svg index 1d80edac09e..c33c37f5f9d 100644 --- a/assets/icons/circle.svg +++ b/assets/icons/circle.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_atom.svg b/assets/icons/editor_atom.svg index cc5fa83843f..ca9c3380c43 100644 --- a/assets/icons/editor_atom.svg +++ b/assets/icons/editor_atom.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg index e20013917d3..28eea301f7b 100644 --- a/assets/icons/editor_cursor.svg +++ b/assets/icons/editor_cursor.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_emacs.svg b/assets/icons/editor_emacs.svg index 951d7b2be16..3dbb2683969 100644 --- a/assets/icons/editor_emacs.svg +++ b/assets/icons/editor_emacs.svg @@ -1,10 +1,8 @@ - - + + + + + - - - - - diff --git a/assets/icons/editor_jet_brains.svg b/assets/icons/editor_jet_brains.svg index 7d9cf0c65cd..94d30903f6c 100644 --- a/assets/icons/editor_jet_brains.svg +++ b/assets/icons/editor_jet_brains.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_sublime.svg b/assets/icons/editor_sublime.svg index 95a04f6b541..92bf14977d4 100644 --- a/assets/icons/editor_sublime.svg +++ b/assets/icons/editor_sublime.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/editor_vs_code.svg b/assets/icons/editor_vs_code.svg index 2a71ad52af2..d1aef6fce4b 100644 --- a/assets/icons/editor_vs_code.svg +++ b/assets/icons/editor_vs_code.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_icons/ballerina.svg b/assets/icons/file_icons/ballerina.svg new file mode 100644 index 00000000000..4a8287252c6 --- /dev/null +++ b/assets/icons/file_icons/ballerina.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/share.svg b/assets/icons/share.svg new file mode 100644 index 00000000000..00d2d09b93b --- /dev/null +++ b/assets/icons/share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/text_unwrap.svg b/assets/icons/text_unwrap.svg new file mode 100644 index 00000000000..1dda70014be --- /dev/null +++ b/assets/icons/text_unwrap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/text_wrap.svg b/assets/icons/text_wrap.svg new file mode 100644 index 00000000000..64ec35a2941 --- /dev/null +++ b/assets/icons/text_wrap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0988611644f..349c980e8bb 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1558,4 +1558,20 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4c9aa4c0bce..23fd201b0d2 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1651,4 +1651,22 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ea11c7f423b..3eece808cc6 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1577,4 +1577,22 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 396c6e40852..bcb114acfae 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -943,7 +943,7 @@ "space w j": "workspace::ActivatePaneDown", "space w k": "workspace::ActivatePaneUp", "space w l": "workspace::ActivatePaneRight", - "space w q": "pane::CloseActiveItem", + "space w q": "pane::CloseActiveItem", }, }, { @@ -1056,8 +1056,8 @@ "ctrl-d": "git_graph::ScrollDown", "ctrl-u": "git_graph::ScrollUp", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + }, }, { "context": "GitPanel && ChangesList && !GitBranchSelector", @@ -1205,4 +1205,18 @@ "enter": "editor::Newline", }, }, + { + "context": "SkillCreator", + "bindings": { + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "bindings": { + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/settings/default.json b/assets/settings/default.json index b22e8589183..17f35962a8f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -71,6 +71,8 @@ "agent_ui_font_size": null, // The default font size for user messages in the agent panel. "agent_buffer_font_size": 12, + // The default font size for the commit editor in the git panel and commit modal. + "git_commit_buffer_font_size": 12, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -1133,6 +1135,7 @@ "spawn_agent": true, "terminal": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, @@ -1153,6 +1156,7 @@ "skill": true, "spawn_agent": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 987db1dcf8e..9123c301079 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -13,7 +13,7 @@ path = "src/acp_thread.rs" doctest = false [features] -test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot", "dep:image"] +test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true @@ -35,7 +35,7 @@ language_model.workspace = true log.workspace = true markdown.workspace = true parking_lot = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image.workspace = true portable-pty.workspace = true project.workspace = true prompt_store.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index afd8aeda5f3..4e6be0fe6a1 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -648,9 +648,16 @@ impl Display for ToolCallStatus { #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { Empty, - Markdown { markdown: Entity }, - ResourceLink { resource_link: acp::ResourceLink }, - Image { image: Arc }, + Markdown { + markdown: Entity, + }, + ResourceLink { + resource_link: acp::ResourceLink, + }, + Image { + image: Arc, + dimensions: Option>, + }, } impl ContentBlock { @@ -692,8 +699,8 @@ impl ContentBlock { }; } (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => { - if let Some(image) = Self::decode_image(image_content) { - *self = ContentBlock::Image { image }; + if let Some((image, dimensions)) = Self::decode_image(image_content) { + *self = ContentBlock::Image { image, dimensions }; } else { let new_content = Self::image_md(image_content); *self = Self::create_markdown_block(new_content, language_registry, cx); @@ -721,14 +728,36 @@ impl ContentBlock { } } - fn decode_image(image_content: &acp::ImageContent) -> Option> { + fn decode_image( + image_content: &acp::ImageContent, + ) -> Option<(Arc, Option>)> { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(image_content.data.as_bytes()) .ok()?; let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?; - Some(Arc::new(gpui::Image::from_bytes(format, bytes))) + let dimensions = Self::image_dimensions(&bytes, format); + Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions)) + } + + fn image_dimensions(bytes: &[u8], format: gpui::ImageFormat) -> Option> { + let format = match format { + gpui::ImageFormat::Png => image::ImageFormat::Png, + gpui::ImageFormat::Jpeg => image::ImageFormat::Jpeg, + gpui::ImageFormat::Webp => image::ImageFormat::WebP, + gpui::ImageFormat::Gif => image::ImageFormat::Gif, + gpui::ImageFormat::Svg => return None, + gpui::ImageFormat::Bmp => image::ImageFormat::Bmp, + gpui::ImageFormat::Tiff => image::ImageFormat::Tiff, + gpui::ImageFormat::Ico => image::ImageFormat::Ico, + gpui::ImageFormat::Pnm => image::ImageFormat::Pnm, + }; + + image::ImageReader::with_format(std::io::Cursor::new(bytes), format) + .into_dimensions() + .ok() + .map(|(width, height)| gpui::Size { width, height }) } fn create_markdown_block( @@ -808,9 +837,9 @@ impl ContentBlock { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { - ContentBlock::Image { image } => Some(image), + ContentBlock::Image { image, dimensions } => Some((image, *dimensions)), _ => None, } } @@ -895,7 +924,7 @@ impl ToolCallContent { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { Self::ContentBlock(content) => content.image(), _ => None, diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 87c8ccf65c1..f58d8a581b8 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -115,6 +115,11 @@ pub trait AgentConnection { self.supports_load_session() || self.supports_resume_session() } + /// Whether this agent supports additional session directories. + fn supports_session_additional_directories(&self, _cx: &App) -> bool { + false + } + fn auth_methods(&self) -> &[acp::AuthMethod]; fn terminal_auth_task( @@ -702,6 +707,7 @@ mod test_support { permission_requests: HashMap, next_prompt_updates: Arc>>, supports_load_session: bool, + supports_session_additional_directories: bool, agent_id: AgentId, telemetry_id: SharedString, } @@ -724,6 +730,7 @@ mod test_support { permission_requests: HashMap::default(), sessions: Arc::default(), supports_load_session: false, + supports_session_additional_directories: false, agent_id: AgentId::new("stub"), telemetry_id: "stub".into(), } @@ -746,6 +753,14 @@ mod test_support { self } + pub fn with_supports_session_additional_directories( + mut self, + supports_session_additional_directories: bool, + ) -> Self { + self.supports_session_additional_directories = supports_session_additional_directories; + self + } + pub fn with_agent_id(mut self, agent_id: AgentId) -> Self { self.agent_id = agent_id; self @@ -863,6 +878,10 @@ mod test_support { self.supports_load_session } + fn supports_session_additional_directories(&self, _cx: &App) -> bool { + self.supports_session_additional_directories + } + fn load_session( self: Rc, session_id: acp::SessionId, diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 6fc2cc50c1f..cb96de34813 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -51,6 +51,8 @@ pub enum MentionUri { #[serde(default, skip_serializing_if = "Option::is_none")] abs_path: Option, line_range: RangeInclusive, + #[serde(default, skip_serializing_if = "Option::is_none")] + column: Option, }, Fetch { url: Url, @@ -105,6 +107,17 @@ impl MentionUri { Ok(start_line..=end_line) } + let parse_column = + |input: Option| -> Option { input?.parse::().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 { let (path_input, fragment) = input .split_once('#') @@ -114,6 +127,7 @@ impl MentionUri { return Ok(MentionUri::Selection { abs_path: Some(path_input.into()), line_range: fragment, + column: None, }); } @@ -123,10 +137,12 @@ impl MentionUri { let line = row .checked_sub(1) .context("Line numbers should be 1-based")?; - // TODO: Preserve column info too. Ok(MentionUri::Selection { abs_path: Some(abs_path), line_range: line..=line, + column: path_with_position + .column + .map(|column| column.saturating_sub(1)), }) } else { Ok(MentionUri::File { abs_path }) @@ -156,8 +172,10 @@ impl MentionUri { let path = normalized.as_ref(); if let Some(fragment) = url.fragment() { + validate_query_params(&url, &["symbol", "column"])?; let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1); - if let Some(name) = single_query_param(&url, "symbol")? { + let column = parse_column(query_param(&url, "column")); + if let Some(name) = query_param(&url, "symbol") { Ok(Self::Symbol { name, abs_path: path.into(), @@ -167,6 +185,7 @@ impl MentionUri { Ok(Self::Selection { abs_path: Some(path.into()), line_range, + column, }) } } else if input.ends_with("/") { @@ -216,9 +235,11 @@ impl MentionUri { .fragment() .context("Missing fragment for untitled buffer selection")?; let line_range = parse_line_range(fragment)?; + validate_query_params(&url, &["column"])?; Ok(Self::Selection { abs_path: None, line_range, + column: parse_column(query_param(&url, "column")), }) } else if let Some(name) = path.strip_prefix("/agent/symbol/") { let fragment = url @@ -245,13 +266,15 @@ impl MentionUri { abs_path: path.into(), }) } else if path.starts_with("/agent/selection") { + validate_query_params(&url, &["path", "column"])?; let fragment = url.fragment().context("Missing fragment for selection")?; let line_range = parse_line_range(fragment)?; - let path = - single_query_param(&url, "path")?.context("Missing path for selection")?; + let column = parse_column(query_param(&url, "column")); + let path = query_param(&url, "path").context("Missing path for selection")?; Ok(Self::Selection { abs_path: Some(path.into()), line_range, + column, }) } else if path.starts_with("/agent/terminal-selection") { let line_count = single_query_param(&url, "lines")? @@ -342,13 +365,33 @@ impl MentionUri { .. } => selection_name(path.as_deref(), line_range), MentionUri::Fetch { url } => url.to_string(), + MentionUri::Skill { name, .. } => name.clone(), + } + } + + /// Returns a label for this mention at the given disambiguation `detail` + /// level. `detail == 0` is the base name returned by [`Self::name`]; higher + /// levels include progressively more context (e.g. additional parent path + /// components for files, or the source for skills) until a fixed point is + /// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`]. + pub fn disambiguated_name(&self, detail: usize) -> String { + if detail == 0 { + return self.name(); + } + + match self { MentionUri::Skill { name, source, .. } => { if source.is_empty() { + // Must match `SkillSource::display_label()` in agent_skills. format!("{} (global)", name) } else { format!("{} ({})", name, source) } } + MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => { + project::path_suffix(abs_path, detail) + } + _ => self.name(), } } @@ -440,6 +483,7 @@ impl MentionUri { abs_path, name, line_range, + .. } => { let mut url = Url::parse("file:///").unwrap(); url.set_path(&abs_path.to_string_lossy()); @@ -454,6 +498,7 @@ impl MentionUri { MentionUri::Selection { abs_path, line_range, + column, } => { let mut url = if let Some(path) = abs_path { let mut url = Url::parse("file:///").unwrap(); @@ -464,6 +509,10 @@ impl MentionUri { url.set_path("/agent/untitled-buffer"); url }; + if let Some(column) = column { + url.query_pairs_mut() + .append_pair("column", &(column + 1).to_string()); + } url.set_fragment(Some(&format!( "L{}:{}", line_range.start() + 1, @@ -544,6 +593,11 @@ fn default_include_errors() -> bool { true } +fn query_param(url: &Url, name: &'static str) -> Option { + url.query_pairs() + .find_map(|(key, value)| (key == name).then(|| value.to_string())) +} + fn single_query_param(url: &Url, name: &'static str) -> Result> { let pairs = url.query_pairs().collect::>(); match pairs.as_slice() { @@ -678,6 +732,7 @@ mod tests { abs_path: path, name, line_range, + .. } => { assert_eq!(path, Path::new(path!("/path/to/file.rs"))); assert_eq!(name, "MySymbol"); @@ -697,6 +752,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &4); @@ -728,6 +784,7 @@ mod tests { MentionUri::Selection { abs_path: None, line_range, + .. } => { assert_eq!(line_range.start(), &0); assert_eq!(line_range.end(), &9); @@ -875,6 +932,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -884,6 +942,29 @@ mod tests { } } + #[test] + fn test_parse_absolute_file_path_with_row_and_column() { + let file_path = "/path/to/file.rs:42:5"; + let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + match &parsed { + MentionUri::Selection { + abs_path: path, + line_range, + column, + } => { + assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); + assert_eq!(line_range.start(), &41); + assert_eq!(line_range.end(), &41); + assert_eq!(column, &Some(4)); + + let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix) + .expect("selection URI with column should parse"); + assert_eq!(parsed_again, parsed.clone()); + } + _ => panic!("Expected Selection variant"), + } + } + #[test] fn test_parse_absolute_file_path_with_fragment_line() { let file_path = "/path/to/file.rs#L42"; @@ -892,6 +973,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -921,6 +1003,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -941,6 +1024,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -973,6 +1057,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -990,6 +1075,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -1011,6 +1097,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &1871); @@ -1028,6 +1115,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &9); @@ -1043,6 +1131,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &9); @@ -1070,4 +1159,68 @@ mod tests { let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap(); assert_eq!(parsed_single.name(), "Terminal (1 line)"); } + + #[test] + fn test_disambiguated_name() { + // Two files with the same name — should disambiguate with parent dir + let file_a = MentionUri::File { + abs_path: PathBuf::from(path!("/project/src/README.md")), + }; + let file_b = MentionUri::File { + abs_path: PathBuf::from(path!("/project/docs/README.md")), + }; + assert_eq!(file_a.name(), "README.md"); + assert_eq!(file_b.name(), "README.md"); + assert_eq!(file_a.disambiguated_name(0), "README.md"); + assert_eq!(file_a.disambiguated_name(1), "src/README.md"); + assert_eq!(file_b.disambiguated_name(1), "docs/README.md"); + + // Files that still collide at one parent should grow further. + let deep_a = MentionUri::File { + abs_path: PathBuf::from(path!("/a/src/foo.rs")), + }; + let deep_b = MentionUri::File { + abs_path: PathBuf::from(path!("/b/src/foo.rs")), + }; + assert_eq!(deep_a.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_a.disambiguated_name(2), "a/src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(2), "b/src/foo.rs"); + + // Two skills with the same name — should disambiguate with source + let global_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "".into(), + skill_file_path: PathBuf::from("/global/create-skill/SKILL.md"), + }; + let project_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "my-project".into(), + skill_file_path: PathBuf::from("/project/create-skill/SKILL.md"), + }; + assert_eq!(global_skill.name(), "create-skill"); + assert_eq!(global_skill.disambiguated_name(0), "create-skill"); + assert_eq!(global_skill.disambiguated_name(1), "create-skill (global)"); + assert_eq!( + project_skill.disambiguated_name(1), + "create-skill (my-project)" + ); + + // A type without special disambiguation (Thread) — detail has no effect + // (the value is a fixed point so the disambiguation loop terminates). + let thread = MentionUri::Thread { + id: acp::SessionId::new("123"), + name: "My Thread".into(), + }; + assert_eq!(thread.disambiguated_name(0), "My Thread"); + assert_eq!(thread.disambiguated_name(1), "My Thread"); + assert_eq!(thread.disambiguated_name(5), "My Thread"); + + // Edge case: file at filesystem root has no parent to show + let root_file = MentionUri::File { + abs_path: PathBuf::from(path!("/README.md")), + }; + assert_eq!(root_file.disambiguated_name(1), "README.md"); + assert_eq!(root_file.disambiguated_name(5), "README.md"); + } } diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 8801379578f..695e2beb440 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -508,6 +508,7 @@ impl AcpTools { } else { CopyButtonVisibility::Hidden }, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }, ), diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index eda50ab5637..8ef06de1649 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -31,13 +31,14 @@ use acp_thread::{ }; use agent_client_protocol::schema as acp; use agent_skills::{ - MAX_SKILL_DESCRIPTIONS_SIZE, Skill, SkillLoadError, SkillScopeId, SkillSource, SkillSummary, - global_skills_dir, load_skills_from_directory, project_skills_relative_path, + MAX_SKILL_DESCRIPTIONS_SIZE, ProjectSkillGroup, Skill, SkillIndex, SkillLoadError, + SkillScopeId, SkillSource, SkillSummary, builtin_skills, global_skills_dir, + load_skills_from_directory, project_skills_relative_path, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; + use fs::Fs; use futures::channel::{mpsc, oneshot}; use futures::future::Shared; @@ -104,7 +105,7 @@ impl From<&Skill> for NativeAvailableSkill { Self { name: skill.name.clone(), description: skill.description.clone(), - source: skill.source.scope_prefix().to_string().into(), + source: skill.source.display_label().to_string().into(), skill_file_path: skill.skill_file_path.clone(), } } @@ -369,6 +370,10 @@ impl NativeAgent { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) } + if !cx.has_global::() { + cx.set_global(SkillIndex::default()); + } + Self { sessions: HashMap::default(), pending_sessions: HashMap::default(), @@ -387,11 +392,11 @@ impl NativeAgent { /// Kicks off a one-time scan of the global skills directory if one /// isn't already in progress and a watch isn't already active. /// - /// Idempotent and cheap: returns immediately if the user lacks the - /// skills feature flag, or if a scan or watch is already running. - /// The expected callers are user-interaction events from the agent - /// panel (input focus, slash autocomplete, conversation submit); - /// firing this from any of them is equivalent and safe to repeat. + /// Idempotent and cheap: returns immediately if a scan or watch is + /// already running. The expected callers are user-interaction events + /// from the agent panel (input focus, slash autocomplete, conversation + /// submit); firing this from any of them is equivalent and safe to + /// repeat. /// /// The scan itself runs detached on the foreground executor. If /// `~/.agents/skills/` exists it transitions state to @@ -400,9 +405,6 @@ impl NativeAgent { /// next trigger retries (covering the case where the user creates /// the directory after the first scan). pub fn ensure_skills_scan_started(&mut self, cx: &mut Context) { - if !cx.has_flag::() { - return; - } if !matches!(self.skills_state, SkillsState::Idle) { return; } @@ -593,12 +595,10 @@ impl NativeAgent { // after the thread is constructed are still visible to the // model — without this, the catalog and tool would drift out // of sync until the session was reopened. - if cx.has_flag::() { - thread.add_tool(SkillTool::new( - skills_resolver_for_project(weak.clone(), project_id), - self.fs.clone(), - )); - } + thread.add_tool(SkillTool::new( + skills_resolver_for_project(weak.clone(), project_id), + self.fs.clone(), + )); }); let subscriptions = vec![ @@ -796,6 +796,7 @@ impl NativeAgent { // the available commands) can change without affecting the // skill error list. this.update_available_commands_for_project(project_id, cx); + this.publish_skill_index(cx); })?; } @@ -816,12 +817,8 @@ impl NativeAgent { }) .collect::>(); - // 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::(); - // Load global skills - let global_skills_task = if skills_enabled { + let global_skills_task = { let global_skills_dir = global_skills_dir(); let global_skills_fs = fs.clone(); cx.background_spawn(async move { @@ -832,8 +829,6 @@ impl NativeAgent { ) .await }) - } else { - Task::ready(Vec::new()) }; // Load project-local skills, but only from worktrees the user has @@ -846,7 +841,7 @@ impl NativeAgent { // worktrees pick up their skills without restarting. let trusted_worktrees = TrustedWorktrees::try_get_global(cx); let worktree_store = project.read(cx).worktree_store(); - let project_skills_task = if skills_enabled { + let project_skills_task = { let project_skills_futures: Vec< futures::future::BoxFuture<'static, Vec>>, > = worktrees @@ -891,8 +886,6 @@ impl NativeAgent { }) .collect(); cx.background_spawn(async move { future::join_all(project_skills_futures).await }) - } else { - Task::ready(Vec::new()) }; let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { prompt_store.read_with(cx, |prompt_store, cx| { @@ -1101,7 +1094,7 @@ impl NativeAgent { &mut self, project: Entity, event: &project::Event, - cx: &mut Context, + _cx: &mut Context, ) { let project_id = project.entity_id(); let Some(state) = self.projects.get_mut(&project_id) else { @@ -1112,16 +1105,14 @@ impl NativeAgent { state.project_context_needs_refresh.send(()).ok(); } project::Event::WorktreeUpdatedEntries(_, items) => { - let skills_enabled = cx.has_flag::(); if items.iter().any(|(path, _, _)| { let path_ref = path.as_ref(); RULES_FILE_REL_PATHS .iter() .any(|rules_path| path_ref == rules_path.as_ref()) - || (skills_enabled - && SKILLS_PREFIX - .as_ref() - .is_some_and(|prefix| path_ref.starts_with(prefix))) + || SKILLS_PREFIX + .as_ref() + .is_some_and(|prefix| path_ref.starts_with(prefix)) }) { state.project_context_needs_refresh.send(()).ok(); } @@ -1213,6 +1204,50 @@ impl NativeAgent { } } + fn publish_skill_index(&self, cx: &mut Context) { + let mut global_skills = Vec::new(); + let mut project_groups: Vec = 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) { let available_commands = Self::build_available_commands_for_project(self.projects.get(&project_id), cx); @@ -1450,6 +1485,7 @@ impl NativeAgent { let has_remaining = self.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { self.projects.remove(&project_id); + self.publish_skill_index(cx); } session.pending_save @@ -1644,14 +1680,18 @@ impl NativeAgent { // Read the body on demand here — bodies live on disk between // materializations to keep memory cost O(total frontmatter) // rather than O(total file size). - let body = agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) - .await - .with_context(|| { - format!( - "Failed to read skill body from {}", - skill.skill_file_path.display() - ) - })?; + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) + .await + .with_context(|| { + format!( + "Failed to read skill body from {}", + skill.skill_file_path.display() + ) + })? + }; let envelope = crate::tools::render_skill_envelope(&skill, &body); let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope)); @@ -1726,6 +1766,16 @@ impl NativeAgentConnection { .update(cx, |agent, cx| agent.ensure_skills_scan_started(cx)); } + pub fn refresh_skills_for_project(&self, project: Entity, cx: &mut App) { + self.0.update(cx, |agent, cx| { + let project_id = agent.get_or_create_project_state(&project, cx); + agent.ensure_skills_scan_started(cx); + if let Some(state) = agent.projects.get_mut(&project_id) { + state.project_context_needs_refresh.send(()).ok(); + } + }); + } + pub fn available_skills( &self, session_id: &acp::SessionId, @@ -2245,9 +2295,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { // we don't clone the entire skill list on every prompt // (including prompts like `/help` that aren't skills at // all). The resolution rule matches the override-applied - // view: prefer a project-local with the matching name, - // falling back to a global, so the slash command picks the - // same entry the model sees in its catalog. + // view: among skills with the matching name, pick the one + // with the highest source precedence, so the slash command + // picks the same entry the model sees in its catalog. + // Ties (e.g. two project-local skills from different + // worktrees) resolve to the first in iteration order to + // match `apply_skill_overrides`. if parsed_command.explicit_server_id.is_none() && parsed_command.skill_scope.is_none() && !project_state.skills.is_empty() @@ -2256,15 +2309,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let resolved = project_state .skills .iter() - .find(|skill| { - skill.name == prompt_name - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - }) - .or_else(|| { - project_state - .skills - .iter() - .find(|skill| skill.name == prompt_name) + .filter(|skill| skill.name == prompt_name) + .reduce(|best, candidate| { + if candidate.source.precedence() > best.source.precedence() { + candidate + } else { + best + } }); if let Some(skill) = resolved { let skill = skill.clone(); @@ -2960,7 +3011,9 @@ fn combine_skills( global: Vec>, project: impl Iterator>, ) -> (Vec, Vec) { - let mut skills = Vec::new(); + // Built-in skills go first (lowest priority) so that global and + // project-local skills with the same name shadow them. + let mut skills = builtin_skills(); let mut errors = Vec::new(); for result in global.into_iter().chain(project) { match result { @@ -2979,17 +3032,16 @@ fn log_skill_conflicts(skills: &[Skill]) { let mut by_name: HashMap<&str, &Skill> = HashMap::default(); for skill in skills { match by_name.get(skill.name.as_str()) { - Some(existing) => match (&existing.source, &skill.source) { - (SkillSource::Global, SkillSource::ProjectLocal { .. }) => { + Some(existing) => { + if skill.source.precedence() > existing.source.precedence() { log::warn!( - "Project skill '{}' at '{}' overrides global skill at '{}' for the model; both appear in the slash-command popup with their source", + "Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source", skill.name, skill.skill_file_path.display(), existing.skill_file_path.display(), ); by_name.insert(skill.name.as_str(), skill); - } - _ => { + } else { log::warn!( "Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source", skill.name, @@ -2997,7 +3049,7 @@ fn log_skill_conflicts(skills: &[Skill]) { existing.skill_file_path.display(), ); } - }, + } None => { by_name.insert(skill.name.as_str(), skill); } @@ -3024,9 +3076,7 @@ fn apply_skill_overrides(skills: &[Skill]) -> Vec { for skill in skills { match indices.get(skill.name.as_str()).copied() { Some(idx) => { - if matches!(result[idx].source, SkillSource::Global) - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - { + if skill.source.precedence() > result[idx].source.precedence() { result[idx] = skill.clone(); } } @@ -3064,6 +3114,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } @@ -3078,9 +3129,30 @@ mod internal_tests { directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } + fn make_builtin_skill(name: &str, description: &str) -> Skill { + Skill { + name: name.to_string(), + description: description.to_string(), + source: SkillSource::BuiltIn, + directory_path: PathBuf::from(format!("/builtin/{name}")), + skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")), + disable_model_invocation: false, + embedded_body: Some("built-in body"), + } + } + + /// Filter to only user-defined (non-built-in) skills for test assertions. + fn user_skills(skills: &[Skill]) -> Vec<&Skill> { + skills + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect() + } + #[test] fn test_combine_skills_keeps_every_entry_for_autocomplete() { // The autocomplete popup needs both same-named entries so the @@ -3092,9 +3164,10 @@ mod internal_tests { let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter()); assert!(errors.is_empty()); - assert_eq!(skills.len(), 2); - assert!(matches!(skills[0].source, SkillSource::Global)); - assert!(matches!(skills[1].source, SkillSource::ProjectLocal { .. })); + let user = user_skills(&skills); + assert_eq!(user.len(), 2); + assert!(matches!(user[0].source, SkillSource::Global)); + assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. })); } #[test] @@ -3130,6 +3203,51 @@ mod internal_tests { assert_eq!(resolved[0].description, "First"); } + #[test] + fn test_apply_skill_overrides_global_wins_over_builtin() { + // A global skill with the same name as a built-in must shadow + // the built-in in the model-facing projection, regardless of + // iteration order. + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let global = make_global_skill("create-skill", "User override"); + + let resolved = apply_skill_overrides(&[built_in, global]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "User override"); + assert!(matches!(resolved[0].source, SkillSource::Global)); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin() { + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let project = make_project_skill("create-skill", "Project override", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project override"); + assert!(matches!( + resolved[0].source, + SkillSource::ProjectLocal { .. } + )); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin_and_global() { + // All three sources present — the project-local must win and + // both lower-precedence entries must be dropped from the + // model-facing projection. + let built_in = make_builtin_skill("create-skill", "Built-in"); + let global = make_global_skill("create-skill", "Global"); + let project = make_project_skill("create-skill", "Project", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, global, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project"); + } + #[test] fn test_apply_skill_overrides_preserves_unique_skills() { let global_a = make_global_skill("alpha", "a"); @@ -3201,6 +3319,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/skills/{name}")), skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, }); } @@ -3275,6 +3394,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-01-first"), skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let second = Skill { name: "skill-02-overflows".to_string(), @@ -3283,6 +3403,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-02-overflows"), skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let third = Skill { name: "skill-03-would-fit".to_string(), @@ -3291,6 +3412,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-03-would-fit"), skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; // Sanity-check the test setup: the third skill is small enough @@ -3346,6 +3468,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/hidden-huge"), skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"), disable_model_invocation: true, + embedded_body: None, }; let visible = Skill { name: "visible".to_string(), @@ -3354,6 +3477,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/visible"), skill_file_path: PathBuf::from("/skills/visible/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let (kept, errors) = select_catalog_skills(&[hidden, visible]); @@ -3454,9 +3578,6 @@ mod internal_tests { #[gpui::test] async fn test_global_skills_load_and_reload(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); let initial_skill_dir = skills_dir.join("my-skill"); @@ -3496,9 +3617,10 @@ mod internal_tests { // The pre-existing skill should be loaded into the project state. agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); - assert_eq!(state.skills[0].description, "First version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); + assert_eq!(user[0].description, "First version"); }); // Modify the SKILL.md and verify the project context refreshes. @@ -3512,17 +3634,15 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].description, "Second version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].description, "Second version"); }); } #[gpui::test] async fn test_global_skills_dir_created_after_startup(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3559,8 +3679,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3585,9 +3705,10 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "late-skill"); - assert_eq!(state.skills[0].description, "Created after startup"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "late-skill"); + assert_eq!(user[0].description, "Created after startup"); }); } @@ -3603,9 +3724,6 @@ mod internal_tests { #[gpui::test] async fn test_skills_added_after_session_visible_to_skill_tool(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3638,8 +3756,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3656,7 +3774,12 @@ mod internal_tests { // empty list — NOT the snapshot that `Thread::new` would have // captured. cx.update(|cx| { - assert!(resolve(cx).is_empty()); + let all = resolve(cx); + let user: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); + assert!(user.is_empty()); }); // Now create a SKILL.md AFTER the session was registered. With @@ -3681,15 +3804,20 @@ mod internal_tests { // `state.skills` reflects the new skill (the watcher ran). agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); }); // The resolver the `SkillTool` uses must see it too. This is the // crux of the regression test: the tool's view of skills is // resolved at invocation time, not at thread-construction time. cx.update(|cx| { - let snapshot = resolve(cx); + let all = resolve(cx); + let snapshot: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!( snapshot.len(), 1, @@ -3737,9 +3865,6 @@ mod internal_tests { #[gpui::test] async fn test_subagent_skills_lookup_matches_parent(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); let skill_dir = skills_dir.join("shared-skill"); @@ -3777,7 +3902,11 @@ mod internal_tests { let parent_resolve = cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id)); cx.update(|cx| { - let parent_skills = parent_resolve(cx); + let all = parent_resolve(cx); + let parent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(parent_skills.len(), 1); assert_eq!(parent_skills[0].name, "shared-skill"); }); @@ -3823,7 +3952,11 @@ mod internal_tests { let subagent_resolve = cx .update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id)); cx.update(|cx| { - let subagent_skills = subagent_resolve(cx); + let all = subagent_resolve(cx); + let subagent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(subagent_skills.len(), 1); assert_eq!(subagent_skills[0].name, "shared-skill"); }); @@ -3832,9 +3965,6 @@ mod internal_tests { #[gpui::test] async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3919,7 +4049,14 @@ mod internal_tests { .iter() .map(|s| s.name.as_str()) .collect(); - assert_eq!(catalog, vec!["visible-skill"]); + assert!( + catalog.contains(&"visible-skill"), + "visible skill missing from catalog: {catalog:?}" + ); + assert!( + !catalog.contains(&"deploy"), + "deploy should be excluded from catalog: {catalog:?}" + ); }); } @@ -3930,7 +4067,6 @@ mod internal_tests { init_test(cx); cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); // The trust global isn't created by `init_test`. We need it // for `Project::test_with_worktree_trust` to actually wire up // trust tracking and for our subscription in @@ -3986,7 +4122,7 @@ mod internal_tests { agent.read_with(cx, |agent, cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), + user_skills(&state.skills).is_empty(), "untrusted worktree skills should not load: {:?}", state .skills @@ -4019,7 +4155,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect(); + let user = user_skills(&state.skills); + let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["my-skill"]); }); diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index b369b07b81f..51f27eaeddf 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -83,7 +83,7 @@ mod tests { let project = prompt_store::ProjectContext::default(); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into(), "update_plan".into()], + available_tools: vec!["echo".into(), "update_plan".into(), "update_title".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -94,6 +94,7 @@ mod tests { assert!(rendered.contains("Today's Date: 2026-01-01")); assert!(rendered.contains("## Fixing Diagnostics")); assert!(rendered.contains("## Planning")); + assert!(rendered.contains("## Session Title")); assert!(rendered.contains("test-model")); } diff --git a/crates/agent/src/templates/experimental_system_prompt.hbs b/crates/agent/src/templates/experimental_system_prompt.hbs index 2611efc671c..824ee679a2a 100644 --- a/crates/agent/src/templates/experimental_system_prompt.hbs +++ b/crates/agent/src/templates/experimental_system_prompt.hbs @@ -52,6 +52,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c7128d9cb6b..54962540757 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -74,6 +74,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 7713907f893..eab1c031f0b 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -26,10 +26,10 @@ use gpui::{ use indoc::indoc; use language_model::{ CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry, - LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, + Role, StopReason, TokenUsage, fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}, }; use pretty_assertions::assert_eq; @@ -1656,6 +1656,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); assert_eq!(tool_call_params.name, "screenshot"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; tool_call_response .send(context_server::types::CallToolResponse { content: vec![ @@ -1663,7 +1664,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { text: "Some text".into(), }, context_server::types::ToolResponseContent::Image { - data: "aGVsbG8=".into(), + data: image_data.into(), mime_type: "image/png".into(), }, context_server::types::ToolResponseContent::Text { @@ -1691,13 +1692,25 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { }) .expect("expected a tool result"); assert_eq!(tool_result.tool_use_id, "tool_1".into()); - assert_eq!(tool_result.content.len(), 2); + assert_eq!(tool_result.content.len(), 3); + assert_eq!( + tool_result.content[0], + language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) + ); + let expected_image = + language_model::LanguageModelImage::from_base64_image(image_data, "image/png") + .expect("image conversion should not error") + .expect("image conversion should succeed"); assert_eq!( tool_result.content[0], language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) ); assert_eq!( tool_result.content[1], + language_model::LanguageModelToolResultContent::Image(expected_image) + ); + assert_eq!( + tool_result.content[2], language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text")) ); fake_model.end_last_completion_stream(); @@ -3794,6 +3807,155 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_update_title_tool_sets_thread_title(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let input = json!({ + "title": "Session title tool" + }); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "title_1".into(), + name: UpdateTitleTool::NAME.into(), + raw_input: input.to_string(), + input, + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!( + tool_call, + acp::ToolCall::new("title_1", "Update title: Session title tool") + .kind(acp::ToolKind::Think) + .raw_input(json!({ + "title": "Session title tool" + })) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "update_title".into() + )])) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Session title updated") + ) + ); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Session title tool".into())); + }); + assert_eq!(summary_model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_update_title_availability_suppresses_summary_title_generation( + cx: &mut TestAppContext, +) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + send.collect::>().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::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Fallback title".into())); + }); +} + #[gpui::test] async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; @@ -4307,6 +4469,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { StreamingFailingEchoTool::NAME: true, TerminalTool::NAME: true, UpdatePlanTool::NAME: true, + UpdateTitleTool::NAME: true, } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 2fe5bc99303..147266b678a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,12 +4,14 @@ use crate::{ FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, UserAgentsMd, WebSearchTool, WriteFileTool, decide_permission_from_settings, + UpdatePlanTool, UpdateTitleTool, UserAgentsMd, WebSearchTool, WriteFileTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use feature_flags::{ FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, + UpdateTitleToolFeatureFlag, }; use agent_client_protocol::schema as acp; @@ -364,11 +366,7 @@ impl UserMessage { .ok(); } MentionUri::Skill { name, source, .. } => { - let label = if source.is_empty() { - format!("{} (global)", name) - } else { - format!("{} ({})", name, source) - }; + let label = format!("{} ({})", name, source); write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok(); } } @@ -1219,10 +1217,10 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { - // Extract saved output and status first, so they're available even if tool is not found let output = tool_result .as_ref() .and_then(|result| result.output.clone()); + let replay_content = tool_result.and_then(Self::tool_result_content_for_replay); let status = tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { @@ -1251,21 +1249,25 @@ impl Thread { // but still display the saved result if available. // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. + let title = Self::title_for_replayed_tool_use(tool_use); stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( - acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + acp::ToolCall::new(tool_use.id.to_string(), title.clone()) .status(status) .raw_input(tool_use.input.clone()), ))) .ok(); - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields::new() - .status(status) - .raw_output(output), - None, - ); + let mut fields = acp::ToolCallUpdateFields::new() + .status(status) + .raw_output(output); + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + fields = fields.title(title); + } + if let Some(content) = replay_content { + fields = fields.content(content); + } + stream.update_tool_call_fields(&tool_use.id, fields, None); return; }; @@ -1279,6 +1281,14 @@ impl Thread { tool_use.input.clone(), ); + if let Some(content) = replay_content { + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new().content(content), + None, + ); + } + if let Some(output) = output.clone() { // For replay, we use a dummy cancellation receiver since the tool already completed let (_cancellation_tx, cancellation_rx) = watch::channel(false); @@ -1301,6 +1311,55 @@ impl Thread { ); } + fn title_for_replayed_tool_use(tool_use: &LanguageModelToolUse) -> String { + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + let input = serde_json::from_value(tool_use.input.clone()) + .map_err(|_| serde_json::Value::String(tool_use.raw_input.clone())); + UpdateTitleTool::title_for_input(input).to_string() + } else { + tool_use.name.to_string() + } + } + + fn tool_result_content_for_replay( + tool_result: &LanguageModelToolResult, + ) -> Option> { + 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::>(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + pub fn from_db( id: acp::SessionId, db_thread: DbThread, @@ -1627,6 +1686,9 @@ impl Thread { if cx.has_flag::() { self.add_tool(UpdatePlanTool); } + if cx.has_flag::() { + self.add_tool(UpdateTitleTool::new(cx.weak_entity())); + } self.add_tool(ReadFileTool::new( self.project.clone(), self.action_log.clone(), @@ -2140,7 +2202,7 @@ impl Thread { this.update(cx, |this, cx| { this.flush_pending_message(cx); - if this.title.is_none() && this.pending_title_generation.is_none() { + if this.title.is_none() { this.generate_title(cx); } })?; @@ -2669,6 +2731,20 @@ impl Thread { self.title_generation_failed } + pub fn can_generate_title(&self, cx: &App) -> bool { + self.pending_title_generation.is_none() + && self.summarization_model.is_some() + && !self.update_title_tool_available(cx) + } + + fn update_title_tool_available(&self, cx: &App) -> bool { + if let Some(running_turn) = self.running_turn.as_ref() { + running_turn.tools.contains_key(UpdateTitleTool::NAME) + } else { + self.enabled_tools(cx).contains_key(UpdateTitleTool::NAME) + } + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -2730,6 +2806,10 @@ impl Thread { } pub fn generate_title(&mut self, cx: &mut Context) { + if !self.can_generate_title(cx) { + return; + } + self.title_generation_failed = false; let Some(model) = self.summarization_model.clone() else { return; @@ -4458,6 +4538,259 @@ mod tests { }) } + struct ReplayImageTool; + + impl AgentTool for ReplayImageTool { + type Input = (); + type Output = String; + + const NAME: &'static str = "registered_image_tool"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "Registered Image Tool".into() + } + + fn run( + self: Arc, + _input: ToolInput, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { + 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(®istered_tool_use_id.to_string())); + assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); + } + + #[gpui::test] + async fn test_update_title_tool_replay_does_not_reenter_thread(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + #[gpui::test] + async fn test_update_title_tool_replay_title_when_tool_not_registered(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + fn push_completed_update_title_tool_call( + thread: &mut Thread, + tool_use_id: LanguageModelToolUseId, + ) { + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: UpdateTitleTool::NAME.into(), + raw_input: json!({ "title": "Replayed title" }).to_string(), + input: json!({ "title": "Replayed title" }), + is_input_complete: true, + thought_signature: None, + }; + + let mut tool_results = IndexMap::default(); + tool_results.insert( + tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id, + tool_name: UpdateTitleTool::NAME.into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Text( + "Session title updated".into(), + )], + output: Some(json!("Session title updated")), + }, + ); + + thread.messages.push(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::ToolUse(tool_use)], + tool_results, + reasoning_details: None, + })); + } + #[gpui::test] async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { let (parent, _event_stream) = setup_thread_for_test(cx).await; diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 5440fe149f8..187ce7f6578 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -24,6 +24,7 @@ mod symbol_locator; mod terminal_tool; mod tool_permissions; mod update_plan_tool; +mod update_title_tool; mod web_search_tool; mod write_file_tool; @@ -80,6 +81,7 @@ pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; +pub use update_title_tool::*; pub use web_search_tool::*; pub use write_file_tool::*; @@ -172,6 +174,7 @@ tools! { SpawnAgentTool, TerminalTool, UpdatePlanTool, + UpdateTitleTool, WebSearchTool, WriteFileTool, } diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 01601679c90..d9dc972e24f 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -5,7 +5,7 @@ use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; use futures::FutureExt as _; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; -use language_model::LanguageModelToolResultContent; +use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; use util::ResultExt; @@ -261,7 +261,8 @@ impl ContextServerRegistry { } ContextServerStatus::Stopped | ContextServerStatus::Error(_) - | ContextServerStatus::AuthRequired => { + | ContextServerStatus::AuthRequired + | ContextServerStatus::ClientSecretRequired { .. } => { if let Some(registered_server) = self.registered_servers.remove(server_id) { if !registered_server.tools.is_empty() { cx.emit(ContextServerRegistryEvent::ToolsChanged); @@ -346,7 +347,7 @@ impl AnyAgentTool for ContextServerTool { let authorize = event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx); - cx.spawn(async move |_cx| { + cx.spawn(async move |cx| { let input = input .recv() .await @@ -394,15 +395,50 @@ impl AnyAgentTool for ContextServerTool { } let mut llm_output = Vec::new(); + let mut tool_call_content = Vec::new(); let mut concatenated_text = String::new(); for content in response.content { match content { context_server::types::ToolResponseContent::Text { text } => { concatenated_text.push_str(&text); + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.clone())), + ))); llm_output.push(LanguageModelToolResultContent::Text(text.into())); } - context_server::types::ToolResponseContent::Image { .. } => { - log::warn!("Ignoring image content from tool response"); + context_server::types::ToolResponseContent::Image { data, mime_type } => { + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Image(acp::ImageContent::new( + data.clone(), + mime_type.clone(), + )), + ))); + let language_model_image = cx + .background_spawn({ + let mime_type = mime_type.clone(); + async move { + LanguageModelImage::from_base64_image(&data, &mime_type) + } + }) + .await; + match language_model_image { + Ok(Some(image)) => { + llm_output.push(LanguageModelToolResultContent::Image(image)); + } + Ok(None) => { + log::warn!( + "Skipping MCP tool response image with MIME type `{}` because it cannot be converted for language model input", + mime_type + ); + } + Err(error) => { + log::warn!( + "Failed to convert MCP tool response image with MIME type `{}` for language model input: {:#}", + mime_type, + error + ); + } + } } context_server::types::ToolResponseContent::Audio { .. } => { log::warn!("Ignoring audio content from tool response"); @@ -415,6 +451,10 @@ impl AnyAgentTool for ContextServerTool { } } } + if !tool_call_content.is_empty() { + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(tool_call_content)); + } let raw_output = serde_json::Value::String(concatenated_text); Ok(AgentToolOutput { raw_output, diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index 1d6528007d0..89d4ef54677 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,16 +1,18 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol::schema as acp; -use anyhow::Result; -use futures::FutureExt as _; -use gpui::{App, Entity, Task}; +use futures::{Future, FutureExt as _}; +use gpui::{App, AsyncApp, Entity, Task}; use language::{DiagnosticSeverity, OffsetRangeExt}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::{fmt::Write, sync::Arc}; use ui::SharedString; use util::markdown::MarkdownInlineCode; +type Result = core::result::Result; + /// Get errors and warnings for the project or a specific file. /// /// This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase. @@ -18,6 +20,11 @@ use util::markdown::MarkdownInlineCode; /// When a path is provided, shows all diagnostics for that specific file. /// When no path is provided, shows a summary of error and warning counts for all files in the project. /// +/// This tool attempts to refresh diagnostics before returning. +/// If refreshing diagnostics fails (for example, if the language server does not support pull-based diagnostics), it will return any diagnostics already present. +/// Note that, in this case, the results may be out-of-date, and may or may not reflect the most recent edits. +/// If this happens, do not attempt to re-run this tool in the hope that refreshing will later succeed. Failures are typically persistent. +/// /// /// To get diagnostics for a specific file: /// { @@ -60,6 +67,71 @@ impl DiagnosticsTool { } } +async fn with_cancellation(f: impl Future, s: &ToolCallEventStream) -> Result { + 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, + path: Option<&Path>, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + match path { + Some(path) => { + let open_buffer_task = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(path, cx) else { + return Err(format!("Could not find path {} in project", path.display())); + }; + Ok(project.open_buffer(project_path, cx)) + })?; + + let buffer = with_cancellation(open_buffer_task, event_stream) + .await? + .map_err(|e| e.to_string())?; + + let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + let pull_result = with_cancellation(pull_task, event_stream).await?; + if let Err(error) = &pull_result { + log::warn!("Failed to pull diagnostics, using cached: {error:#}"); + } + Ok(pull_result.is_ok()) + } + None => { + let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + let succeeded = with_cancellation(pull_task, event_stream).await?; + if !succeeded { + log::warn!("Failed to pull workspace diagnostics, using cached"); + } + Ok(succeeded) + } + } +} + impl AgentTool for DiagnosticsTool { type Input = DiagnosticsToolInput; type Output = String; @@ -96,21 +168,22 @@ impl AgentTool for DiagnosticsTool { let input = input.recv().await.map_err(|e| e.to_string())?; match input.path { - Some(path) if !path.is_empty() => { - let (_project_path, open_buffer_task) = project.update(cx, |project, cx| { - let Some(project_path) = project.find_project_path(&path, cx) else { + Some(ref path) if !path.is_empty() => { + let refreshed = + pull_diagnostics(&project, Some(Path::new(path)), &event_stream, cx) + .await?; + + let open_buffer_task = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(path, cx) else { return Err(format!("Could not find path {path} in project")); }; - let task = project.open_buffer(project_path.clone(), cx); - Ok((project_path, task)) + Ok(project.open_buffer(project_path, cx)) })?; - let buffer = futures::select! { - result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Diagnostics cancelled by user".to_string()); - } - }; + let buffer = with_cancellation(open_buffer_task, &event_stream) + .await? + .map_err(|e| e.to_string())?; + let mut output = String::new(); let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); @@ -133,13 +206,18 @@ impl AgentTool for DiagnosticsTool { .ok(); } + let freshness = freshness_message(refreshed); if output.is_empty() { - Ok("File doesn't have errors or warnings!".to_string()) + Ok(format!( + "{freshness}\n\nFile doesn't have errors or warnings!" + )) } else { - Ok(output) + Ok(format!("{freshness}\n\n{output}")) } } _ => { + let refreshed = pull_diagnostics(&project, None, &event_stream, cx).await?; + let (output, has_diagnostics) = project.read_with(cx, |project, cx| { let mut output = String::new(); let mut has_diagnostics = false; @@ -165,10 +243,13 @@ impl AgentTool for DiagnosticsTool { (output, has_diagnostics) }); + let freshness = freshness_message(refreshed); if has_diagnostics { - Ok(output) + Ok(format!("{freshness}\n\n{output}")) } else { - Ok("No errors or warnings found in the project.".into()) + Ok(format!( + "{freshness}\n\nNo errors or warnings found in the project." + )) } } } diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index 3961edf564c..71dbc2c9bba 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -113,7 +113,7 @@ impl StreamingParser { { if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { // new_text appeared after old_text, so old_text is done — emit everything. - let start = state.old_text_emitted_len.min(old_text.len()); + let start = find_char_boundary(old_text, state.old_text_emitted_len); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); @@ -124,9 +124,10 @@ impl StreamingParser { }); } else { let safe_end = safe_emit_end_for_edit_text(old_text); + let safe_start = find_char_boundary(old_text, state.old_text_emitted_len); - if safe_end > state.old_text_emitted_len { - let chunk = old_text[state.old_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = old_text[safe_start..safe_end].to_string(); state.old_text_emitted_len = safe_end; events.push(EditEvent::OldTextChunk { edit_index: index, @@ -143,9 +144,10 @@ impl StreamingParser { && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); + let safe_start = find_char_boundary(new_text, state.new_text_emitted_len); - if safe_end > state.new_text_emitted_len { - let chunk = new_text[state.new_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = new_text[safe_start..safe_end].to_string(); state.new_text_emitted_len = safe_end; events.push(EditEvent::NewTextChunk { edit_index: index, @@ -343,8 +345,10 @@ impl StreamingParser { /// held back because it may be an artifact of the partial JSON fixer closing /// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`). /// The next partial will reveal the correct character. +/// +/// The returned position is always a valid UTF-8 character boundary. fn safe_emit_end(text: &str) -> usize { - if text.as_bytes().last() == Some(&b'\\') { + if text.ends_with('\\') { text.len() - 1 } else { text.len() @@ -353,13 +357,35 @@ fn safe_emit_end(text: &str) -> usize { fn safe_emit_end_for_edit_text(text: &str) -> usize { let safe_end = safe_emit_end(text); - if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' { + // Use string slicing to check the last character, ensuring we respect UTF-8 boundaries. + if safe_end > 0 && text[..safe_end].ends_with('\n') { safe_end - 1 } else { safe_end } } +/// Finds a valid UTF-8 character boundary at or before the target position. +/// +/// When streaming partial JSON, the text structure can change between updates +/// (e.g., an escape sequence being completed). This means a byte position that +/// was valid in one partial may land inside a multi-byte character in the next. +/// This function finds the nearest valid boundary at or before the target. +fn find_char_boundary(text: &str, target: usize) -> usize { + if target >= text.len() { + return text.len(); + } + if text.is_char_boundary(target) { + return target; + } + // Walk backwards to find a valid boundary. + let mut pos = target; + while pos > 0 && !text.is_char_boundary(pos) { + pos -= 1; + } + pos +} + fn normalize_done_chunk(mut chunk: String) -> String { if chunk.ends_with('\n') { chunk.pop(); @@ -1146,4 +1172,77 @@ mod tests { }] ); } + + #[test] + fn test_multibyte_char_with_trailing_backslash() { + // Reproduces a panic where the stored `old_text_emitted_len` from a previous + // partial lands inside a multi-byte UTF-8 character in the current partial. + // + // Scenario: The JSON fixer produces a literal backslash when the stream cuts + // mid-escape. If the *next* partial replaces that backslash with a multi-byte + // character (e.g., em-dash '—'), the stored byte position is no longer valid. + let mut parser = StreamingParser::default(); + + // First partial: text ends with backslash (held back by safe_emit_end). + // "abc" = 3 bytes, backslash held back, so emitted_len = 3. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("abc\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "abc".into(), + done: false, + }] + ); + + // Second partial: the backslash is replaced by em-dash '—' (3 bytes: E2 80 94). + // "ab—" = 2 + 3 = 5 bytes total, with em-dash at bytes 2..5. + // The stored emitted_len (3) is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ab—".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + let _ = events; + } + + #[test] + fn test_emitted_len_inside_multibyte_char_boundary() { + // More direct reproduction: emitted_len points inside a multi-byte character. + // + // This can happen when: + // 1. First partial has text where byte N is a valid boundary + // 2. Second partial has *different* text where byte N is inside a multi-byte char + let mut parser = StreamingParser::default(); + + // First partial: "ab" (2 bytes), backslash held back. + // After processing: emitted_len = 2 + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ab\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ab".into(), + done: false, + }] + ); + + // Second partial: "a—" where em-dash starts at byte 1 and spans bytes 1-3. + // Stored emitted_len = 2, but byte 2 is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("a—".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + // We don't care exactly what it emits, just that it doesn't panic. + let _ = events; + } } diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 95301ee1523..4638b170314 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -338,6 +338,7 @@ impl AgentTool for ReadFileTool { } let mut anchor = None; + let mut is_outline_response = false; // Check if specific line ranges are provided let result = if input.start_line.is_some() || input.end_line.is_some() { @@ -377,6 +378,8 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer.clone(), cx); }); + is_outline_response = buffer_content.is_outline; + if buffer_content.is_outline { Ok(formatdoc! {" SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers. @@ -409,11 +412,12 @@ impl AgentTool for ReadFileTool { } if let Ok(LanguageModelToolResultContent::Text(text)) = &result { let text: &str = text; - let markdown = MarkdownCodeBlock { - tag: &input.path, - text, - } - .to_string(); + // For outline responses, omit the path tag so the markdown renderer + // does not invoke tree-sitter syntax highlighting against pseudo-code + // outline text. The outline is not valid source for the file's language, + // so highlighting would be both expensive and incorrect. + let tag: &str = if is_outline_response { "" } else { &input.path }; + let markdown = MarkdownCodeBlock { tag, text }.to_string(); event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(markdown)), ])); @@ -610,6 +614,131 @@ mod test { ); } + // The outline returned for a large file is not valid source for the file's + // language, so the UI-side markdown wrapping must omit the path tag. + // Otherwise the markdown renderer routes the fenced block through + // `CodeBlockKind::FencedSrc`, resolves the file's language, and runs + // tree-sitter against pseudo-code outline text on every paint. + #[gpui::test] + async fn test_outline_response_uses_untagged_code_block(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(language::rust_lang()); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); + let (event_stream, mut rx) = ToolCallEventStream::test(); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/large_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.clone() + .run(ToolInput::resolved(input), event_stream, cx) + }) + .await + .unwrap(); + + // Sanity-check: the file is large enough to trigger the outline branch. + assert!( + result + .to_str() + .unwrap() + .starts_with("SUCCESS: File outline retrieved."), + "expected outline response, got: {:?}", + result.to_str().unwrap() + ); + + // The first update carries the location; the second carries the + // markdown content destined for the tool-call UI. + let _location_update = rx.expect_update_fields().await; + let content_update = rx.expect_update_fields().await; + let content_blocks = content_update.content.expect("expected content update"); + let acp::ToolCallContent::Content(content) = content_blocks + .first() + .expect("expected at least one content block") + else { + panic!("expected ContentBlock, got {:?}", content_blocks.first()); + }; + let acp::ContentBlock::Text(text) = &content.content else { + panic!("expected text content block, got {:?}", content.content); + }; + + assert!( + text.text.starts_with("```\n"), + "outline response must use an untagged fenced code block; got first line: {:?}", + text.text.lines().next() + ); + assert!( + !text.text.starts_with("```root/"), + "outline response must not include the file path as a code block tag" + ); + } + + // The full-file (non-outline) response should still tag the code block + // with the file path so the markdown renderer can resolve the file's + // language for syntax highlighting. + #[gpui::test] + async fn test_full_file_response_keeps_path_tag(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "small_file.rs": "fn main() {}" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); + let (event_stream, mut rx) = ToolCallEventStream::test(); + + cx.update(|cx| { + let input = ReadFileToolInput { + path: "root/small_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.clone() + .run(ToolInput::resolved(input), event_stream, cx) + }) + .await + .unwrap(); + + let _location_update = rx.expect_update_fields().await; + let content_update = rx.expect_update_fields().await; + let content_blocks = content_update.content.expect("expected content update"); + let acp::ToolCallContent::Content(content) = content_blocks + .first() + .expect("expected at least one content block") + else { + panic!("expected ContentBlock, got {:?}", content_blocks.first()); + }; + let acp::ContentBlock::Text(text) = &content.content else { + panic!("expected text content block, got {:?}", content.content); + }; + + assert!( + text.text.starts_with("```root/small_file.rs\n"), + "full-file response must tag the code block with the file path; got first line: {:?}", + text.text.lines().next() + ); + } + // When a worktree is named "foo" and contains a subdirectory also named "foo", // read_file({"path": "foo/test.txt"}) should return the file at the worktree // root (as the tool schema promises), not the one inside the foo/ subdirectory. diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index ac8fa1dccb2..d05b9872b8c 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use std::sync::Arc; use agent_client_protocol::schema as acp; +use collections::HashSet; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; @@ -95,6 +96,12 @@ impl AgentTool for RenameTool { )); } + let buffers = transaction.0.keys().cloned().collect::>(); + project + .update(cx, |project, cx| project.save_buffers(buffers, cx)) + .await + .map_err(|e| format!("Rename succeeded, but failed to save renamed files: {e}"))?; + let mut output = format!( "Renamed `{}` to `{}` in {} file(s):\n", input.symbol.symbol_name, diff --git a/crates/agent/src/tools/skill_tool.rs b/crates/agent/src/tools/skill_tool.rs index d45633da505..978a24f6968 100644 --- a/crates/agent/src/tools/skill_tool.rs +++ b/crates/agent/src/tools/skill_tool.rs @@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String { /// frontmatter), not O(total file size). pub fn render_skill_envelope(skill: &Skill, body: &str) -> String { let source = match &skill.source { + agent_skills::SkillSource::BuiltIn => "built-in", agent_skills::SkillSource::Global => "global", agent_skills::SkillSource::ProjectLocal { .. } => "project-local", }; let worktree = match &skill.source { - agent_skills::SkillSource::Global => None, + agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None, agent_skills::SkillSource::ProjectLocal { worktree_root_name, .. } => Some(worktree_root_name.clone()), @@ -200,31 +201,33 @@ impl AgentTool for SkillTool { (skill.clone(), path_string) }; - // Read the body on demand. Bodies are not kept in memory - // between materializations — see `agent_skills::read_skill_body`. - let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) - .await - .map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // For built-in skills the body is already in memory (compiled + // into the binary). For user skills, read on demand from disk. + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) + .await + .map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })? + }; let rendered = render_skill_envelope(&skill, &body); - // Activations go through the standard tool-permission flow so - // they participate in the same Allow-Once / Always-Allow UX as - // every other built-in tool. The auth context value is the - // skill's absolute SKILL.md path so that "always allow this - // specific skill" is keyed to a specific file: editing the - // SKILL.md will change the path's content but not the path, - // so for content-change re-trust we'd want a hash too — but - // at minimum, two skills with the same name from different - // locations get independent trust grants. - let authorize = cx.update(|cx| { - let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); - event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) - }); - authorize.await.map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // Built-in skills ship with Zed and are trusted by default, + // so they skip the authorization prompt. User-installed skills + // go through the standard Allow-Once / Always-Allow UX. + let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn; + if !is_builtin { + let authorize = cx.update(|cx| { + let context = + crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); + event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) + }); + authorize.await.map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })?; + } Ok(SkillToolOutput::Found { rendered }) }) diff --git a/crates/agent/src/tools/update_title_tool.rs b/crates/agent/src/tools/update_title_tool.rs new file mode 100644 index 00000000000..b86b82f9ac0 --- /dev/null +++ b/crates/agent/src/tools/update_title_tool.rs @@ -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, +} + +impl UpdateTitleTool { + pub fn new(thread: WeakEntity) -> Self { + Self { thread } + } + + pub(crate) fn title_for_input( + input: Result, + ) -> 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, + _cx: &mut App, + ) -> SharedString { + Self::title_for_input(input) + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + 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 { + 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")); + } +} diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index b3328790a5d..3a718c7a9e8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,7 +9,7 @@ use agent_client_protocol::{ }; use anyhow::anyhow; use async_channel; -use collections::HashMap; +use collections::{HashMap, HashSet}; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::channel::mpsc; use futures::future::Shared; @@ -509,6 +509,7 @@ impl AgentSessionList for AcpSessionList { cx: &mut App, ) -> Task> { let conn = self.connection.clone(); + let include_additional_directories = cx.has_flag::(); cx.foreground_executor().spawn(async move { let acp_request = acp::ListSessionsRequest::new() .cwd(request.cwd) @@ -522,7 +523,14 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - work_dirs: Some(PathList::new(&[s.cwd])), + work_dirs: Some(work_dirs_from_session_info( + s.cwd, + if include_additional_directories { + s.additional_directories + } else { + vec![] + }, + )), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) @@ -1053,6 +1061,15 @@ impl AcpConnection { } } + fn session_directories_from_work_dirs( + &self, + work_dirs: &PathList, + cx: &App, + ) -> Result { + let supports_additional_directories = self.supports_session_additional_directories(cx); + session_directories_from_work_dirs(work_dirs, supports_additional_directories) + } + fn open_or_create_session( self: Rc, session_id: acp::SessionId, @@ -1062,7 +1079,7 @@ impl AcpConnection { rpc_call: impl FnOnce( ConnectionTo, acp::SessionId, - PathBuf, + SessionDirectories, ) -> futures::future::LocalBoxFuture<'static, Result> + 'static, @@ -1089,9 +1106,9 @@ impl AcpConnection { } } - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let shared_task = cx @@ -1133,7 +1150,9 @@ impl AcpConnection { ); let response = - match rpc_call(this.connection.clone(), session_id.clone(), cwd).await { + match rpc_call(this.connection.clone(), session_id.clone(), directories) + .await + { Ok(response) => response, Err(err) => { this.sessions.borrow_mut().remove(&session_id); @@ -1288,6 +1307,77 @@ impl AcpConnection { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct SessionDirectories { + cwd: PathBuf, + additional_directories: Vec, +} + +impl SessionDirectories { + fn into_new_session_request(self, mcp_servers: Vec) -> 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::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::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 { + 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) -> PathList { + let mut seen_paths = HashSet::default(); + let mut paths = Vec::with_capacity(1 + additional_directories.len()); + + seen_paths.insert(cwd.clone()); + paths.push(cwd); + + for path in additional_directories { + if seen_paths.insert(path.clone()) { + paths.push(path); + } + } + + PathList::new(&paths) +} + fn emit_load_error_to_all_sessions( sessions: &Rc>>, error: LoadError, @@ -1385,17 +1475,18 @@ impl AgentConnection for AcpConnection { work_dirs: PathList, cx: &mut App, ) -> Task>> { - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs, cx) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { let response = into_foreground_future( - self.connection - .send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)), + self.connection.send_request( + directories.into_new_session_request(mcp_servers), + ), ) .await .map_err(map_acp_error)?; @@ -1550,6 +1641,15 @@ impl AgentConnection for AcpConnection { .is_some() } + fn supports_session_additional_directories(&self, cx: &App) -> bool { + cx.has_flag::() + && self + .agent_capabilities + .session_capabilities + .additional_directories + .is_some() + } + fn load_session( self: Rc, session_id: acp::SessionId, @@ -1570,14 +1670,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::LoadSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_load_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -1616,14 +1713,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::ResumeSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_resume_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -2107,6 +2201,10 @@ pub mod test_support { self.inner.supports_resume_session() } + fn supports_session_additional_directories(&self, cx: &App) -> bool { + self.inner.supports_session_additional_directories(cx) + } + fn resume_session( self: Rc, 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![ + 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![ + 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![ + 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![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::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, + cx: &mut gpui::TestAppContext, + ) -> ConnectionTo { + 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| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().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::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::FeatureFlagStore::init(cx); + }); + + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + ]); + + let missing_capability = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert!(missing_capability.additional_directories.is_empty()); + + Rc::get_mut(&mut harness.connection) + .expect("test harness should own the only ACP connection handle") + .agent_capabilities + .session_capabilities + .additional_directories = Some(acp::SessionAdditionalDirectoriesCapabilities::new()); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "off".to_string()); + }); + }); + }); + let disabled = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert!(disabled.additional_directories.is_empty()); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert("acp-beta".to_string(), "on".to_string()); + }); + }); + }); + let enabled = cx + .update(|cx| { + harness + .connection + .session_directories_from_work_dirs(&work_dirs, cx) + }) + .expect("work dirs should convert"); + assert_eq!( + enabled, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: vec![std::path::PathBuf::from("/workspace-a")], + } + ); + } + #[gpui::test] async fn session_delete_support_requires_beta_flag_and_capability( cx: &mut gpui::TestAppContext, @@ -3407,6 +3844,7 @@ fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec Some(acp::McpServer::Http( acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( headers diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b3574f6e81a..77c9595f171 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -88,22 +88,18 @@ impl AgentServer for CustomAgentServer { let config_id = config_id.to_string(); let value_id = value_id.to_string(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { favorite_config_option_values, .. } - | settings::CustomAgentServerSettings::Extension { - favorite_config_option_values, - .. - } | settings::CustomAgentServerSettings::Registry { favorite_config_option_values, .. @@ -129,16 +125,15 @@ impl AgentServer for CustomAgentServer { fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } - | settings::CustomAgentServerSettings::Extension { default_mode, .. } | settings::CustomAgentServerSettings::Registry { default_mode, .. } => { *default_mode = mode_id.map(|m| m.to_string()); } @@ -161,16 +156,15 @@ impl AgentServer for CustomAgentServer { fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } - | settings::CustomAgentServerSettings::Extension { default_model, .. } | settings::CustomAgentServerSettings::Registry { default_model, .. } => { *default_model = model_id.map(|m| m.to_string()); } @@ -205,20 +199,17 @@ impl AgentServer for CustomAgentServer { cx: &App, ) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { favorite_models, .. } - | settings::CustomAgentServerSettings::Extension { - favorite_models, .. - } | settings::CustomAgentServerSettings::Registry { favorite_models, .. } => favorite_models, @@ -258,22 +249,18 @@ impl AgentServer for CustomAgentServer { let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_config_options, .. } - | settings::CustomAgentServerSettings::Extension { - default_config_options, - .. - } | settings::CustomAgentServerSettings::Registry { default_config_options, .. @@ -307,10 +294,6 @@ impl AgentServer for CustomAgentServer { default_config_options, .. } - | project::agent_server_store::CustomAgentServerSettings::Extension { - default_config_options, - .. - } | project::agent_server_store::CustomAgentServerSettings::Registry { default_config_options, .. @@ -422,28 +405,14 @@ fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { is_in_registry || is_settings_registry } -fn default_settings_for_agent( - agent_id: impl Into, - cx: &App, -) -> settings::CustomAgentServerSettings { - if is_registry_agent(agent_id, cx) { - settings::CustomAgentServerSettings::Registry { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - } - } else { - settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - } +fn default_settings_for_agent() -> settings::CustomAgentServerSettings { + settings::CustomAgentServerSettings::Registry { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), } } @@ -547,53 +516,4 @@ mod tests { assert!(is_registry_agent("agent-from-settings", cx)); }); } - - #[gpui::test] - fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) { - init_test(cx); - set_agent_server_settings( - cx, - vec![( - "my-extension-agent", - settings::CustomAgentServerSettings::Extension { - env: HashMap::default(), - default_mode: None, - default_model: None, - favorite_models: Vec::new(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - }, - )], - ); - cx.update(|cx| { - assert!(!is_registry_agent("my-extension-agent", cx)); - }); - } - - #[gpui::test] - fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - assert!(matches!( - default_settings_for_agent("some-extension-agent", cx), - settings::CustomAgentServerSettings::Extension { .. } - )); - }); - } - - #[gpui::test] - fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) { - init_test(cx); - init_registry_with_agents(cx, &["new-registry-agent"]); - cx.update(|cx| { - assert!(matches!( - default_settings_for_agent("new-registry-agent", cx), - settings::CustomAgentServerSettings::Registry { .. } - )); - assert!(matches!( - default_settings_for_agent("not-in-registry", cx), - settings::CustomAgentServerSettings::Extension { .. } - )); - }); - } } diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 63c506bbf64..0c240998f74 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -1,7 +1,8 @@ use anyhow::{Context as _, Result}; -use const_format::concatcp; +use const_format::{concatcp, formatcp}; use fs::Fs; use futures::StreamExt; +use gpui::{Global, SharedString}; use serde::{Deserialize, Serialize}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; @@ -64,11 +65,19 @@ pub struct Skill { /// `skill` tool refuses to load it. The user can still invoke it as a /// slash command. pub disable_model_invocation: bool, + /// For built-in skills whose content is compiled into the binary, + /// this holds the full SKILL.md body so the skill tool can serve it + /// without a filesystem read. + pub embedded_body: Option<&'static str>, } /// Indicates where a skill was loaded from. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SkillSource { + /// Compiled into the Zed binary. These are always available and have + /// the lowest override priority (global and project-local skills can + /// shadow them). + BuiltIn, /// From ~/.agents/skills/ Global, /// From {project}/.agents/skills/ @@ -79,6 +88,23 @@ pub enum SkillSource { } impl SkillSource { + /// Precedence for resolving same-named skills. Higher values shadow + /// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources + /// returning equal precedence (e.g. two project-local skills from + /// different worktrees) leave the winner up to the caller, which by + /// convention keeps the first one in iteration order. + /// + /// Adding a new `SkillSource` variant should be a one-line change + /// here — every consumer routes through this method so the hierarchy + /// stays in sync. + pub fn precedence(&self) -> u8 { + match self { + Self::BuiltIn => 0, + Self::Global => 1, + Self::ProjectLocal { .. } => 2, + } + } + /// Scope prefix used in the `/:` slash-command /// syntax that the autocomplete popup inserts. Global skills use /// an empty prefix (so the inserted text is `/:`), and @@ -91,9 +117,21 @@ impl SkillSource { /// invoked as `/:`, and the worktree's skill is invoked as /// `/global:`. The two grammars never collide on the /// inserted text. + /// Human-readable label for this source, used in the UI to + /// distinguish skills from different origins. + pub fn display_label(&self) -> &str { + match self { + Self::BuiltIn => "built-in", + Self::Global => "global", + Self::ProjectLocal { + worktree_root_name, .. + } => worktree_root_name.as_ref(), + } + } + pub fn scope_prefix(&self) -> &str { match self { - Self::Global => "", + Self::BuiltIn | Self::Global => "", Self::ProjectLocal { worktree_root_name, .. } => worktree_root_name.as_ref(), @@ -112,7 +150,7 @@ impl SkillSource { /// strictness only affects users typing by memory. pub fn matches_scope(&self, scope: &str) -> bool { match self { - Self::Global => scope.is_empty(), + Self::BuiltIn | Self::Global => scope.is_empty(), Self::ProjectLocal { worktree_root_name, .. } => !scope.is_empty() && worktree_root_name.as_ref() == scope, @@ -120,6 +158,23 @@ impl SkillSource { } } +/// App-wide index of loaded skills, published by NativeAgent and read +/// by any UI that needs to display the skill list (e.g. Settings UI). +#[derive(Default)] +pub struct SkillIndex { + pub global_skills: Vec, + pub project_skills: Vec, +} + +#[derive(Clone)] +pub struct ProjectSkillGroup { + pub worktree_id: SkillScopeId, + pub worktree_root_name: SharedString, + pub skills: Vec, +} + +impl Global for SkillIndex {} + /// Just the frontmatter, used for parsing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillMetadata { @@ -196,8 +251,8 @@ pub fn parse_skill_frontmatter( let (metadata, _body) = extract_frontmatter(content)?; - validate_name(&metadata.name)?; - validate_description(&metadata.description)?; + validate_name(&metadata.name).map_err(anyhow::Error::msg)?; + validate_description(&metadata.description).map_err(anyhow::Error::msg)?; let directory_path = skill_file_path .parent() @@ -211,6 +266,7 @@ pub fn parse_skill_frontmatter( directory_path, skill_file_path: skill_file_path.to_path_buf(), disable_model_invocation: metadata.disable_model_invocation, + embedded_body: None, }) } @@ -290,6 +346,14 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { /// by [`validate_name`]. pub const MAX_SKILL_NAME_LEN: usize = 64; +/// Maximum length (in bytes) for a valid skill description. Mirrors the +/// upper bound enforced by [`validate_description`]. +/// +/// Byte-based rather than char-based because that's what `.len()` returns +/// and what every caller currently measures; the UI also surfaces this +/// limit as a byte count so the editor's counter matches the validator. +pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; + /// Convert an arbitrary human-readable string into a valid skill name, or /// return `None` if no valid name can be produced (e.g. the input contains /// no ASCII alphanumeric characters at all). @@ -354,34 +418,54 @@ pub fn slugify_skill_name(input: &str) -> Option { if slug.is_empty() { None } else { Some(slug) } } -fn validate_name(name: &str) -> Result<()> { +/// Validate a skill name against the rules enforced by both the loader +/// and the create-skill UI. +/// +/// Rules: +/// * non-empty +/// * at most [`MAX_SKILL_NAME_LEN`] bytes +/// * ASCII lowercase letters, digits, and hyphens only +/// * must not start or end with a hyphen — [`slugify_skill_name`] +/// already guarantees this for its output, so requiring it in the +/// validator keeps hand-written `SKILL.md` files consistent with +/// slugifier output +/// +/// Error messages are returned as `&'static str` (interpolated at +/// compile time via `formatcp!`) so that UI surfaces can store them in +/// `Option<&'static str>` fields without allocating, and loader callers +/// can convert them to `anyhow::Error` via `anyhow::Error::msg`. +pub fn validate_name(name: &str) -> Result<(), &'static str> { if name.is_empty() { - anyhow::bail!("Skill name cannot be empty"); + return Err("Skill name cannot be empty"); } - if name.len() > MAX_SKILL_NAME_LEN { - anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters"); + return Err(formatcp!( + "Skill name must be at most {MAX_SKILL_NAME_LEN} characters" + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err("Skill name must not start or end with a hyphen"); } - if !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { - anyhow::bail!("Skill name must contain only lowercase letters, numbers, and hyphens"); + return Err("Skill name must contain only lowercase letters, numbers, and hyphens"); } - Ok(()) } -fn validate_description(description: &str) -> Result<()> { - if description.is_empty() { - anyhow::bail!("Skill description cannot be empty"); +/// Validate a skill description against the rules enforced by both the +/// loader and the create-skill UI. +pub fn validate_description(description: &str) -> Result<(), &'static str> { + if description.trim().is_empty() { + return Err("Skill description cannot be empty"); } - - if description.len() > 1024 { - anyhow::bail!("Skill description must be at most 1024 characters"); + if description.len() > MAX_SKILL_DESCRIPTION_LEN { + return Err(formatcp!( + "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes" + )); } - Ok(()) } @@ -600,6 +684,53 @@ pub async fn read_skill_body( Ok(body.trim().to_string()) } +/// Content of the built-in `create-skill` SKILL.md, embedded at compile time. +const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md"); + +/// Returns the set of skills that are compiled into the Zed binary. +pub fn builtin_skills() -> Vec { + 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 `` path since it doesn't live on disk. +fn parse_builtin_skill(name: &str, content: &'static str) -> Result { + 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!("/{}", 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!("/{}", name)).join(SKILL_FILE_NAME); + (expected == skill_file_path).then_some(*content) + }) +} + /// Returns the global skills directory: `~/.agents/skills`. /// /// Other agents (e.g. Claude Code) already write skill files into this @@ -663,6 +794,34 @@ mod tests { use fs::FakeFs; use gpui::TestAppContext; + #[test] + fn test_skill_source_precedence_is_total_and_ordered() { + // Pin the hierarchy: project-local > global > built-in. Every + // override and conflict-resolution site routes through this, + // so the rest of the codebase relies on it being correct. + let built_in = SkillSource::BuiltIn.precedence(); + let global = SkillSource::Global.precedence(); + let project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(1), + worktree_root_name: "my-project".into(), + } + .precedence(); + + assert!(built_in < global, "global must shadow built-in"); + assert!(global < project, "project-local must shadow global"); + + // Two project-local skills from different worktrees tie. The + // "first wins" convention is enforced by the callers, but the + // precedence itself must be equal so neither silently shadows + // the other. + let other_project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(2), + worktree_root_name: "other-project".into(), + } + .precedence(); + assert_eq!(project, other_project); + } + #[test] fn test_parse_valid_skill() { let content = r#"--- @@ -873,12 +1032,8 @@ Content. SkillSource::Global, ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("at most 64 characters") - ); + let expected = format!("at most {MAX_SKILL_NAME_LEN} characters"); + assert!(result.unwrap_err().to_string().contains(&expected)); } #[test] @@ -1154,12 +1309,8 @@ Content. SkillSource::Global, ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("at most 1024 characters") - ); + let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes"); + assert!(result.unwrap_err().to_string().contains(&expected)); } #[test] @@ -1532,6 +1683,7 @@ description: A skill with no body content directory_path: PathBuf::from("/skills/test-skill"), skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); @@ -1759,4 +1911,89 @@ description: A skill with no body content "project/.AGENTS/SKILLS/foo" ))); } + + #[test] + fn validate_name_accepts_valid_names() { + assert!(validate_name("draft-pr").is_ok()); + assert!(validate_name("a").is_ok()); + assert!(validate_name("skill1").is_ok()); + assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN)).is_ok()); + } + + #[test] + fn validate_name_rejects_empty() { + assert!(validate_name("").is_err()); + } + + #[test] + fn validate_name_rejects_uppercase() { + assert!(validate_name("Draft-PR").is_err()); + } + + #[test] + fn validate_name_rejects_leading_and_trailing_hyphens() { + assert!(validate_name("-draft").is_err()); + assert!(validate_name("draft-").is_err()); + } + + #[test] + fn validate_name_rejects_invalid_chars() { + assert!(validate_name("draft_pr").is_err()); + assert!(validate_name("draft pr").is_err()); + assert!(validate_name("draft.pr").is_err()); + } + + #[test] + fn validate_name_rejects_too_long() { + assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN + 1)).is_err()); + } + + #[test] + fn validate_description_accepts_valid() { + assert!(validate_description("A useful skill").is_ok()); + } + + #[test] + fn validate_description_rejects_empty_and_whitespace_only() { + assert!(validate_description("").is_err()); + assert!(validate_description(" ").is_err()); + assert!(validate_description("\t\n ").is_err()); + } + + #[test] + fn validate_description_rejects_too_long() { + assert!(validate_description(&"a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1)).is_err()); + } + + #[test] + fn validate_description_length_is_measured_in_bytes() { + // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has + // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be + // rejected by a byte-based validator (and accepted by a char-based + // one). This regression-tests the byte semantics that the loader + // and UI both rely on. + let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1; + let description = "é".repeat(chars); + assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN); + assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN); + assert!(validate_description(&description).is_err()); + } + + #[test] + fn slugify_output_always_passes_validate_name() { + for input in [ + "foo", + "Foo Bar", + "rock & roll", + "---weird---", + "a".repeat(200).as_str(), + ] { + if let Some(slug) = slugify_skill_name(input) { + assert!( + validate_name(&slug).is_ok(), + "slug {slug:?} from {input:?} failed validate_name" + ); + } + } + } } diff --git a/crates/agent_skills/builtin/create-skill/SKILL.md b/crates/agent_skills/builtin/create-skill/SKILL.md new file mode 100644 index 00000000000..4728ca46714 --- /dev/null +++ b/crates/agent_skills/builtin/create-skill/SKILL.md @@ -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.md` | Personal skills, available in all projects | +| Project-local | `/.agents/skills//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 1–64 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 1–1024 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 `` 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). diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 03cb331334f..68b0b5faa41 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -30,10 +30,10 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true -agent_skills.workspace = true async-channel.workspace = true agent_servers.workspace = true agent_settings.workspace = true +agent_skills.workspace = true ai_onboarding.workspace = true anyhow.workspace = true heapless.workspace = true @@ -69,6 +69,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +lru.workspace = true lsp.workspace = true markdown.workspace = true menu.workspace = true @@ -89,6 +90,7 @@ remote.workspace = true remote_connection.workspace = true rope.workspace = true rules_library.workspace = true +skill_creator.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 67d21211026..b90a02c30e5 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -664,8 +664,14 @@ impl AgentConfiguration { None }; let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); + let client_secret_required = matches!( + server_status, + ContextServerStatus::ClientSecretRequired { .. } + ); let authenticating = matches!(server_status, ContextServerStatus::Authenticating); let context_server_store = self.context_server_store.clone(); + let workspace = self.workspace.clone(); + let language_registry = self.language_registry.clone(); let tool_count = self .context_server_registry @@ -685,6 +691,9 @@ impl AgentConfiguration { ContextServerStatus::Error(_) => AiSettingItemStatus::Error, ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, + ContextServerStatus::ClientSecretRequired { .. } => { + AiSettingItemStatus::ClientSecretRequired + } ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, }; @@ -886,7 +895,7 @@ impl AgentConfiguration { ), ) .child( - Button::new("error-logout-server", "Authenticate") + Button::new("authenticate-server", "Authenticate") .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .on_click({ @@ -900,6 +909,46 @@ impl AgentConfiguration { ) .into_any_element(), ) + } else if client_secret_required { + Some( + feedback_base_container() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new("Enter a client secret to connect this server") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + Button::new("enter-client-secret", "Enter Client Secret") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_id = context_server_id.clone(); + move |_event, window, cx| { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } + }), + ) + .into_any_element(), + ) } else if authenticating { Some( h_flex() @@ -1125,7 +1174,6 @@ impl AgentConfiguration { }; let source_kind = match source { - ExternalAgentSource::Extension => AiSettingItemSource::Extension, ExternalAgentSource::Registry => AiSettingItemSource::Registry, ExternalAgentSource::Custom => AiSettingItemSource::Custom, }; @@ -1169,26 +1217,6 @@ impl AgentConfiguration { }); let uninstall_button = match source { - ExternalAgentSource::Extension => Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Uninstall Agent Extension")) - .on_click(cx.listener(move |this, _, _window, cx| { - let agent_name = agent_server_name.clone(); - - if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| { - store.get_extension_id_for_agent(&agent_name) - }) { - ExtensionStore::global(cx) - .update(cx, |store, cx| store.uninstall_extension(ext_id, cx)) - .detach_and_log_err(cx); - } - })), - ), ExternalAgentSource::Registry => { let fs = self.fs.clone(); Some( diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 48d01e506bf..5ccc901b4a4 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -17,7 +17,7 @@ use project::{ ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, registry::ContextServerDescriptorRegistry, }, - project_settings::{ContextServerSettings, ProjectSettings}, + project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings}, worktree_store::WorktreeStore, }; use serde::Deserialize; @@ -43,7 +43,9 @@ enum ConfigurationTarget { id: ContextServerId, url: String, headers: HashMap, + oauth: Option, }, + Extension { id: ContextServerId, repository_url: Option, @@ -121,15 +123,17 @@ impl ConfigurationSource { id, url, headers: auth, + oauth, } => ConfigurationSource::Existing { editor: create_editor( - context_server_http_input(Some((id, url, auth))), + context_server_http_input(Some((id, url, auth, oauth))), jsonc_language, window, cx, ), is_http: true, }, + ConfigurationTarget::Extension { id, repository_url, @@ -168,7 +172,7 @@ impl ConfigurationSource { ConfigurationSource::New { editor, is_http } | ConfigurationSource::Existing { editor, is_http } => { if *is_http { - parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| { + parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, ContextServerSettings::Http { @@ -176,6 +180,7 @@ impl ConfigurationSource { url, headers: auth, timeout: None, + oauth, }, ) }) @@ -256,11 +261,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) } fn context_server_http_input( - existing: Option<(ContextServerId, String, HashMap)>, + existing: Option<( + ContextServerId, + String, + HashMap, + Option, + )>, ) -> String { - let (name, url, headers) = match existing { - Some((id, url, headers)) => { - let header = if headers.is_empty() { + let (name, url, headers, oauth) = match existing { + Some((id, url, headers, oauth)) => { + let headers = if headers.is_empty() { r#"// "Authorization": "Bearer "#.to_string() } else { let json = serde_json::to_string_pretty(&headers).unwrap(); @@ -274,15 +284,48 @@ fn context_server_http_input( .map(|line| format!(" {}", line)) .collect::() }; - (id.0.to_string(), url, header) + (id.0.to_string(), url, headers, oauth) } None => ( "some-remote-server".to_string(), "https://example.com/mcp".to_string(), r#"// "Authorization": "Bearer "#.to_string(), + None, ), }; + let oauth = oauth.map_or_else( + || { + r#" + /// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain. + // "oauth": { + // "client_id": "your-client-id", + // },"# + .to_string() + }, + + |oauth| { + let mut lines = vec![ + String::from("\n \"oauth\": {"), + + format!(" \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()), + ]; + if let Some(client_secret) = oauth.client_secret { + lines.push(format!( + " \"client_secret\": {}", + serde_json::to_string(&client_secret).unwrap() + )); + } else { + lines.push(String::from( + " /// Optional client secret for confidential clients\n // \"client_secret\": \"your-client-secret\"", + )); + } + lines.push(String::from(" },")); + + lines.join("\n") + }, + ); + format!( r#"{{ /// Configure an MCP server that you connect to over HTTP @@ -290,7 +333,7 @@ fn context_server_http_input( /// The name of your remote MCP server "{name}": {{ /// The URL of the remote MCP server - "url": "{url}", + "url": "{url}",{oauth} "headers": {{ /// Any headers to send along {headers} @@ -300,12 +343,21 @@ fn context_server_http_input( ) } -fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap)> { +fn parse_http_input( + text: &str, +) -> Result<( + ContextServerId, + String, + HashMap, + Option, +)> { #[derive(Deserialize)] struct Temp { url: String, #[serde(default)] headers: HashMap, + #[serde(default)] + oauth: Option, } let value: HashMap = serde_json_lenient::from_str(text)?; if value.len() != 1 { @@ -314,7 +366,12 @@ fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap, + }, + Authenticating { + server_id: ContextServerId, + }, Error(SharedString), } @@ -361,10 +426,47 @@ pub struct ConfigureContextServerModal { state: State, original_server_id: Option, scroll_handle: ScrollHandle, + secret_editor: Entity, _auth_subscription: Option, } impl ConfigureContextServerModal { + fn initial_state( + context_server_store: &Entity, + target: &ConfigurationTarget, + cx: &App, + ) -> State { + let Some(server_id) = (match target { + ConfigurationTarget::Existing { id, .. } + | ConfigurationTarget::ExistingHttp { id, .. } + | ConfigurationTarget::Extension { id, .. } => Some(id), + ConfigurationTarget::New => None, + }) else { + return State::Idle; + }; + + match context_server_store.read(cx).status_for_server(server_id) { + Some(ContextServerStatus::AuthRequired) => State::AuthRequired { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::ClientSecretRequired { error }) => { + State::ClientSecretRequired { + server_id: server_id.clone(), + error: error.map(SharedString::from), + } + } + Some(ContextServerStatus::Authenticating) => State::Authenticating { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::Error(error)) => State::Error(error.into()), + + Some(ContextServerStatus::Starting) + | Some(ContextServerStatus::Running) + | Some(ContextServerStatus::Stopped) + | None => State::Idle, + } + } + pub fn register( workspace: &mut Workspace, language_registry: Arc, @@ -426,12 +528,14 @@ impl ConfigureContextServerModal { url, headers, timeout: _, - .. + oauth, } => Some(ConfigurationTarget::ExistingHttp { id: server_id, url, headers, + oauth, }), + ContextServerSettings::Extension { .. } => { match workspace .update(cx, |workspace, cx| { @@ -468,9 +572,10 @@ impl ConfigureContextServerModal { let workspace_handle = cx.weak_entity(); let context_server_store = workspace.project().read(cx).context_server_store(); workspace.toggle_modal(window, cx, |window, cx| Self { - context_server_store, + context_server_store: context_server_store.clone(), workspace: workspace_handle, - state: State::Idle, + state: Self::initial_state(&context_server_store, &target, cx), + original_server_id: match &target { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), @@ -485,6 +590,16 @@ impl ConfigureContextServerModal { cx, ), scroll_handle: ScrollHandle::new(), + secret_editor: cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "Enter client secret (leave empty for public clients)", + window, + cx, + ); + editor.set_masked(true, cx); + editor + }), _auth_subscription: None, }) }) @@ -497,13 +612,12 @@ impl ConfigureContextServerModal { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { - if matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ) { + if matches!(self.state, State::Waiting | State::Authenticating { .. }) { return; } + self._auth_subscription = None; + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; @@ -519,7 +633,7 @@ impl ConfigureContextServerModal { self.state = State::Waiting; - let existing_server = self.context_server_store.read(cx).get_running_server(&id); + let existing_server = self.context_server_store.read(cx).get_server(&id); if existing_server.is_some() { self.context_server_store.update(cx, |store, cx| { store.stop_server(&id, cx).log_err(); @@ -542,6 +656,13 @@ impl ConfigureContextServerModal { this.state = State::AuthRequired { server_id: id }; cx.notify(); } + Ok(ContextServerStatus::ClientSecretRequired { error }) => { + this.state = State::ClientSecretRequired { + server_id: id, + error: error.map(SharedString::from), + }; + cx.notify(); + } Err(err) => { this.set_error(err, cx); } @@ -581,13 +702,33 @@ impl ConfigureContextServerModal { cx.emit(DismissEvent); } + fn cancel_authentication(&mut self, server_id: &ContextServerId, cx: &mut Context) { + self._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.context_server_store.update(cx, |store, cx| { store.authenticate_server(&server_id, cx).log_err(); }); + self.await_auth_outcome(server_id, cx); + } + fn submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context) { + 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.state = State::Authenticating { - _server_id: server_id.clone(), + server_id: server_id.clone(), }; self._auth_subscription = Some(cx.subscribe( @@ -610,6 +751,14 @@ impl ConfigureContextServerModal { }; cx.notify(); } + ContextServerStatus::ClientSecretRequired { error } => { + this._auth_subscription = None; + this.state = State::ClientSecretRequired { + server_id: event.server_id.clone(), + error: error.clone().map(SharedString::from), + }; + cx.notify(); + } ContextServerStatus::Error(error) => { this._auth_subscription = None; this.set_error(error.clone(), cx); @@ -814,10 +963,7 @@ impl ConfigureContextServerModal { fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); - let is_busy = matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ); + let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. }); ModalFooter::new() .start_slot::") + .and_then(|html| html.strip_suffix("")) + { + let code_start = range.start + "".len(); + self.push_markdown_code_span( + &mut builder, + code, + code_start..code_start + code.len(), + cx, + ); + continue; + } if html.starts_with("") { builder.push_text_style(self.style.inline_code.clone()); continue; @@ -2420,6 +2534,29 @@ fn apply_heading_style( heading } +fn render_wrap_code_block_button( + id: usize, + is_wrapped: bool, + markdown: Entity, +) -> impl IntoElement { + let (icon, tooltip) = if is_wrapped { + (IconName::TextUnwrap, "Unwrap Content") + } else { + (IconName::TextWrap, "Wrap Content") + }; + + IconButton::new(("wrap-code-block", id), icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_event, _window, cx| { + markdown.update(cx, |markdown, cx| { + markdown.toggle_code_block_wrap(id); + cx.notify(); + }); + }) +} + fn render_copy_code_block_button( id: usize, code: String, @@ -2580,6 +2717,7 @@ struct MarkdownElementBuilder { base_text_style: TextStyle, text_style_stack: Vec, code_block_stack: Vec>>, + link_depth: usize, list_stack: Vec, table: TableState, syntax_theme: Arc, @@ -2618,6 +2756,7 @@ impl MarkdownElementBuilder { base_text_style, text_style_stack: Vec::new(), code_block_stack: Vec::new(), + link_depth: 0, list_stack: Vec::new(), table: TableState::default(), syntax_theme, @@ -2789,33 +2928,36 @@ impl MarkdownElementBuilder { self.pending_line.text.push_str(text); self.current_source_index = source_range.end; + // Compute the base text style once + let text_style = self.text_style(); + if let Some(Some(language)) = self.code_block_stack.last() { let mut offset = 0; for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) { if range.start > offset { self.pending_line .runs - .push(self.text_style().to_run(range.start - offset)); + .push(text_style.to_run(range.start - offset)); } - let mut run_style = self.text_style(); + let run_len = range.len(); if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { - run_style = run_style.highlight(highlight); + self.pending_line + .runs + .push(text_style.clone().highlight(highlight).to_run(run_len)); + } else { + self.pending_line.runs.push(text_style.to_run(run_len)); } - - self.pending_line.runs.push(run_style.to_run(range.len())); offset = range.end; } if offset < text.len() { self.pending_line .runs - .push(self.text_style().to_run(text.len() - offset)); + .push(text_style.to_run(text.len() - offset)); } } else { - self.pending_line - .runs - .push(self.text_style().to_run(text.len())); + self.pending_line.runs.push(text_style.to_run(text.len())); } } @@ -3394,6 +3536,40 @@ mod tests { render_markdown_with_language_registry(markdown, None, cx) } + fn render_markdown_with_code_span_link( + markdown: &str, + callback: impl Fn(&str, &App) -> Option + 'static, + cx: &mut TestAppContext, + ) -> RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()) + .on_code_span_link(callback) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, + border: false, + }) + }, + ); + rendered.text + } + fn render_markdown_with_language_registry( markdown: &str, language_registry: Option>, @@ -3436,6 +3612,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) @@ -3931,6 +4108,30 @@ mod tests { ); } + #[test] + fn test_escape_non_ascii() { + // Cyrillic characters should not have backslashes added before them, + // but ASCII punctuation should still be escaped. + assert_eq!(Markdown::escape("Привет, мир"), r"Привет\, мир"); + // Test with markdown special characters mixed in + assert_eq!(Markdown::escape("Привет, *мир*"), r"Привет\, \*мир\*"); + // Test with the exact example from the issue (single quotes are also ASCII punctuation) + assert_eq!( + Markdown::escape("Отсутствует пробел справа от ','"), + r"Отсутствует пробел справа от \'\,\'" + ); + // Test more non-ASCII scripts + assert_eq!( + Markdown::escape("こんにちは *world*"), + r"こんにちは \*world\*" + ); + assert_eq!(Markdown::escape("العربيّة [link]"), r"العربيّة \[link\]"); + assert_eq!(Markdown::escape("Ελληνικά _text_"), r"Ελληνικά \_text\_"); + assert_eq!(Markdown::escape("עברית `code`"), r"עברית \`code\`"); + // Non-ASCII followed by ASCII punctuation + assert_eq!(Markdown::escape("Test: тест"), r"Test\: тест"); + } + fn has_code_block(markdown: &str) -> bool { let parsed_data = parse_markdown_with_options(markdown, false, false); parsed_data @@ -3959,12 +4160,12 @@ mod tests { ]; for input in cases { let mut escaper = MarkdownEscaper::new(); - let precomputed: usize = input.bytes().map(|b| escaper.next(b).output_len()).sum(); + let precomputed: usize = input.chars().map(|c| escaper.next(c).output_len(c)).sum(); let mut escaper = MarkdownEscaper::new(); let mut output = String::new(); for c in input.chars() { - escaper.next(c as u8).write_to(c, &mut output); + escaper.next(c).write_to(c, &mut output); } assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input); @@ -4004,6 +4205,50 @@ mod tests { assert!(rendered.link_for_source_index(5).is_none()); } + #[gpui::test] + fn test_code_span_link_detected_for_source_index(cx: &mut TestAppContext) { + let source = "see `foo.rs` for details"; + let rendered = render_markdown_with_code_span_link( + source, + |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()), + cx, + ); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "file:///tmp/foo.rs"); + + let code_index = source.find("foo.rs").unwrap(); + let link = rendered.link_for_source_index(code_index); + assert!(link.is_some()); + assert_eq!(link.unwrap().destination_url, "file:///tmp/foo.rs"); + + assert!( + rendered + .link_for_source_index(source.find("see").unwrap()) + .is_none() + ); + } + + #[gpui::test] + fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) { + let rendered = render_markdown("see `foo.rs` for details", cx); + + assert!(rendered.links.is_empty()); + } + + #[gpui::test] + fn test_code_span_link_ignores_code_inside_markdown_link(cx: &mut TestAppContext) { + let source = "see [`foo.rs`](https://example.com) for details"; + let rendered = render_markdown_with_code_span_link( + source, + |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()), + cx, + ); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "https://example.com"); + } + #[gpui::test] fn test_context_menu_link_initial_state(cx: &mut TestAppContext) { struct TestWindow; diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 250edeea3a5..019cb6d78ad 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -585,7 +585,7 @@ mod tests { }; use crate::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions, - MarkdownStyle, + MarkdownStyle, WrapButtonVisibility, }; use collections::HashMap; use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size}; @@ -644,6 +644,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) @@ -924,6 +925,7 @@ mod tests { MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }) }, diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 4413e6b0f12..38ce126badb 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -623,6 +623,7 @@ impl MarkdownPreviewView { let mut markdown_element = MarkdownElement::new(self.markdown.clone(), markdown_style) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::VisibleOnHover, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .scroll_handle(self.scroll_handle.clone()) diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index dfa40ad666e..25f7b2997e5 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true async-compression.workspace = true async-tar.workspace = true async-trait.workspace = true +chrono.workspace = true futures.workspace = true http_client.workspace = true log.workspace = true diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 9d4bfe9cffb..7ce29532644 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; +use chrono::{DateTime, Utc}; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared}; use http_client::{Host, HttpClient, Url}; use log::Level; @@ -253,9 +254,8 @@ impl NodeRuntime { pub async fn npm_package_latest_version(&self, name: &str) -> Result { let http = self.0.lock().await.http.clone(); - let output = self - .instance() - .await + let instance = self.instance().await; + let output = instance .run_npm_subcommand( None, http.proxy(), @@ -273,11 +273,18 @@ impl NodeRuntime { ) .await?; - let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?; - info.dist_tags - .latest - .or_else(|| info.versions.pop()) - .with_context(|| format!("no version found for npm package {name}")) + let info: NpmInfo = serde_json::from_slice(&output.stdout)?; + let before = npm_config_before(instance.as_ref(), http.proxy()) + .await + .context("getting npm before config") + .log_err() + .flatten(); + let latest_dist_tag = info.dist_tags.latest.clone(); + let selected_version = select_npm_package_version(name, info, before.as_deref())?; + log::debug!( + "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" + ); + Ok(selected_version) } pub async fn npm_install_packages( @@ -289,6 +296,11 @@ impl NodeRuntime { return Ok(()); } + log::debug!( + "installing npm packages directory={} packages={packages:?}", + directory.display() + ); + let packages: Vec<_> = packages .iter() .map(|(name, version)| format!("{name}@{version}")) @@ -314,6 +326,23 @@ impl NodeRuntime { Ok(()) } + pub async fn npm_install_latest_packages( + &self, + directory: &Path, + package_names: &[&str], + ) -> Result<()> { + // Let npm apply user config such as `before` and `min-release-age` during resolution. + log::debug!( + "installing latest npm packages directory={} packages={package_names:?}", + directory.display() + ); + let packages = package_names + .iter() + .map(|package_name| (*package_name, "latest")) + .collect::>(); + self.npm_install_packages(directory, &packages).await + } + pub async fn should_install_npm_package( &self, package_name: &str, @@ -325,6 +354,10 @@ impl NodeRuntime { // or in the instances where we fail to parse package.json data, // we attempt to install the package. if fs::metadata(local_executable_path).await.is_err() { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-executable executable={}", + local_executable_path.display() + ); return true; } @@ -334,13 +367,33 @@ impl NodeRuntime { .log_err() .flatten() else { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}", + local_package_directory.display() + ); return true; }; - match version_strategy { - VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version, - VersionStrategy::Latest(latest_version) => &installed_version < latest_version, - } + let version_strategy_label = match &version_strategy { + VersionStrategy::Pin(version) => format!("pin:{version}"), + VersionStrategy::Latest(version) => format!("latest:{version}"), + }; + let should_install = + should_install_npm_package_version(&installed_version, version_strategy); + log::debug!( + "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}" + ); + should_install + } +} + +fn should_install_npm_package_version( + installed_version: &Version, + version_strategy: VersionStrategy<'_>, +) -> bool { + match version_strategy { + VersionStrategy::Pin(pinned_version) => installed_version != pinned_version, + VersionStrategy::Latest(latest_version) => installed_version < latest_version, } } @@ -355,6 +408,8 @@ pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, versions: Vec, + #[serde(default)] + time: HashMap, } #[derive(Debug, Deserialize, Default)] @@ -362,6 +417,95 @@ pub struct NpmInfoDistTags { latest: Option, } +#[derive(Debug, Deserialize)] +struct NpmConfig { + #[serde(default)] + before: Option, +} + +async fn npm_config_before( + node_runtime: &dyn NodeRuntimeTrait, + proxy: Option<&Url>, +) -> Result> { + // `npm config get before` renders Date values for display. The JSON config output keeps the + // computed cutoff in the same ISO format used by `npm info --json` release times. + let output = node_runtime + .run_npm_subcommand(None, proxy, "config", &["list", "--json"]) + .await?; + let config: NpmConfig = serde_json::from_slice(&output.stdout)?; + Ok(config + .before + .filter(|before| !before.trim().is_empty() && before != "null")) +} + +fn select_npm_package_version( + package_name: &str, + mut info: NpmInfo, + before: Option<&str>, +) -> Result { + if let Some(before) = before + && !info.time.is_empty() + { + let before_timestamp = DateTime::parse_from_rfc3339(before) + .with_context(|| format!("parsing npm before config timestamp {before:?}"))? + .with_timezone(&Utc); + let latest_version = info.dist_tags.latest.as_ref(); + + if let Some(version) = latest_version + && npm_version_was_published_before(version, &info.time, &before_timestamp)? + { + return Ok(version.clone()); + } + + for version in info.versions.iter().rev() { + if is_allowed_npm_version_before( + version, + latest_version, + &info.time, + &before_timestamp, + )? { + return Ok(version.clone()); + } + } + + bail!("no version found for npm package {package_name} before {before}"); + } + + info.dist_tags + .latest + .or_else(|| info.versions.pop()) + .with_context(|| format!("no version found for npm package {package_name}")) +} + +fn is_allowed_npm_version_before( + version: &Version, + latest_version: Option<&Version>, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + if !version.pre.is_empty() + || latest_version.is_some_and(|latest_version| version > latest_version) + { + return Ok(false); + } + + npm_version_was_published_before(version, published_at_by_version, before) +} + +fn npm_version_was_published_before( + version: &Version, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + let Some(published_at) = published_at_by_version.get(&version.to_string()) else { + return Ok(false); + }; + let published_at = DateTime::parse_from_rfc3339(published_at) + .with_context(|| format!("parsing npm release timestamp for version {version}"))? + .with_timezone(&Utc); + Ok(&published_at <= before) +} + #[async_trait::async_trait] trait NodeRuntimeTrait: Send + Sync { fn boxed_clone(&self) -> Box; @@ -936,9 +1080,14 @@ fn npm_command_env(node_binary: Option<&Path>) -> HashMap { mod tests { use std::path::Path; + use anyhow::{Result, bail}; use http_client::Url; + use semver::Version; - use super::{build_npm_command_args, proxy_argument}; + use super::{ + NpmInfo, VersionStrategy, build_npm_command_args, proxy_argument, + select_npm_package_version, should_install_npm_package_version, + }; // Map localhost to 127.0.0.1 // NodeRuntime without environment information can not parse `localhost` correctly. @@ -1021,4 +1170,174 @@ mod tests { ] ); } + + #[test] + fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> { + let target_version = Version::parse("2.0.0")?; + + assert!(!should_install_npm_package_version( + &Version::parse("2.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(should_install_npm_package_version( + &Version::parse("1.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(!should_install_npm_package_version( + &Version::parse("3.0.0")?, + VersionStrategy::Latest(&target_version) + )); + + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, None)?, + Version::parse("3.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0-beta.1" }, + "versions": ["1.0.0", "2.0.0-beta.1"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0-beta.1")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z", + "3.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + let Err(error) = + select_npm_package_version("test-package", info, Some("2023-12-01T00:00:00.000Z")) + else { + bail!("expected cutoff to reject all package versions"); + }; + assert_eq!( + error.to_string(), + "no version found for npm package test-package before 2023-12-01T00:00:00.000Z" + ); + Ok(()) + } } diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 607dfb13e4f..8cfcb115cd8 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -709,26 +709,36 @@ impl PickerDelegate for OpenPathDelegate { ) -> Option { let settings = FileFinderSettings::get_global(cx); let candidate = self.get_entry(ix)?; - let mut match_positions = match &self.directory_state { - DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(), + let string_match = match &self.directory_state { + DirectoryState::List { .. } => self.string_matches.get(ix), DirectoryState::Create { user_input, .. } => { if let Some(user_input) = user_input { if !user_input.exists || !user_input.is_dir { if ix == 0 { - Vec::new() + None } else { - self.string_matches.get(ix - 1)?.positions.clone() + self.string_matches.get(ix - 1) } } else { - self.string_matches.get(ix)?.positions.clone() + self.string_matches.get(ix) } } else { - self.string_matches.get(ix)?.positions.clone() + self.string_matches.get(ix) } } - DirectoryState::None { .. } => Vec::new(), + DirectoryState::None { .. } => None, }; + // Directory entries and string matches can briefly go out of sync during + // async updates. When that happens, render the row without highlights. + let mut match_positions = string_match + .filter(|string_match| { + string_match.candidate_id == candidate.path.id + && string_match.string == candidate.path.string + }) + .map(|string_match| string_match.positions.clone()) + .unwrap_or_default(); + let is_current_dir_candidate = candidate.path.string == self.current_dir(); let file_icon = maybe!({ diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index 7d359dbf176..d89b657860e 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; use picker::{Picker, PickerDelegate}; use project::Project; @@ -8,7 +9,7 @@ use ui::rems; use util::path; use workspace::{AppState, MultiWorkspace}; -use crate::OpenPathDelegate; +use crate::{CandidateInfo, DirectoryState, OpenPathDelegate}; #[gpui::test] async fn test_open_path_prompt(cx: &mut TestAppContext) { @@ -372,6 +373,39 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]); } +#[gpui::test] +async fn test_open_path_prompt_panics_with_stale_highlight_positions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, cx) = build_open_path_prompt(project, false, false, cx); + + picker.update_in(cx, |picker, window, cx| { + picker.delegate.prompt_root = "/".to_string(); + picker.delegate.directory_state = DirectoryState::List { + parent_path: picker.delegate.prompt_root.clone(), + entries: vec![CandidateInfo { + path: StringMatchCandidate::new(0, "éclair"), + is_dir: false, + }], + error: None, + }; + picker.delegate.string_matches = vec![StringMatch { + candidate_id: 0, + score: 0.0, + positions: vec![1], + string: "ab".to_string(), + }]; + + picker.delegate.render_match(0, false, window, cx); + }); +} + #[gpui::test] async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 0e235bf7166..c4919f1759c 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -141,8 +141,6 @@ pub enum Model { MimoV2_5, #[serde(rename = "big-pickle")] BigPickle, - #[serde(rename = "ring-2.6-1t-free")] - Ring2_6_1TFree, #[serde(rename = "nemotron-3-super-free")] Nemotron3SuperFree, #[serde(rename = "qwen3.5-plus")] @@ -204,10 +202,9 @@ impl Model { | Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go], // Free models - Self::MiniMaxM2_5Free - | Self::Nemotron3SuperFree - | Self::BigPickle - | Self::Ring2_6_1TFree => &[OpenCodeSubscription::Free], + Self::MiniMaxM2_5Free | Self::Nemotron3SuperFree | Self::BigPickle => { + &[OpenCodeSubscription::Free] + } // Custom models get their subscription from settings, not from here Self::Custom { .. } => &[], @@ -263,7 +260,6 @@ impl Model { Self::Qwen3_5Plus => "qwen3.5-plus", Self::Qwen3_6Plus => "qwen3.6-plus", Self::BigPickle => "big-pickle", - Self::Ring2_6_1TFree => "ring-2.6-1t-free", Self::Nemotron3SuperFree => "nemotron-3-super-free", Self::Custom { name, .. } => name, @@ -316,7 +312,6 @@ impl Model { Self::Qwen3_5Plus => "Qwen3.5 Plus", Self::Qwen3_6Plus => "Qwen3.6 Plus", Self::BigPickle => "Big Pickle", - Self::Ring2_6_1TFree => "Ring 2.6 1T Free", Self::Nemotron3SuperFree => "Nemotron 3 Super Free", Self::Custom { @@ -378,7 +373,6 @@ impl Model { | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat, Self::Custom { protocol, .. } => *protocol, @@ -395,8 +389,8 @@ impl Model { | Self::MimoV2_5Pro | Self::Glm5 | Self::Glm5_1 - | Self::BigPickle - | Self::Ring2_6_1TFree => true, + | Self::Nemotron3SuperFree + | Self::BigPickle => true, Self::Custom { interleaved_reasoning, @@ -407,7 +401,7 @@ impl Model { } } - pub fn max_token_count(&self) -> u64 { + pub fn max_token_count(&self, subscription: OpenCodeSubscription) -> u64 { match self { // Anthropic models Self::ClaudeOpus4_7 => 1_000_000, @@ -436,13 +430,18 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 204_800, - Self::Glm5 | Self::Glm5_1 => 202_725, + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + 202_752 + } else { + 204_800 + } + } Self::KimiK2_6 | Self::KimiK2_5 => 262_144, Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144, Self::BigPickle => 200_000, - Self::Ring2_6_1TFree => 262_000, Self::Nemotron3SuperFree => 204_800, Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => 1_000_000, @@ -450,7 +449,7 @@ impl Model { } } - pub fn max_output_tokens(&self) -> Option { + pub fn max_output_tokens(&self, subscription: OpenCodeSubscription) -> Option { match self { // Anthropic models Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), @@ -485,10 +484,22 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(131_072), - Self::Glm5 | Self::Glm5_1 => Some(32_768), + Self::MiniMaxM2_5Free => Some(131_072), + Self::MiniMaxM2_5 => { + if subscription == OpenCodeSubscription::Go { + Some(65_536) + } else { + Some(131_072) + } + } + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + Some(32_768) + } else { + Some(131_072) + } + } Self::BigPickle => Some(128_000), - Self::Ring2_6_1TFree => Some(66_000), Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536), Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000), @@ -525,7 +536,6 @@ impl Model { | Self::Gpt5_4Mini | Self::Gpt5_4Nano | Self::Gpt5_3Codex - | Self::Gpt5_3Spark | Self::Gpt5_2 | Self::Gpt5_2Codex | Self::Gpt5_1 @@ -536,6 +546,9 @@ impl Model { | Self::Gpt5Codex | Self::Gpt5Nano => true, + // OpenAI models without image support + Self::Gpt5_3Spark => false, + // Google models support images Self::Gemini3_1Pro | Self::Gemini3Flash => true, @@ -556,7 +569,6 @@ impl Model { | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => false, Self::Custom { protocol, .. } => matches!( @@ -571,7 +583,7 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::Ring2_6_1TFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index f43f045c5e0..81779906ed6 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1,7 +1,7 @@ use std::{ any::Any, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, LazyLock}, time::Duration, }; @@ -17,14 +17,11 @@ use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; use percent_encoding::percent_decode_str; use remote::RemoteClient; -use rpc::{ - AnyProtoClient, TypedEnvelope, - proto::{self, ExternalExtensionAgent}, -}; +use rpc::{AnyProtoClient, TypedEnvelope, proto}; use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; -use settings::{RegisterSetting, SettingsStore}; +use settings::{RegisterSetting, SettingsStore, update_settings_file}; use sha2::{Digest, Sha256}; use url::Url; use util::{ResultExt as _, debug_panic}; @@ -114,7 +111,6 @@ impl std::borrow::Borrow for AgentId { pub enum ExternalAgentSource { #[default] Custom, - Extension, Registry, } @@ -140,16 +136,6 @@ pub trait ExternalAgentServer { fn as_any_mut(&mut self) -> &mut dyn Any; } -struct ExtensionAgentEntry { - agent_name: Arc, - extension_id: String, - targets: HashMap, - env: HashMap, - icon_path: Option, - display_name: Option, - version: Option, -} - enum AgentServerStoreState { Local { node_runtime: NodeRuntime, @@ -158,7 +144,6 @@ enum AgentServerStoreState { downstream_client: Option<(u64, AnyProtoClient)>, settings: Option, http_client: Arc, - extension_agents: Vec, _subscriptions: Vec, }, Remote { @@ -201,123 +186,54 @@ pub struct AgentServersUpdated; impl EventEmitter for AgentServerStore {} +static EXTENSION_TO_REGISTRY_IDS: LazyLock> = + LazyLock::new(|| { + HashMap::from_iter([ + ("opencode", "opencode"), + ("mistral-vibe", "mistral-vibe"), + ("auggie", "auggie"), + ("stakpak", "stakpak"), + ("codebuddy", "codebuddy-code"), + ("autohand-acp", "autohand"), + ("corust-agent", "corust-agent"), + ("factory-droid", "factory-droid"), + // Unmaintained + // ("qqcode", ""), + ]) + }); + impl AgentServerStore { - /// Synchronizes extension-provided agent servers with the store. - pub fn sync_extension_agents<'a, I>( + pub fn migrate_agent_server_from_extensions( &mut self, - manifests: I, - extensions_dir: PathBuf, + id: Arc, + fs: Arc, cx: &mut Context, - ) where - I: IntoIterator, - { - // Collect manifests first so we can iterate twice - let manifests: Vec<_> = manifests.into_iter().collect(); + ) { + let Some(registry_id) = EXTENSION_TO_REGISTRY_IDS.get(id.as_ref()) else { + return; + }; - // Remove all extension-provided agents - // (They will be re-added below if they're in the currently installed extensions) - self.external_agents - .retain(|_, entry| entry.source != ExternalAgentSource::Extension); - - // Insert agent servers from extension manifests - match &mut self.state { - AgentServerStoreState::Local { - extension_agents, .. - } => { - extension_agents.clear(); - for (ext_id, manifest) in manifests { - for (agent_name, agent_entry) in &manifest.agent_servers { - let display_name = SharedString::from(agent_entry.name.clone()); - let icon_path = agent_entry.icon.as_ref().and_then(|icon| { - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - }); - - extension_agents.push(ExtensionAgentEntry { - agent_name: agent_name.clone(), - extension_id: ext_id.to_owned(), - targets: agent_entry.targets.clone(), - env: agent_entry.env.clone(), - icon_path, - display_name: Some(display_name), - version: Some(SharedString::from(manifest.version.clone())), - }); - } - } - self.reregister_agents(cx); + update_settings_file(fs, cx, move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + // Take the old settings + let settings = agent_servers.remove(id.as_ref()); + // If they had both installed, just remove the extension settings, leave theirregistry settings alone + if agent_servers.contains_key(*registry_id) { + return; } - AgentServerStoreState::Remote { - project_id, - upstream_client, - worktree_store, - } => { - let mut agents = vec![]; - for (ext_id, manifest) in manifests { - for (agent_name, agent_entry) in &manifest.agent_servers { - let display_name = SharedString::from(agent_entry.name.clone()); - let icon_path = agent_entry.icon.as_ref().and_then(|icon| { - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - }); - let icon_shared = icon_path - .as_ref() - .map(|path| SharedString::from(path.clone())); - let icon = icon_path; - let agent_server_name = AgentId(agent_name.clone().into()); - self.external_agents - .entry(agent_server_name.clone()) - .and_modify(|entry| { - entry.icon = icon_shared.clone(); - entry.display_name = Some(display_name.clone()); - entry.source = ExternalAgentSource::Extension; - }) - .or_insert_with(|| { - ExternalAgentEntry::new( - Box::new(RemoteExternalAgentServer { - project_id: *project_id, - upstream_client: upstream_client.clone(), - worktree_store: worktree_store.clone(), - name: agent_server_name.clone(), - new_version_available_tx: None, - }) - as Box, - ExternalAgentSource::Extension, - icon_shared.clone(), - Some(display_name.clone()), - ) - }); - - agents.push(ExternalExtensionAgent { - name: agent_name.to_string(), - icon_path: icon, - extension_id: ext_id.to_string(), - targets: agent_entry - .targets - .iter() - .map(|(k, v)| (k.clone(), v.to_proto())) - .collect(), - env: agent_entry - .env - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - version: Some(manifest.version.to_string()), - }); - } - } - upstream_client - .read(cx) - .proto_client() - .send(proto::ExternalExtensionAgentsUpdated { - project_id: *project_id, - agents, - }) - .log_err(); - } - AgentServerStoreState::Collab => { - // Do nothing - } - } - - cx.emit(AgentServersUpdated); + // Insert the old settings, or write new ones so it is "installed" via the registry + agent_servers.insert( + registry_id.to_string(), + settings.unwrap_or_else(|| settings::CustomAgentServerSettings::Registry { + default_mode: None, + default_model: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }), + ); + }); } pub fn agent_icon(&self, id: &AgentId) -> Option { @@ -331,46 +247,6 @@ impl AgentServerStore { } } -/// Safely resolves an extension icon path, ensuring it stays within the extension directory. -/// Returns `None` if the path would escape the extension directory (path traversal attack). -pub fn resolve_extension_icon_path( - extensions_dir: &Path, - extension_id: &str, - icon_relative_path: &str, -) -> Option { - let extension_root = extensions_dir.join(extension_id); - let icon_path = extension_root.join(icon_relative_path); - - // Canonicalize both paths to resolve symlinks and normalize the paths. - // For the extension root, we need to handle the case where it might be a symlink - // (common for dev extensions). - let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root); - let canonical_icon_path = match icon_path.canonicalize() { - Ok(path) => path, - Err(err) => { - log::warn!( - "Failed to canonicalize icon path for extension '{}': {} (path: {})", - extension_id, - err, - icon_relative_path - ); - return None; - } - }; - - // Verify the resolved icon path is within the extension directory - if canonical_icon_path.starts_with(&canonical_extension_root) { - Some(canonical_icon_path.to_string_lossy().to_string()) - } else { - log::warn!( - "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security", - icon_relative_path, - extension_id - ); - None - } -} - impl AgentServerStore { pub fn agent_display_name(&self, name: &AgentId) -> Option { self.external_agents @@ -384,7 +260,6 @@ impl AgentServerStore { } pub fn init_headless(session: &AnyProtoClient) { - session.add_entity_message_handler(Self::handle_external_extension_agents_updated); session.add_entity_request_handler(Self::handle_get_agent_server_command); } @@ -419,7 +294,6 @@ impl AgentServerStore { downstream_client, settings: old_settings, http_client, - extension_agents, .. } = &mut self.state else { @@ -470,47 +344,6 @@ impl AgentServerStore { } } - // Insert extension agents before custom/registry so registry entries override extensions. - for entry in extension_agents.iter() { - let name = AgentId(entry.agent_name.clone().into()); - let mut env = entry.env.clone(); - if let Some(settings_env) = - new_settings - .get(entry.agent_name.as_ref()) - .and_then(|settings| match settings { - CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()), - _ => None, - }) - { - env.extend(settings_env); - } - let icon = entry - .icon_path - .as_ref() - .map(|path| SharedString::from(path.clone())); - - self.external_agents.insert( - name.clone(), - ExternalAgentEntry::new( - Box::new(LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client: http_client.clone(), - node_runtime: node_runtime.clone(), - project_environment: project_environment.clone(), - extension_id: Arc::from(&*entry.extension_id), - targets: entry.targets.clone(), - env, - agent_id: entry.agent_name.clone(), - version: entry.version.clone(), - new_version_available_tx: None, - }) as Box, - ExternalAgentSource::Extension, - icon, - entry.display_name.clone(), - ), - ); - } - for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { @@ -593,7 +426,6 @@ impl AgentServerStore { } } } - CustomAgentServerSettings::Extension { .. } => {} } } @@ -662,12 +494,10 @@ impl AgentServerStore { http_client, downstream_client: None, settings: None, - extension_agents: vec![], _subscriptions: subscriptions, }, external_agents: HashMap::default(), }; - if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); this } @@ -900,52 +730,6 @@ impl AgentServerStore { }) } - async fn handle_external_extension_agents_updated( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, cx| { - let AgentServerStoreState::Local { - extension_agents, .. - } = &mut this.state - else { - panic!( - "handle_external_extension_agents_updated \ - should not be called for a non-remote project" - ); - }; - - extension_agents.clear(); - for ExternalExtensionAgent { - name, - icon_path, - extension_id, - targets, - env, - version, - } in envelope.payload.agents - { - extension_agents.push(ExtensionAgentEntry { - agent_name: Arc::from(&*name), - extension_id, - targets: targets - .into_iter() - .map(|(k, v)| (k, extension::TargetConfig::from_proto(v))) - .collect(), - env: env.into_iter().collect(), - icon_path, - display_name: None, - version: version.map(SharedString::from), - }); - } - - this.reregister_agents(cx); - cx.emit(AgentServersUpdated); - Ok(()) - }) - } - async fn handle_new_version_available( this: Entity, envelope: TypedEnvelope, @@ -961,16 +745,6 @@ impl AgentServerStore { }); Ok(()) } - - pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option> { - self.external_agents.get(name).and_then(|entry| { - entry - .server - .as_any() - .downcast_ref::() - .map(|ext_agent| ext_agent.extension_id.clone()) - }) - } } struct RemoteExternalAgentServer { @@ -1196,213 +970,6 @@ async fn remove_stale_versioned_archive_cache_dirs( Ok(()) } -pub struct LocalExtensionArchiveAgent { - pub fs: Arc, - pub http_client: Arc, - pub node_runtime: NodeRuntime, - pub project_environment: Entity, - pub extension_id: Arc, - pub agent_id: Arc, - pub targets: HashMap, - pub env: HashMap, - pub version: Option, - pub new_version_available_tx: Option>>, -} - -impl ExternalAgentServer for LocalExtensionArchiveAgent { - fn version(&self) -> Option<&SharedString> { - self.version.as_ref() - } - - fn take_new_version_available_tx(&mut self) -> Option>> { - self.new_version_available_tx.take() - } - - fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { - self.new_version_available_tx = Some(tx); - } - - fn get_command( - &self, - extra_args: Vec, - extra_env: HashMap, - cx: &mut AsyncApp, - ) -> Task> { - let fs = self.fs.clone(); - let http_client = self.http_client.clone(); - let node_runtime = self.node_runtime.clone(); - let project_environment = self.project_environment.downgrade(); - let extension_id = self.extension_id.clone(); - let agent_id = self.agent_id.clone(); - let targets = self.targets.clone(); - let base_env = self.env.clone(); - let version = self.version.clone(); - - cx.spawn(async move |cx| { - // Get project environment - let mut env = project_environment - .update(cx, |project_environment, cx| { - project_environment.default_environment(cx) - })? - .await - .unwrap_or_default(); - - // Merge manifest env and extra env - env.extend(base_env); - env.extend(extra_env); - - let cache_key = format!("{}/{}", extension_id, agent_id); - let dir = paths::external_agents_dir().join(&cache_key); - fs.create_dir(&dir).await?; - - // Determine platform key - let os = if cfg!(target_os = "macos") { - "darwin" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "windows") { - "windows" - } else { - anyhow::bail!("unsupported OS"); - }; - - let arch = if cfg!(target_arch = "aarch64") { - "aarch64" - } else if cfg!(target_arch = "x86_64") { - "x86_64" - } else { - anyhow::bail!("unsupported architecture"); - }; - - let platform_key = format!("{}-{}", os, arch); - let target_config = targets.get(&platform_key).with_context(|| { - format!( - "no target specified for platform '{}'. Available platforms: {}", - platform_key, - targets - .keys() - .map(|k| k.as_str()) - .collect::>() - .join(", ") - ) - })?; - - let archive_url = &target_config.archive; - let version_dir = versioned_archive_cache_dir( - &dir, - version.as_ref().map(|version| version.as_ref()), - archive_url, - ); - - if !fs.is_dir(&version_dir).await { - // Determine SHA256 for verification - let sha256 = if let Some(provided_sha) = &target_config.sha256 { - // Use provided SHA256 - Some(provided_sha.clone()) - } else if let Some(github_archive) = github_release_archive_from_url(archive_url) { - // Try to fetch SHA256 from GitHub API - if let Ok(release) = ::http_client::github::get_release_by_tag_name( - &github_archive.repo_name_with_owner, - &github_archive.tag, - http_client.clone(), - ) - .await - { - // Find matching asset - if let Some(asset) = release - .assets - .iter() - .find(|a| a.name == github_archive.asset_name) - { - // Strip "sha256:" prefix if present - asset.digest.as_ref().map(|d| { - d.strip_prefix("sha256:") - .map(|s| s.to_string()) - .unwrap_or_else(|| d.clone()) - }) - } else { - None - } - } else { - None - } - } else { - None - }; - - let asset_kind = asset_kind_for_archive_url(archive_url)?; - - // Download and extract - ::http_client::github_download::download_server_binary( - &*http_client, - archive_url, - sha256.as_deref(), - &version_dir, - asset_kind, - ) - .await?; - } - - // Validate and resolve cmd path - let cmd = &target_config.cmd; - - let cmd_path = if cmd == "node" { - // Use Zed's managed Node.js runtime - node_runtime.binary_path().await? - } else { - if cmd.contains("..") { - anyhow::bail!("command path cannot contain '..': {}", cmd); - } - - if cmd.starts_with("./") || cmd.starts_with(".\\") { - // Relative to extraction directory - let cmd_path = version_dir.join(&cmd[2..]); - anyhow::ensure!( - fs.is_file(&cmd_path).await, - "Missing command {} after extraction", - cmd_path.to_string_lossy() - ); - cmd_path - } else { - // On PATH - anyhow::bail!("command must be relative (start with './'): {}", cmd); - } - }; - - cx.background_spawn({ - let fs = fs.clone(); - let dir = dir.clone(); - let version_dir = version_dir.clone(); - async move { - remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir) - .await - .log_err(); - } - }) - .detach(); - - let mut args = target_config.args.clone(); - args.extend(extra_args); - - let command = AgentServerCommand { - path: cmd_path, - args, - env: Some(env), - }; - - Ok(command) - }) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - struct LocalRegistryArchiveAgent { fs: Arc, http_client: Arc, @@ -1813,40 +1380,6 @@ pub enum CustomAgentServerSettings { /// Default: {} favorite_config_option_values: HashMap>, }, - Extension { - /// Additional environment variables to pass to the agent. - /// - /// Default: {} - env: HashMap, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - default_model: Option, - /// The favorite models for this agent. - /// - /// Default: [] - favorite_models: Vec, - /// Default values for session config options. - /// - /// This is a map from config option ID to value ID. - /// - /// Default: {} - default_config_options: HashMap, - /// Favorited values for session config options. - /// - /// This is a map from config option ID to a list of favorited value IDs. - /// - /// Default: {} - favorite_config_option_values: HashMap>, - }, Registry { /// Additional environment variables to pass to the agent. /// @@ -1887,15 +1420,13 @@ impl CustomAgentServerSettings { pub fn command(&self) -> Option<&AgentServerCommand> { match self { CustomAgentServerSettings::Custom { command, .. } => Some(command), - CustomAgentServerSettings::Extension { .. } - | CustomAgentServerSettings::Registry { .. } => None, + CustomAgentServerSettings::Registry { .. } => None, } } pub fn default_mode(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_mode, .. } - | CustomAgentServerSettings::Extension { default_mode, .. } | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(), } } @@ -1903,7 +1434,6 @@ impl CustomAgentServerSettings { pub fn default_model(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_model, .. } - | CustomAgentServerSettings::Extension { default_model, .. } | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(), } } @@ -1913,9 +1443,6 @@ impl CustomAgentServerSettings { CustomAgentServerSettings::Custom { favorite_models, .. } - | CustomAgentServerSettings::Extension { - favorite_models, .. - } | CustomAgentServerSettings::Registry { favorite_models, .. } => favorite_models, @@ -1928,10 +1455,6 @@ impl CustomAgentServerSettings { default_config_options, .. } - | CustomAgentServerSettings::Extension { - default_config_options, - .. - } | CustomAgentServerSettings::Registry { default_config_options, .. @@ -1945,10 +1468,6 @@ impl CustomAgentServerSettings { favorite_config_option_values, .. } - | CustomAgentServerSettings::Extension { - favorite_config_option_values, - .. - } | CustomAgentServerSettings::Registry { favorite_config_option_values, .. @@ -1983,21 +1502,6 @@ impl From for CustomAgentServerSettings { default_config_options, favorite_config_option_values, }, - settings::CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - } => CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - }, settings::CustomAgentServerSettings::Registry { env, default_mode, @@ -2024,7 +1528,15 @@ impl settings::Settings for AllAgentServersSettings { agent_settings .0 .into_iter() - .map(|(k, v)| (k, v.into())) + .map(|(k, v)| { + ( + EXTENSION_TO_REGISTRY_IDS + .get(&k.as_str()) + .map(|v| v.to_string()) + .unwrap_or(k), + v.into(), + ) + }) .collect(), ) } diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 6e231453e41..35651a7ff4b 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -27,7 +27,7 @@ use util::{ResultExt as _, rel_path::RelPath}; use crate::{ DisableAiSettings, Project, - project_settings::{ContextServerSettings, ProjectSettings}, + project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; @@ -56,6 +56,11 @@ pub enum ContextServerStatus { /// The server returned 401 and OAuth authorization is needed. The UI /// should show an "Authenticate" button. AuthRequired, + /// The server has a pre-registered OAuth client_id, but a client_secret + /// is needed and not available in settings or the keychain. + ClientSecretRequired { + error: Option>, + }, /// The OAuth browser flow is in progress — the user has been redirected /// to the authorization server and we're waiting for the callback. Authenticating, @@ -69,6 +74,11 @@ impl ContextServerStatus { ContextServerState::Stopped { .. } => ContextServerStatus::Stopped, ContextServerState::Error { error, .. } => ContextServerStatus::Error(error.clone()), ContextServerState::AuthRequired { .. } => ContextServerStatus::AuthRequired, + ContextServerState::ClientSecretRequired { error, .. } => { + ContextServerStatus::ClientSecretRequired { + error: error.clone(), + } + } ContextServerState::Authenticating { .. } => ContextServerStatus::Authenticating, } } @@ -100,6 +110,14 @@ enum ContextServerState { configuration: Arc, discovery: Arc, }, + /// A pre-registered client_id is configured but no client_secret was found + /// in settings or the keychain. + ClientSecretRequired { + server: Arc, + configuration: Arc, + discovery: Arc, + error: Option>, + }, /// The OAuth browser flow is in progress. The user has been redirected /// to the authorization server and we're waiting for the callback. Authenticating { @@ -117,6 +135,7 @@ impl ContextServerState { | ContextServerState::Stopped { server, .. } | ContextServerState::Error { server, .. } | ContextServerState::AuthRequired { server, .. } + | ContextServerState::ClientSecretRequired { server, .. } | ContextServerState::Authenticating { server, .. } => server.clone(), } } @@ -128,6 +147,7 @@ impl ContextServerState { | ContextServerState::Stopped { configuration, .. } | ContextServerState::Error { configuration, .. } | ContextServerState::AuthRequired { configuration, .. } + | ContextServerState::ClientSecretRequired { configuration, .. } | ContextServerState::Authenticating { configuration, .. } => configuration.clone(), } } @@ -148,6 +168,7 @@ pub enum ContextServerConfiguration { url: url::Url, headers: HashMap, timeout: Option, + oauth: Option, }, } @@ -228,12 +249,14 @@ impl ContextServerConfiguration { url, headers: auth, timeout, + oauth, } => { let url = url::Url::parse(&url).log_err()?; Some(ContextServerConfiguration::Http { url, headers: auth, timeout, + oauth, }) } } @@ -841,6 +864,7 @@ impl ContextServerStore { url, headers, timeout, + oauth: _, } => { let transport = HttpTransport::new_with_token_provider( cx.http_client(), @@ -1007,6 +1031,15 @@ impl ContextServerStore { _ => anyhow::bail!("Server is not in AuthRequired state"), }; + let needs_keychain_check = match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + oauth: Some(oauth_settings), + .. + } if oauth_settings.client_secret.is_none() => Some(url.clone()), + _ => None, + }; + let id = id.clone(); let task = cx.spawn({ @@ -1014,6 +1047,33 @@ impl ContextServerStore { let server = server.clone(); let configuration = configuration.clone(); async move |this, cx| { + if let Some(server_url) = needs_keychain_check { + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); + let has_keychain_secret = + Self::load_client_secret(&credentials_provider, &server_url, cx) + .await + .ok() + .flatten() + .is_some(); + + if !has_keychain_secret { + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + error: None, + }, + cx, + ); + }) + .log_err(); + return; + } + } + let result = Self::run_oauth_flow( this.clone(), id.clone(), @@ -1025,15 +1085,13 @@ impl ContextServerStore { if let Err(err) = &result { log::error!("{} OAuth authentication failed: {:?}", id, err); - // Transition back to AuthRequired so the user can retry - // rather than landing in a terminal Error state. this.update(cx, |this, cx| { this.update_server_state( id.clone(), - ContextServerState::AuthRequired { + ContextServerState::Error { server, configuration, - discovery, + error: format!("{err:#}").into(), }, cx, ) @@ -1056,6 +1114,121 @@ impl ContextServerStore { Ok(()) } + /// Store the client secret and proceed with authentication. + pub fn submit_client_secret( + &mut self, + id: &ContextServerId, + secret: String, + cx: &mut Context, + ) -> Result<()> { + let state = self.servers.get(id).context("Context server not found")?; + + let (server, configuration, discovery) = match state { + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + .. + } => (server.clone(), configuration.clone(), discovery.clone()), + _ => anyhow::bail!("Server is not in ClientSecretRequired state"), + }; + + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } => url.clone(), + _ => anyhow::bail!("OAuth only supported for HTTP servers"), + }; + + let id = id.clone(); + + let task = cx.spawn({ + let id = id.clone(); + let server = server.clone(); + let configuration = configuration.clone(); + async move |this, cx| { + // Store the secret if non-empty (empty means public client / skip). + if !secret.is_empty() { + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); + if let Err(err) = + Self::store_client_secret(&credentials_provider, &server_url, &secret, cx) + .await + { + log::error!( + "{} failed to store client secret in keychain: {:?}", + id, + err + ); + } + } + + let result = Self::run_oauth_flow( + this.clone(), + id.clone(), + discovery.clone(), + configuration.clone(), + cx, + ) + .await; + + if let Err(err) = &result { + log::error!("{} OAuth authentication failed: {:?}", id, err); + + let is_bad_client_credentials = err + .downcast_ref::() + .is_some_and(|e| e.error == "unauthorized_client"); + + if is_bad_client_credentials { + // Clear the bad secret from the keychain so the user + // gets a fresh prompt. + let credentials_provider = + cx.update(|cx| zed_credentials_provider::global(cx)); + Self::clear_client_secret(&credentials_provider, &server_url, cx) + .await + .log_err(); + + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + error: Some(format!("{err:#}").into()), + }, + cx, + ); + }) + .log_err(); + } else { + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::Error { + server, + configuration, + error: format!("{err:#}").into(), + }, + cx, + ) + }) + .log_err(); + } + } + } + }); + + self.update_server_state( + id, + ContextServerState::Authenticating { + server, + configuration, + _task: task, + }, + cx, + ); + + Ok(()) + } + async fn run_oauth_flow( this: WeakEntity, id: ContextServerId, @@ -1083,10 +1256,30 @@ impl ContextServerStore { _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), }; - let client_registration = - oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri) + let client_registration = match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + oauth: Some(oauth_settings), + .. + } => { + // Pre-registered client. Resolve the secret from settings, then keychain. + let client_secret = if oauth_settings.client_secret.is_some() { + oauth_settings.client_secret.clone() + } else { + Self::load_client_secret(&credentials_provider, url, cx) + .await + .ok() + .flatten() + }; + oauth::OAuthClientRegistration { + client_id: oauth_settings.client_id.clone(), + client_secret, + } + } + _ => oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri) .await - .context("Failed to resolve OAuth client registration")?; + .context("Failed to resolve OAuth client registration")?, + }; let auth_url = oauth::build_authorization_url( &discovery.auth_server_metadata, @@ -1116,6 +1309,7 @@ impl ContextServerStore { &redirect_uri, &pkce.verifier, &resource, + client_registration.client_secret.as_deref(), ) .await .context("Failed to exchange authorization code for tokens")?; @@ -1149,6 +1343,7 @@ impl ContextServerStore { url, headers, timeout, + oauth: _, } => { let transport = HttpTransport::new_with_token_provider( http_client.clone(), @@ -1222,6 +1417,46 @@ impl ContextServerStore { format!("mcp-oauth:{}", oauth::canonical_server_uri(server_url)) } + fn client_secret_keychain_key(server_url: &url::Url) -> String { + format!( + "mcp-oauth-client-secret:{}", + oauth::canonical_server_uri(server_url) + ) + } + + async fn load_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + cx: &AsyncApp, + ) -> Result> { + let key = Self::client_secret_keychain_key(server_url); + match credentials_provider.read_credentials(&key, cx).await? { + Some((_username, secret_bytes)) => Ok(Some(String::from_utf8(secret_bytes)?)), + None => Ok(None), + } + } + + pub async fn store_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + secret: &str, + cx: &AsyncApp, + ) -> Result<()> { + let key = Self::client_secret_keychain_key(server_url); + credentials_provider + .write_credentials(&key, "mcp-oauth-client-secret", secret.as_bytes(), cx) + .await + } + + async fn clear_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + cx: &AsyncApp, + ) -> Result<()> { + let key = Self::client_secret_keychain_key(server_url); + credentials_provider.delete_credentials(&key, cx).await + } + /// Log out of an OAuth-authenticated MCP server: clear the stored OAuth /// session from the keychain and stop the server. pub fn logout_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { @@ -1241,6 +1476,11 @@ impl ContextServerStore { if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::error!("{} failed to clear OAuth session: {}", id, err); } + // Also clear any client secret so the user gets a fresh prompt on + // the next authentication attempt. + Self::clear_client_secret(&credentials_provider, &server_url, &cx) + .await + .log_err(); // Trigger server recreation so the next start uses a fresh // transport without the old (now-invalidated) token provider. this.update(cx, |this, cx| { @@ -1487,6 +1727,34 @@ async fn resolve_start_failure( match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await { Ok(discovery) => { + use context_server::oauth::{ + ClientRegistrationStrategy, determine_registration_strategy, + }; + + let has_preregistered_client_id = matches!( + configuration.as_ref(), + ContextServerConfiguration::Http { oauth: Some(_), .. } + ); + + let strategy = determine_registration_strategy(&discovery.auth_server_metadata); + + if matches!(strategy, ClientRegistrationStrategy::Unavailable) + && !has_preregistered_client_id + { + log::error!( + "{id} authorization server supports neither CIMD nor DCR, \ + and no pre-registered client_id is configured" + ); + return ContextServerState::Error { + configuration, + server, + error: "Authorization server supports neither CIMD nor DCR. \ + Configure a pre-registered client_id in your settings \ + under the \"oauth\" key." + .into(), + }; + } + log::info!( "{id} requires OAuth authorization (auth server: {})", discovery.auth_server_metadata.issuer, diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 39578eaf8f0..fc5f56395cc 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -3145,7 +3145,7 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { let temp_dir = tempfile::tempdir().context("creating temporary directory")?; - node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + node.npm_install_latest_packages(temp_dir.path(), &[PACKAGE_NAME]) .await .context("installing latest companion package")?; let version = node diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 9f97f829b0c..ad5174ec365 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1498,6 +1498,7 @@ impl GitStore { else { return; }; + log::debug!("received worktree update for repositories: {changed_repos:?}"); self.update_repositories_from_worktree( *worktree_id, project_environment.clone(), @@ -9143,6 +9144,8 @@ async fn compute_snapshot( backend: Arc, cx: &mut AsyncApp, ) -> Result { + log::debug!("starting compute snapshot"); + let (id, work_directory_abs_path, prev_snapshot) = this.update(cx, |this, _| { this.paths_needing_status_update.clear(); ( @@ -9174,6 +9177,7 @@ async fn compute_snapshot( } }) .await?; + log::debug!("fetched branches, head commit, worktrees"); let branch = branches.iter().find(|branch| branch.is_head).cloned(); let branch_list: Arc<[Branch]> = branches.into(); @@ -9197,6 +9201,8 @@ async fn compute_snapshot( }) .await?; + log::debug!("fetched remotes"); + let snapshot = this.update(cx, |this, cx| { let head_changed = branch != this.snapshot.branch || head_commit != this.snapshot.head_commit; @@ -9256,6 +9262,7 @@ async fn compute_snapshot( } }) .await?; + log::debug!("fetched statuses, diff stats, stash entries"); let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); diff --git a/crates/project/src/git_store/job_debug_queue.rs b/crates/project/src/git_store/job_debug_queue.rs index c204451d58b..6a31e62b02c 100644 --- a/crates/project/src/git_store/job_debug_queue.rs +++ b/crates/project/src/git_store/job_debug_queue.rs @@ -113,6 +113,10 @@ impl GitJobDebugQueue { } pub fn to_debug_string(&self) -> String { + serde_json::to_string_pretty(&self.to_debug_value()).unwrap_or_default() + } + + pub fn to_debug_value(&self) -> serde_json::Value { let mut entries = Vec::new(); let mut pending_count = 0u64; @@ -141,7 +145,7 @@ impl GitJobDebugQueue { let json_entries: Vec = entries.into_iter().map(|(_, json)| json).collect(); - let json = serde_json::json!({ + serde_json::json!({ "summary": { "pending": pending_count, "running": running_count, @@ -149,9 +153,7 @@ impl GitJobDebugQueue { "skipped": skipped_count, }, "entries": json_entries, - }); - - serde_json::to_string_pretty(&json).unwrap_or_default() + }) } fn format_pending(&self, job: &PendingJob) -> serde_json::Value { diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index e110176dd20..438f8f66375 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -189,6 +189,7 @@ pub(crate) struct PerformRename { #[derive(Debug, Clone, Copy)] pub struct GetDefinitions { pub position: PointUtf16, + pub workspace_only: bool, } #[derive(Debug, Clone, Copy)] @@ -199,6 +200,7 @@ pub(crate) struct GetDeclarations { #[derive(Debug, Clone, Copy)] pub(crate) struct GetTypeDefinitions { pub position: PointUtf16, + pub workspace_only: bool, } #[derive(Debug, Clone, Copy)] @@ -689,7 +691,15 @@ impl LspCommand for GetDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp( + message, + lsp_store, + buffer, + server_id, + self.workspace_only, + cx, + ) + .await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDefinition { @@ -700,6 +710,7 @@ impl LspCommand for GetDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), + workspace_only: self.workspace_only, } } @@ -720,6 +731,7 @@ impl LspCommand for GetDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + workspace_only: message.workspace_only, }) } @@ -792,7 +804,7 @@ impl LspCommand for GetDeclarations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDeclaration { @@ -894,7 +906,7 @@ impl LspCommand for GetImplementations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetImplementation { @@ -993,7 +1005,7 @@ impl LspCommand for GetTypeDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, project, buffer, server_id, cx).await + location_links_from_lsp(message, project, buffer, server_id, self.workspace_only, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetTypeDefinition { @@ -1004,6 +1016,7 @@ impl LspCommand for GetTypeDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), + workspace_only: self.workspace_only, } } @@ -1024,6 +1037,7 @@ impl LspCommand for GetTypeDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + workspace_only: message.workspace_only, }) } @@ -1148,6 +1162,7 @@ pub async fn location_links_from_lsp( lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, + workspace_only: bool, mut cx: AsyncApp, ) -> Result> { let message = match message { @@ -1179,6 +1194,25 @@ pub async fn location_links_from_lsp( let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { + if workspace_only + && !lsp_store.update(&mut cx, |this, cx| { + use util::paths::UrlExt as _; + let worktree_store = this.worktree_store().read(cx); + let path_style = worktree_store.path_style(); + let Ok(abs_path) = target_uri.clone().to_file_path_ext(path_style) else { + return false; + }; + worktree_store + .find_worktree(&abs_path, cx) + .is_some_and(|(worktree, _)| { + let worktree = worktree.read(cx); + worktree.is_visible() && !worktree.is_single_file() + }) + }) + { + continue; + } + let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index c943498817d..4561ace3b3d 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -58,6 +58,7 @@ use clock::Global; use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, + channel::oneshot, future::{Either, Shared, join_all, pending, select}, select, select_biased, stream::FuturesUnordered, @@ -3593,8 +3594,10 @@ impl LocalLspStore { } } servers_to_remove.retain(|server_id| !servers_to_preserve.contains(server_id)); - self.language_server_ids - .retain(|_, state| !servers_to_remove.contains(&state.id)); + self.language_server_ids.retain(|seed, state| { + seed.worktree_id != id_to_remove && !servers_to_remove.contains(&state.id) + }); + self.lsp_tree.instances.remove(&id_to_remove); for server_id_to_remove in &servers_to_remove { self.language_server_watched_paths .remove(server_id_to_remove); @@ -5937,9 +5940,31 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, + ) -> Task>>> { + self.definitions_with_filter(buffer, position, false, cx) + } + + pub fn workspace_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + cx: &mut Context, + ) -> Task>>> { + self.definitions_with_filter(buffer, position, true, cx) + } + + fn definitions_with_filter( + &mut self, + buffer: &Entity, + position: PointUtf16, + workspace_only: bool, + cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetDefinitions { position }; + let request = GetDefinitions { + position, + workspace_only, + }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -5964,7 +5989,11 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetDefinitions { position }.response_from_proto( + GetDefinitions { + position, + workspace_only, + } + .response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -5987,7 +6016,10 @@ impl LspStore { let definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetDefinitions { position }, + GetDefinitions { + position, + workspace_only, + }, cx, ); cx.background_spawn(async move { @@ -6077,9 +6109,31 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, + ) -> Task>>> { + self.type_definitions_with_filter(buffer, position, false, cx) + } + + pub fn workspace_type_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + cx: &mut Context, + ) -> Task>>> { + self.type_definitions_with_filter(buffer, position, true, cx) + } + + fn type_definitions_with_filter( + &mut self, + buffer: &Entity, + position: PointUtf16, + workspace_only: bool, + cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetTypeDefinitions { position }; + let request = GetTypeDefinitions { + position, + workspace_only, + }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6102,7 +6156,11 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetTypeDefinitions { position }.response_from_proto( + GetTypeDefinitions { + position, + workspace_only, + } + .response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6125,7 +6183,10 @@ impl LspStore { let type_definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetTypeDefinitions { position }, + GetTypeDefinitions { + position, + workspace_only, + }, cx, ); cx.background_spawn(async move { @@ -10042,6 +10103,16 @@ impl LspStore { .map(|(key, value)| (*key, value)) } + #[cfg(feature = "test-support")] + pub fn has_language_server_seed_for_worktree(&self, worktree_id: WorktreeId) -> bool { + self.as_local().is_some_and(|local| { + local + .language_server_ids + .keys() + .any(|seed| seed.worktree_id == worktree_id) + }) + } + pub(super) fn did_rename_entry( &self, worktree_id: WorktreeId, @@ -12423,11 +12494,45 @@ impl LspStore { .and_then(|local| local.language_servers.get_mut(&server_id)) { for diagnostics in workspace_diagnostics_refresh_tasks.values_mut() { - diagnostics.refresh_tx.try_send(()).ok(); + diagnostics.refresh_tx.try_send(None).ok(); } } } + /// Triggers a workspace diagnostics pull on all running language servers + /// and returns a [`Task`] that resolves once the requests have completed. + /// + /// This reuses the same background refresh loops as + /// [`Self::pull_workspace_diagnostics`], but provides a completion signal + /// so callers can wait for fresh diagnostics before reading them. + pub fn pull_workspace_diagnostics_once(&mut self, cx: &mut Context) -> Task { + let Some(local) = self.as_local_mut() else { + return Task::ready(true); + }; + + let mut receivers = Vec::new(); + for state in local.language_servers.values_mut() { + let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state + else { + continue; + }; + for task in workspace_diagnostics_refresh_tasks.values_mut() { + let (tx, rx) = oneshot::channel(); + task.refresh_tx.try_send(Some(tx)).ok(); + receivers.push(rx); + } + } + + cx.background_spawn(async { + FuturesUnordered::from_iter(receivers) + .all(async |result| result.unwrap_or(false)) + .await + }) + } + /// Refreshes `textDocument/diagnostic` for all open buffers associated with the given server. /// This is called in response to `workspace/diagnostic/refresh` to comply with the LSP spec, /// which requires refreshing both workspace and document diagnostics. @@ -13517,8 +13622,8 @@ fn lsp_workspace_diagnostics_refresh( let registration_id_shared = registration_id.as_ref().map(SharedString::from); let (progress_tx, mut progress_rx) = mpsc::channel(1); - let (mut refresh_tx, mut refresh_rx) = mpsc::channel(1); - refresh_tx.try_send(()).ok(); + let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(1); + refresh_tx.try_send(None).ok(); let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings @@ -13538,7 +13643,7 @@ fn lsp_workspace_diagnostics_refresh( let mut requests = 0; loop { - let Some(()) = refresh_rx.recv().await else { + let Some(mut completion_tx) = refresh_rx.recv().await else { return; }; @@ -13614,6 +13719,9 @@ fn lsp_workspace_diagnostics_refresh( } ConnectionResult::Result(Err(e)) => { log::error!("Error during workspace diagnostics pull: {e:#}"); + if let Some(tx) = completion_tx.take() { + tx.send(false).ok(); + } break 'request; } ConnectionResult::Result(Ok(pulled_diagnostics)) => { @@ -13631,6 +13739,9 @@ fn lsp_workspace_diagnostics_refresh( { return; } + if let Some(tx) = completion_tx.take() { + tx.send(true).ok(); + } break 'request; } } @@ -14109,7 +14220,7 @@ impl LanguageServerLogType { } pub struct WorkspaceRefreshTask { - refresh_tx: mpsc::Sender<()>, + refresh_tx: mpsc::Sender>>, progress_tx: mpsc::Sender<()>, #[allow(dead_code)] task: Task<()>, diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index bb994492d00..dd7010275dc 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -443,6 +443,7 @@ impl LspCommand for GoToParentModule { lsp_store, buffer, server_id, + false, cx, ) .await diff --git a/crates/project/src/prettier_store.rs b/crates/project/src/prettier_store.rs index faa2cca7986..8d9399dce64 100644 --- a/crates/project/src/prettier_store.rs +++ b/crates/project/src/prettier_store.rs @@ -930,23 +930,11 @@ async fn install_prettier_packages( plugins_to_install: HashSet>, node: NodeRuntime, ) -> anyhow::Result<()> { - let packages_to_versions = future::try_join_all( - plugins_to_install - .iter() - .chain(Some(&"prettier".into())) - .map(|package_name| async { - let returned_package_name = package_name.to_string(); - let latest_version = node - .npm_package_latest_version(package_name) - .await - .with_context(|| { - format!("fetching latest npm version for package {returned_package_name}") - })?; - anyhow::Ok((returned_package_name, latest_version.to_string())) - }), - ) - .await - .context("fetching latest npm versions")?; + let packages_to_install = plugins_to_install + .iter() + .map(|package_name| package_name.to_string()) + .chain(Some("prettier".to_string())) + .collect::>(); let default_prettier_dir = default_prettier_dir().as_path(); match fs.metadata(default_prettier_dir).await.with_context(|| { @@ -962,12 +950,12 @@ async fn install_prettier_packages( .with_context(|| format!("creating default prettier dir {default_prettier_dir:?}"))?, } - log::info!("Installing default prettier and plugins: {packages_to_versions:?}"); - let borrowed_packages = packages_to_versions + log::info!("Installing default prettier and plugins: {packages_to_install:?}"); + let borrowed_packages = packages_to_install .iter() - .map(|(package, version)| (package.as_str(), version.as_str())) + .map(|package_name| package_name.as_str()) .collect::>(); - node.npm_install_packages(default_prettier_dir, &borrowed_packages) + node.npm_install_latest_packages(default_prettier_dir, &borrowed_packages) .await .context("fetching formatter packages")?; anyhow::Ok(()) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 65d8307bb57..31bd21f2e18 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4180,6 +4180,24 @@ impl Project { }) } + pub fn workspace_definitions( + &mut self, + buffer: &Entity, + position: T, + cx: &mut Context, + ) -> Task>>> { + let position = position.to_point_utf16(buffer.read(cx)); + let guard = self.retain_remotely_created_models(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.workspace_definitions(buffer, position, cx) + }); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + pub fn declarations( &mut self, buffer: &Entity, @@ -4216,6 +4234,24 @@ impl Project { }) } + pub fn workspace_type_definitions( + &mut self, + buffer: &Entity, + position: T, + cx: &mut Context, + ) -> Task>>> { + let position = position.to_point_utf16(buffer.read(cx)); + let guard = self.retain_remotely_created_models(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.workspace_type_definitions(buffer, position, cx) + }); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + pub fn implementations( &mut self, buffer: &Entity, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index d2dc70b8392..f11263a8f70 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -201,6 +201,10 @@ pub enum ContextServerSettings { headers: HashMap, /// Timeout for tool calls in milliseconds. timeout: Option, + /// Pre-registered OAuth client credentials for authorization servers that + /// require out-of-band client registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + oauth: Option, }, Extension { /// Whether the context server is enabled. @@ -243,11 +247,16 @@ impl From for ContextServerSettings { url, headers, timeout, + oauth, } => ContextServerSettings::Http { enabled, url, headers, timeout, + oauth: oauth.map(|o| OAuthClientSettings { + client_id: o.client_id, + client_secret: o.client_secret, + }), }, } } @@ -278,16 +287,35 @@ impl Into for ContextServerSettings { url, headers, timeout, + oauth, } => settings::ContextServerSettingsContent::Http { enabled, url, headers, timeout, + oauth: oauth.map(|o| settings::OAuthClientSettings { + client_id: o.client_id, + client_secret: o.client_secret, + }), }, } } } +/// Pre-registered OAuth client credentials for MCP servers that don't support +/// Dynamic Client Registration. +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct OAuthClientSettings { + /// The OAuth client ID obtained from out-of-band registration with the + /// authorization server. + pub client_id: String, + /// The OAuth client secret, if this is a confidential client. For security, + /// prefer providing this interactively; we will prompt and store it in + /// the system keychain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + impl ContextServerSettings { pub fn default_extension() -> Self { Self::Extension { diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs index 69d410adc66..8d8804c3f97 100644 --- a/crates/project/src/trusted_worktrees.rs +++ b/crates/project/src/trusted_worktrees.rs @@ -113,6 +113,17 @@ impl TrustedWorktrees { pub fn try_get_global(cx: &App) -> Option> { cx.try_global::().map(|this| this.0.clone()) } + + /// Whether the given project store has any restricted worktrees. + pub fn has_restricted_worktrees(worktree_store: &Entity, cx: &App) -> bool { + Self::try_get_global(cx) + .map(|trusted| { + trusted + .read(cx) + .has_restricted_worktrees(worktree_store, cx) + }) + .unwrap_or(false) + } } /// A collection of worktrees that are considered trusted and not trusted. diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index f9dbce84a17..090baacf032 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -897,6 +897,7 @@ async fn test_remote_context_server(cx: &mut TestAppContext) { url: server_url.to_string(), headers: Default::default(), timeout: None, + oauth: None, }, )], cx, @@ -963,6 +964,7 @@ async fn test_context_server_global_timeout(cx: &mut TestAppContext) { url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"), headers: Default::default(), timeout: None, + oauth: None, }), &mut async_cx, ) @@ -998,6 +1000,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext url: "http://localhost:8080".to_string(), headers: Default::default(), timeout: Some(120), + oauth: None, }, )], ) @@ -1021,6 +1024,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"), headers: Default::default(), timeout: Some(120), + oauth: None, }), &mut async_cx, ) diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs deleted file mode 100644 index 82135485d3f..00000000000 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ /dev/null @@ -1,224 +0,0 @@ -use anyhow::Result; -use collections::HashMap; -use gpui::{AsyncApp, SharedString, Task}; -use project::agent_server_store::*; -use std::{any::Any, collections::HashSet, fmt::Write as _, path::PathBuf}; -// A simple fake that implements ExternalAgentServer without needing async plumbing. -struct NoopExternalAgent; - -impl ExternalAgentServer for NoopExternalAgent { - fn get_command( - &self, - _extra_args: Vec, - _extra_env: HashMap, - _cx: &mut AsyncApp, - ) -> Task> { - Task::ready(Ok(AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - })) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[test] -fn external_agent_server_name_display() { - let name = AgentId(SharedString::from("Ext: Tool")); - let mut s = String::new(); - write!(&mut s, "{name}").unwrap(); - assert_eq!(s, "Ext: Tool"); -} - -#[test] -fn sync_extension_agents_removes_previous_extension_entries() { - let mut store = AgentServerStore::collab(); - - // Seed with a couple of agents that will be replaced by extensions - store.external_agents.insert( - AgentId(SharedString::from("foo-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("bar-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("custom")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - - // Simulate the removal phase: if we're syncing extensions that provide - // "foo-agent" and "bar-agent", those should be removed first - let extension_agent_names: HashSet = ["foo-agent".to_string(), "bar-agent".to_string()] - .into_iter() - .collect(); - - let keys_to_remove: Vec<_> = store - .external_agents - .keys() - .filter(|name| extension_agent_names.contains(name.0.as_ref())) - .cloned() - .collect(); - - for key in keys_to_remove { - store.external_agents.remove(&key); - } - - // Only the custom entry should remain. - let remaining: Vec<_> = store - .external_agents - .keys() - .map(|k| k.0.to_string()) - .collect(); - assert_eq!(remaining, vec!["custom".to_string()]); -} - -#[test] -fn resolve_extension_icon_path_allows_valid_paths() { - // Create a temporary directory structure for testing - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Create a valid icon file - let icon_path = ext_dir.join("icon.svg"); - std::fs::write(&icon_path, "").unwrap(); - - // Test that a valid relative path works - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "icon.svg", - ); - assert!(result.is_some()); - assert!(result.unwrap().ends_with("icon.svg")); -} - -#[test] -fn resolve_extension_icon_path_allows_nested_paths() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - let icons_dir = ext_dir.join("assets").join("icons"); - std::fs::create_dir_all(&icons_dir).unwrap(); - - let icon_path = icons_dir.join("logo.svg"); - std::fs::write(&icon_path, "").unwrap(); - - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "assets/icons/logo.svg", - ); - assert!(result.is_some()); - assert!(result.unwrap().ends_with("logo.svg")); -} - -#[test] -fn resolve_extension_icon_path_blocks_path_traversal() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - - // Create two extension directories - let ext1_dir = extensions_dir.join("extension1"); - let ext2_dir = extensions_dir.join("extension2"); - std::fs::create_dir_all(&ext1_dir).unwrap(); - std::fs::create_dir_all(&ext2_dir).unwrap(); - - // Create a file in extension2 - let secret_file = ext2_dir.join("secret.svg"); - std::fs::write(&secret_file, "secret").unwrap(); - - // Try to access extension2's file from extension1 using path traversal - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "extension1", - "../extension2/secret.svg", - ); - assert!( - result.is_none(), - "Path traversal to sibling extension should be blocked" - ); -} - -#[test] -fn resolve_extension_icon_path_blocks_absolute_escape() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Create a file outside the extensions directory - let outside_file = temp_dir.path().join("outside.svg"); - std::fs::write(&outside_file, "outside").unwrap(); - - // Try to escape to parent directory - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "../outside.svg", - ); - assert!( - result.is_none(), - "Path traversal to parent directory should be blocked" - ); -} - -#[test] -fn resolve_extension_icon_path_blocks_deep_traversal() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Try deep path traversal - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "../../../../../../etc/passwd", - ); - assert!( - result.is_none(), - "Deep path traversal should be blocked (file doesn't exist)" - ); -} - -#[test] -fn resolve_extension_icon_path_returns_none_for_nonexistent() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Try to access a file that doesn't exist - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "nonexistent.svg", - ); - assert!(result.is_none(), "Nonexistent file should return None"); -} diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs deleted file mode 100644 index 5af2cd229c4..00000000000 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ /dev/null @@ -1,332 +0,0 @@ -use anyhow::Result; -use collections::HashMap; -use gpui::{AppContext, AsyncApp, SharedString, Task, TestAppContext}; -use node_runtime::NodeRuntime; -use project::worktree_store::WorktreeStore; -use project::{agent_server_store::*, worktree_store::WorktreeIdCounter}; -use std::{any::Any, path::PathBuf, sync::Arc}; - -#[test] -fn extension_agent_constructs_proper_display_names() { - // Verify the display name format for extension-provided agents - let name1 = AgentId(SharedString::from("Extension: Agent")); - assert!(name1.0.contains(": ")); - - let name2 = AgentId(SharedString::from("MyExt: MyAgent")); - assert_eq!(name2.0, "MyExt: MyAgent"); - - // Non-extension agents shouldn't have the separator - let custom = AgentId(SharedString::from("custom")); - assert!(!custom.0.contains(": ")); -} - -struct NoopExternalAgent; - -impl ExternalAgentServer for NoopExternalAgent { - fn get_command( - &self, - _extra_args: Vec, - _extra_env: HashMap, - _cx: &mut AsyncApp, - ) -> Task> { - Task::ready(Ok(AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - })) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[test] -fn sync_removes_only_extension_provided_agents() { - let mut store = AgentServerStore::collab(); - - // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") - store.external_agents.insert( - AgentId(SharedString::from("Ext1: Agent1")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Extension, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("Ext2: Agent2")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Extension, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("custom-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - - // Simulate removal phase - store - .external_agents - .retain(|_, entry| entry.source != ExternalAgentSource::Extension); - - // Only custom-agent should remain - assert_eq!(store.external_agents.len(), 1); - assert!( - store - .external_agents - .contains_key(&AgentId(SharedString::from("custom-agent"))) - ); -} - -#[test] -fn archive_launcher_constructs_with_all_fields() { - use extension::AgentServerManifestEntry; - - let mut env = HashMap::default(); - env.insert("GITHUB_TOKEN".into(), "secret".into()); - - let mut targets = HashMap::default(); - targets.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: - "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip" - .into(), - cmd: "./agent".into(), - args: vec![], - sha256: None, - env: Default::default(), - }, - ); - - let _entry = AgentServerManifestEntry { - name: "GitHub Agent".into(), - targets, - env, - icon: None, - }; - - // Verify display name construction - let expected_name = AgentId(SharedString::from("GitHub Agent")); - assert_eq!(expected_name.0, "GitHub Agent"); -} - -#[gpui::test] -async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs, - http_client, - node_runtime: node_runtime::NodeRuntime::unavailable(), - project_environment, - extension_id: Arc::from("my-extension"), - agent_id: Arc::from("my-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/my-agent-darwin-arm64.zip".into(), - cmd: "./my-agent".into(), - args: vec!["--serve".into()], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: { - let mut map = HashMap::default(); - map.insert("PORT".into(), "8080".into()); - map - }, - new_version_available_tx: None, - }; - - // Verify agent is properly constructed - assert_eq!(agent.extension_id.as_ref(), "my-extension"); - assert_eq!(agent.agent_id.as_ref(), "my-agent"); - assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string())); - assert!(agent.targets.contains_key("darwin-aarch64")); -} - -#[test] -fn sync_extension_agents_registers_archive_launcher() { - use extension::AgentServerManifestEntry; - - let expected_name = AgentId(SharedString::from("Release Agent")); - assert_eq!(expected_name.0, "Release Agent"); - - // Verify the manifest entry structure for archive-based installation - let mut env = HashMap::default(); - env.insert("API_KEY".into(), "secret".into()); - - let mut targets = HashMap::default(); - targets.insert( - "linux-x86_64".to_string(), - extension::TargetConfig { - archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(), - cmd: "./release-agent".into(), - args: vec!["serve".into()], - sha256: None, - env: Default::default(), - }, - ); - - let manifest_entry = AgentServerManifestEntry { - name: "Release Agent".into(), - targets: targets.clone(), - env, - icon: None, - }; - - // Verify target config is present - assert!(manifest_entry.targets.contains_key("linux-x86_64")); - let target = manifest_entry.targets.get("linux-x86_64").unwrap(); - assert_eq!(target.cmd, "./release-agent"); -} - -#[gpui::test] -async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let node_runtime = NodeRuntime::unavailable(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client, - node_runtime, - project_environment, - extension_id: Arc::from("node-extension"), - agent_id: Arc::from("node-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/node-agent.zip".into(), - cmd: "node".into(), - args: vec!["index.js".into()], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: HashMap::default(), - new_version_available_tx: None, - }; - - // Verify that when cmd is "node", it attempts to use the node runtime - assert_eq!(agent.extension_id.as_ref(), "node-extension"); - assert_eq!(agent.agent_id.as_ref(), "node-agent"); - - let target = agent.targets.get("darwin-aarch64").unwrap(); - assert_eq!(target.cmd, "node"); - assert_eq!(target.args, vec!["index.js"]); -} - -#[gpui::test] -async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let node_runtime = NodeRuntime::unavailable(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client, - node_runtime, - project_environment, - extension_id: Arc::from("test-ext"), - agent_id: Arc::from("test-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/test.zip".into(), - cmd: "node".into(), - args: vec![ - "server.js".into(), - "--config".into(), - "./config.json".into(), - ], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: Default::default(), - new_version_available_tx: None, - }; - - // Verify the agent is configured with relative paths in args - let target = agent.targets.get("darwin-aarch64").unwrap(); - assert_eq!(target.args[0], "server.js"); - assert_eq!(target.args[2], "./config.json"); - // These relative paths will resolve relative to the extraction directory - // when the command is executed -} - -#[test] -fn test_tilde_expansion_in_settings() { - let settings = settings::CustomAgentServerSettings::Custom { - path: PathBuf::from("~/custom/agent"), - args: vec!["serve".into()], - env: Default::default(), - default_mode: None, - default_model: None, - favorite_models: vec![], - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }; - - let converted: CustomAgentServerSettings = settings.into(); - let CustomAgentServerSettings::Custom { - command: AgentServerCommand { path, .. }, - .. - } = converted - else { - panic!("Expected Custom variant"); - }; - - assert!( - !path.to_string_lossy().starts_with("~"), - "Tilde should be expanded for custom agent path" - ); -} diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 7d266ff1365..100042f5d99 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -1,8 +1,94 @@ use std::path::Path; -use language::{CodeLabel, HighlightId}; +use fs::FakeFs; +use futures::StreamExt; +use gpui::TestAppContext; +use language::{CodeLabel, FakeLspAdapter, HighlightId, rust_lang}; +use lsp::Uri; +use project::{Project, lsp_store::*}; +use serde_json::json; +use util::path; -use project::lsp_store::*; +use crate::init_test; + +#[gpui::test] +async fn test_removing_invisible_worktree_cleans_reused_lsp_bookkeeping(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/the-root"), json!({ "main.rs": "fn main() {}" })) + .await; + fs.insert_tree( + path!("/the-registry"), + json!({ "dep": { "src": { "dep.rs": "pub fn dep() {}" } } }), + ) + .await; + + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (_visible_buffer, _visible_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/main.rs"), cx) + }) + .await + .unwrap(); + fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + let server_id = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .language_server_statuses() + .next() + .unwrap() + .0 + }); + let external_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/the-registry/dep/src/dep.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let invisible_worktree_id = + external_buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); + project.read_with(cx, |project, cx| { + let worktree = project.worktree_for_id(invisible_worktree_id, cx).unwrap(); + assert!(!worktree.read(cx).is_visible()); + assert!( + project + .lsp_store() + .read(cx) + .has_language_server_seed_for_worktree(invisible_worktree_id) + ); + }); + + project.update(cx, |project, cx| { + project.remove_worktree(invisible_worktree_id, cx); + }); + cx.run_until_parked(); + + project.read_with(cx, |project, cx| { + let lsp_store = project.lsp_store(); + let lsp_store = lsp_store.read(cx); + assert!( + lsp_store + .language_server_statuses() + .any(|(status_server_id, _)| status_server_id == server_id) + ); + assert!(!lsp_store.has_language_server_seed_for_worktree(invisible_worktree_id)); + }); +} #[test] fn test_glob_literal_prefix() { diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 9624be05940..257734c4e1a 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5,8 +5,6 @@ mod bookmark_store; mod color_extractor; mod context_server_store; mod debugger; -mod ext_agent_tests; -mod extension_agent_tests; mod git_store; mod image_store; mod lsp_command; diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index 91bcdd251bf..8c1f296f171 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -18,7 +18,7 @@ assets.workspace = true chrono.workspace = true collections.workspace = true db.workspace = true -feature_flags.workspace = true + fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index b3194dd1d61..6417f49f85b 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -163,6 +163,7 @@ mod tests { directory_path: PathBuf::from("/skills/oversized"), skill_file_path: PathBuf::from("/skills/oversized/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); diff --git a/crates/prompt_store/src/rules_to_skills_migration.rs b/crates/prompt_store/src/rules_to_skills_migration.rs index 13dba07207d..89bc0541033 100644 --- a/crates/prompt_store/src/rules_to_skills_migration.rs +++ b/crates/prompt_store/src/rules_to_skills_migration.rs @@ -24,14 +24,10 @@ //! (still using Zed's shipped default content) are skipped so we don't //! pollute AGENTS.md with text the user never wrote. //! -//! Both migrations are gated by: -//! -//! * the `skills` feature flag — users without it never have their Rules -//! touched in any way; -//! * a single global "migration already ran" flag persisted in -//! [`GlobalKeyValueStore`] — keyed by [`MIGRATION_DONE_KEY`], so a -//! shared home directory only gets populated once per machine even -//! across release channels. +//! Both migrations are gated by a single global "migration already ran" +//! flag persisted in [`GlobalKeyValueStore`] — keyed by +//! [`MIGRATION_DONE_KEY`], so a shared home directory only gets +//! populated once per machine even across release channels. //! //! The migration is intentionally non-destructive: rule rows in the LMDB //! database are left in place after the migration. That way users can @@ -45,7 +41,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use agent_skills::{SKILL_FILE_NAME, global_skills_dir, slugify_skill_name}; use anyhow::{Context as _, Result}; use db::kvp::GlobalKeyValueStore; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; use fs::Fs; use gpui::{App, AsyncApp, Entity, TaskExt as _}; use serde::{Deserialize, Serialize}; @@ -62,15 +57,15 @@ pub const MIGRATION_DONE_KEY: &str = "rules_to_skills_migration_done"; /// Global KVP key for the JSON-serialized [`MigrationResult`] produced by /// the most recent migration run — the lists of source-Rule titles that -/// were migrated to each destination. The title-bar banner and its -/// explainer modal read this to decide what (if anything) to tell the -/// user about what changed. +/// were migrated to each destination. The skills announcement toast +/// reads this to decide whether to mention the migration in its copy. pub const MIGRATION_RESULT_KEY: &str = "rules_to_skills_migration_result"; /// A persistent record of what the rules-to-skills migration actually /// migrated. Persisted in [`GlobalKeyValueStore`] under -/// [`MIGRATION_RESULT_KEY`] and read back by the announcement UI so the -/// modal can list specific rule names instead of vaguely gesturing. +/// [`MIGRATION_RESULT_KEY`] and read back by the skills announcement +/// toast so it can tailor its copy to users who actually had Rules to +/// migrate. /// /// All three lists hold the *original* user-facing Rule titles, not the /// derived skill slug or any other transformed identifier — those are @@ -92,10 +87,9 @@ pub struct MigrationResult { impl MigrationResult { /// `true` if the migration didn't actually move any Rule anywhere — - /// i.e. the user had no Rules of any kind to migrate. The - /// announcement banner/modal uses this to switch between the - /// "Introducing: Skills" generic intro and the "Skills have replaced - /// Rules" migration summary. + /// i.e. the user had no Rules of any kind to migrate. The skills + /// announcement toast uses this to omit the migration-flavored + /// bullet for users who never had any Rules. pub fn is_empty(&self) -> bool { self.skill_names.is_empty() && self.agents_md_names.is_empty() @@ -151,13 +145,9 @@ static MIGRATION_TASK_SPAWNED: AtomicBool = AtomicBool::new(false); /// Migrate non-Default user rules to global Skills, if not already done. /// /// Safe to call on every startup — short-circuits immediately when the -/// migration has already run, when another invocation in this process -/// has already started it, or when the user doesn't have the `skills` -/// feature flag enabled. +/// migration has already run or when another invocation in this process +/// has already started it. pub fn migrate_rules_to_skills_if_needed(fs: Arc, cx: &mut App) { - if !cx.has_flag::() { - return; - } if migration_done() { return; } diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 813f9e9ec65..f6891a9170e 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -8,6 +8,7 @@ message GetDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; + bool workspace_only = 5; } message GetDefinitionResponse { @@ -30,6 +31,7 @@ message GetTypeDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; + bool workspace_only = 5; } message GetTypeDefinitionResponse { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7ed1db6bfc5..712dd34f353 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1135,24 +1135,41 @@ impl PickerDelegate for RecentProjectsDelegate { } let key = key.clone(); - let path_list = key.path_list().clone(); if let Some(handle) = window.window_handle().downcast::() { cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.find_or_create_local_workspace( - path_list, - Some(key.clone()), - &[], - None, - OpenMode::Activate, - window, - cx, - ) + // Try to activate an existing workspace for this project group + // first, so we preserve the actual worktree paths (which may + // differ from the main git worktree paths stored in the key). + if let Some(workspace) = handle + .update(cx, |multi_workspace, _window, cx| { + multi_workspace.last_active_workspace_for_group(&key, cx) }) .log_err() + .flatten() { - task.detach_and_log_err(cx); + handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace, None, window, cx); + }) + .log_err(); + } else { + let path_list = key.path_list().clone(); + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.find_or_create_local_workspace( + path_list, + Some(key.clone()), + &[], + None, + OpenMode::Activate, + window, + cx, + ) + }) + .log_err() + { + task.detach_and_log_err(cx); + } } }); } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 236f0da3403..43808c7b153 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -977,6 +977,7 @@ impl VsCodeSettings { buffer_font_features: None, agent_ui_font_size: None, agent_buffer_font_size: None, + git_commit_buffer_font_size: None, markdown_preview_font_family: None, markdown_preview_code_font_family: None, markdown_preview_theme: None, diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1a1d4fc6423..917ca5e0530 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -151,7 +151,7 @@ pub struct AgentSettingsContent { pub play_sound_when_agent_done: Option, /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. /// - /// Default: true + /// Default: false pub single_file_review: Option, /// Additional parameters for language model requests. When making a request /// to a model, parameters will be taken from the last entry in this list @@ -523,46 +523,8 @@ pub enum CustomAgentServerSettings { #[serde(default, skip_serializing_if = "HashMap::is_empty")] favorite_config_option_values: HashMap>, }, - Extension { - /// Additional environment variables to pass to the agent. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - env: HashMap, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - default_model: Option, - /// The favorite models for this agent. - /// - /// These are the model IDs as reported by the agent. - /// - /// Default: [] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - favorite_models: Vec, - /// Default values for session config options. - /// - /// This is a map from config option ID to value ID. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - default_config_options: HashMap, - /// Favorited values for session config options. - /// - /// This is a map from config option ID to a list of favorited value IDs. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - favorite_config_option_values: HashMap>, - }, + // Used for the ACP extension migration + #[serde(alias = "extension")] Registry { /// Additional environment variables to pass to the agent. /// diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 081406a6846..2e5ef23875e 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroU32, path::Path}; +use std::num::NonZeroU32; use collections::{HashMap, HashSet}; use schemars::JsonSchema; @@ -137,8 +137,6 @@ pub struct EditPredictionSettingsContent { pub ollama: Option, /// Settings specific to using custom OpenAI-compatible servers for edit prediction. pub open_ai_compatible_api: Option, - /// The directory where manually captured edit prediction examples are stored. - pub examples_dir: Option>, /// Controls whether Zed may collect training data when using Zed's Edit Predictions. /// Data is only ever captured for files in projects that are detected as open source. /// diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 93ef9a36293..fbeede37871 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -388,6 +388,10 @@ pub enum ContextServerSettingsContent { headers: HashMap, /// Timeout for tool calls in seconds. Defaults to global context_server_timeout if not specified. timeout: Option, + /// Pre-registered OAuth client credentials for authorization servers that + /// require out-of-band client registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + oauth: Option, }, Extension { /// Whether the context server is enabled. @@ -429,6 +433,20 @@ impl ContextServerSettingsContent { } } +/// Pre-registered OAuth client credentials for MCP servers that don't support +/// Dynamic Client Registration. +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)] +pub struct OAuthClientSettings { + /// The OAuth client ID obtained from out-of-band registration with the + /// authorization server. + pub client_id: String, + /// The OAuth client secret, if this is a confidential client. For security, + /// prefer providing this interactively; we will prompt and store it in + /// the system keychain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + #[with_fallible_options] #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom)] pub struct ContextServerCommand { diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 4d33d0d9b9d..305e1d40530 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -149,6 +149,7 @@ pub struct ThemeSettingsContent { pub agent_ui_font_size: Option, /// The font size for user messages in the agent panel. pub agent_buffer_font_size: Option, + pub git_commit_buffer_font_size: Option, /// The name of a font to use for rendering in the markdown preview. /// Falls back to the UI font if unset. pub markdown_preview_font_family: Option, diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index d6865a4906b..ee725c0a1d9 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] [dependencies] agent.workspace = true agent_settings.workspace = true +agent_skills.workspace = true anyhow.workspace = true audio.workspace = true component.workspace = true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index eafd7ed93bb..3eb1ec94512 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -13,7 +13,7 @@ use crate::{ ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names, pages::{ - open_audio_test_window, render_edit_prediction_setup_page, + open_audio_test_window, render_edit_prediction_setup_page, render_skills_setup_page, render_tool_permissions_setup_page, }, }; @@ -7484,6 +7484,15 @@ fn ai_page(cx: &App) -> SettingsPage { fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> { let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), + SettingsPageItem::SubPageLink(SubPageLink { + title: "Skills".into(), + r#type: Default::default(), + json_path: Some("agent.skills"), + description: Some("View and manage agent skills installed globally or in project worktrees.".into()), + in_json: false, + files: USER | PROJECT, + render: render_skills_setup_page, + }), SettingsPageItem::SubPageLink(SubPageLink { title: "Tool Permissions".into(), r#type: Default::default(), diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 4a69069148e..f2f8dab3c4c 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -2,6 +2,7 @@ mod audio_input_output_setup; mod audio_test_window; mod edit_prediction_provider_setup; mod feature_flags; +mod skills_setup; mod tool_permissions_setup; pub(crate) use audio_input_output_setup::{ @@ -10,6 +11,7 @@ pub(crate) use audio_input_output_setup::{ pub(crate) use audio_test_window::open_audio_test_window; pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page; pub(crate) use feature_flags::render_feature_flags_page; +pub(crate) use skills_setup::render_skills_setup_page; pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page; pub use tool_permissions_setup::{ diff --git a/crates/settings_ui/src/pages/skills_setup.rs b/crates/settings_ui/src/pages/skills_setup.rs new file mode 100644 index 00000000000..0208f7db47f --- /dev/null +++ b/crates/settings_ui/src/pages/skills_setup.rs @@ -0,0 +1,216 @@ +use agent_skills::{Skill, SkillIndex}; +use fs::RemoveOptions; +use gpui::{Action as _, ScrollHandle, SharedString, prelude::*}; + +use ui::{Divider, Tooltip, prelude::*}; +use util::ResultExt as _; + +use crate::{SettingsUiFile, SettingsWindow}; + +pub(crate) fn render_skills_setup_page( + settings_window: &SettingsWindow, + scroll_handle: &ScrollHandle, + _window: &mut Window, + cx: &mut Context, +) -> AnyElement { + let skill_index = cx.try_global::(); + + // Pick skills that match the current settings file tab: + // - User tab → global skills only + // - Project tab → project-local skills for that worktree only + let skills: Vec = match &settings_window.current_file { + SettingsUiFile::User => skill_index + .map(|idx| idx.global_skills.clone()) + .unwrap_or_default(), + SettingsUiFile::Project((worktree_id, _)) => { + let worktree_id = usize::from(*worktree_id); + skill_index + .and_then(|index| { + index + .project_skills + .iter() + .find(|group| group.worktree_id.0 == worktree_id) + .map(|group| group.skills.clone()) + }) + .unwrap_or_default() + } + _ => Vec::new(), + } + .into_iter() + .filter(|skill| { + !settings_window + .hidden_deleted_skill_directory_paths + .contains(&skill.directory_path) + }) + .collect(); + + v_flex() + .id("skills-page") + .size_full() + .pt_2p5() + .px_8() + .pb_16() + .map(|this| { + if skills.is_empty() { + let message = match &settings_window.current_file { + SettingsUiFile::User => "No global skills installed.", + SettingsUiFile::Project(_) => "No project skills found.", + _ => "No skills available for this context.", + }; + let original_window = settings_window.original_window; + this.items_center().justify_center().child( + v_flex() + .items_center() + .gap_2() + .child(Label::new(message).color(Color::Muted)) + .child( + Button::new("open-skill-creator", "Create a Skill") + .tab_index(0_isize) + .style(ButtonStyle::Outlined) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener(move |_this, _event, window, cx| { + let Some(original_window) = original_window else { + return; + }; + original_window + .update(cx, |_workspace, original_window, cx| { + original_window.dispatch_action( + zed_actions::assistant::OpenSkillCreator + .boxed_clone(), + cx, + ); + }) + .log_err(); + window.remove_window(); + })), + ), + ) + } else { + this.track_scroll(scroll_handle) + .overflow_y_scroll() + .children(skills.iter().enumerate().flat_map(|(i, skill)| { + let mut elements: Vec = vec![render_skill_row(skill, cx)]; + if i + 1 < skills.len() { + elements.push(Divider::horizontal().into_any_element()); + } + elements + })) + } + }) + .into_any_element() +} + +fn render_skill_row(skill: &Skill, cx: &mut Context) -> AnyElement { + let skill_file_path = skill.skill_file_path.clone(); + let directory_path = skill.directory_path.clone(); + + h_flex() + .w_full() + .justify_between() + .py_2p5() + .gap_4() + .child( + v_flex() + .gap_0p5() + .min_w_0() + .flex_1() + .child(Label::new(skill.name.clone())) + .child( + Label::new(skill.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_2() + .child( + IconButton::new( + SharedString::from(format!("delete-{}", skill.name)), + IconName::Trash, + ) + .tab_index(0_isize) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Delete Skill")) + .on_click(cx.listener( + move |settings_window, _event, _window, cx| { + let directory_path = directory_path.clone(); + if !settings_window + .hidden_deleted_skill_directory_paths + .insert(directory_path.clone()) + { + return; + } + cx.notify(); + + let app_state = workspace::AppState::global(cx); + let fs = app_state.fs.clone(); + cx.spawn(async move |settings_window, cx| { + let remove_result = fs + .remove_dir( + &directory_path, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await; + if let Err(error) = remove_result { + log::error!( + "failed to delete skill directory {}: {error:#}", + directory_path.display() + ); + settings_window + .update(cx, |settings_window, cx| { + settings_window + .hidden_deleted_skill_directory_paths + .remove(&directory_path); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + }, + )), + ) + .child( + Button::new(SharedString::from(format!("open-{}", skill.name)), "Open") + .tab_index(0_isize) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener(move |settings_window, _event, window, cx| { + let skill_file_path = skill_file_path.clone(); + let Some(original_window) = settings_window.original_window else { + return; + }; + original_window + .update(cx, |multi_workspace, original_window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + skill_file_path, + Default::default(), + original_window, + cx, + ) + .detach_and_log_err(cx); + }); + }) + .log_err(); + window.remove_window(); + })), + ), + ) + .into_any_element() +} diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 0484181fb61..3122e63d2b6 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1423,6 +1423,9 @@ mod tests { // update_plan updates UI-visible planning state but does not use // tool permission rules. "update_plan", + // update_title updates UI-visible session metadata but + // does not use tool permission rules. + "update_title", ]; let tool_info_ids: Vec<&str> = TOOLS.iter().map(|t| t.id).collect(); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f8d938e9eec..2a49a95af2a 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -2,6 +2,7 @@ mod components; mod page_data; pub mod pages; +use agent_skills::SkillIndex; use anyhow::{Context as _, Result}; use editor::{Editor, EditorEvent}; use futures::{StreamExt, channel::mpsc}; @@ -29,6 +30,7 @@ use std::{ collections::{HashMap, HashSet}, num::{NonZero, NonZeroU32}, ops::Range, + path::PathBuf, rc::Rc, sync::{Arc, LazyLock, RwLock}, time::Duration, @@ -42,7 +44,8 @@ use ui::{ use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; use workspace::{ - AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations, + AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, WorkspaceSettings, + client_side_decorations, }; use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; @@ -662,7 +665,10 @@ pub fn open_settings_editor( let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { Ok(val) if val == "server" => gpui::WindowDecorations::Server, Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => gpui::WindowDecorations::Client, + _ => match WorkspaceSettings::get_global(cx).window_decorations { + settings::WindowDecorations::Server => gpui::WindowDecorations::Server, + settings::WindowDecorations::Client => gpui::WindowDecorations::Client, + }, }; cx.open_window( @@ -763,6 +769,7 @@ pub struct SettingsWindow { search_index: Option>, list_state: ListState, shown_errors: HashSet, + pub(crate) hidden_deleted_skill_directory_paths: HashSet, pub(crate) regex_validation_error: Option, last_copied_link_path: Option<&'static str>, } @@ -1538,6 +1545,28 @@ impl SettingsWindow { }) .detach(); + cx.observe_global_in::(window, |this, _window, cx| { + if let Some(skill_index) = cx.try_global::() { + this.hidden_deleted_skill_directory_paths + .retain(|directory_path| { + skill_index + .global_skills + .iter() + .chain( + skill_index + .project_skills + .iter() + .flat_map(|group| group.skills.iter()), + ) + .any(|skill| skill.directory_path.as_path() == directory_path.as_path()) + }); + } else { + this.hidden_deleted_skill_directory_paths.clear(); + } + cx.notify(); + }) + .detach(); + cx.on_window_closed(|cx, _window_id| { if let Some(existing_window) = cx .windows() @@ -1685,6 +1714,7 @@ impl SettingsWindow { .tab_stop(false), search_index: None, shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, list_state, last_copied_link_path: None, @@ -3002,19 +3032,26 @@ impl SettingsWindow { } fn render_sub_page_breadcrumbs(&self) -> impl IntoElement { + let scope_name: SharedString = self + .display_name(&self.current_file) + .unwrap_or_else(|| self.current_file.setting_type().to_string()) + .into(); + h_flex().min_w_0().gap_1().overflow_x_hidden().children( itertools::intersperse( - std::iter::once(self.current_page().title.into()).chain( - self.sub_page_stack - .iter() - .enumerate() - .flat_map(|(index, page)| { - (index == 0) - .then(|| page.section_header.clone()) - .into_iter() - .chain(std::iter::once(page.link.title.clone())) - }), - ), + std::iter::once(scope_name) + .chain(std::iter::once(self.current_page().title.into())) + .chain( + self.sub_page_stack + .iter() + .enumerate() + .flat_map(|(index, page)| { + (index == 0) + .then(|| page.section_header.clone()) + .into_iter() + .chain(std::iter::once(page.link.title.clone())) + }), + ), "/".into(), ) .map(|item| Label::new(item).color(Color::Muted)), @@ -3350,6 +3387,65 @@ impl SettingsWindow { .into_any_element() } + let mut restricted_banner = gpui::Empty.into_any_element(); + if let SettingsUiFile::Project((worktree_id, _)) = &self.current_file { + let worktree_id = *worktree_id; + let is_restricted = all_projects(self.original_window.as_ref(), cx) + .find(|project| project.read(cx).worktree_for_id(worktree_id, cx).is_some()) + .map(|project| { + let worktree_store = project.read(cx).worktree_store(); + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &worktree_store, + cx, + ) + }) + .unwrap_or(false); + + if is_restricted { + let original_window = self.original_window; + restricted_banner = Banner::new() + .severity(Severity::Warning) + .child( + v_flex() + .my_0p5() + .gap_0p5() + .child(Label::new("Restricted Mode")) + .child( + Label::new( + "This project is in restricted mode. Some project settings may not apply.", + ) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .action_slot( + div().pr_2().pb_1().child( + Button::new("manage-trust", "Manage Trust") + .style(ButtonStyle::Tinted(ui::TintColor::Warning)) + .on_click(cx.listener(move |_this, _, window, cx| { + if let Some(original_window) = original_window { + original_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, cx| { + workspace + .show_worktree_trust_security_modal( + true, window, cx, + ); + }); + }) + .log_err(); + } + // Close the settings window + window.remove_window(); + })), + ), + ) + .into_any_element(); + } + } + v_flex() .id("settings-ui-page") .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| { @@ -3440,7 +3536,8 @@ impl SettingsWindow { .px_8() .gap_2() .child(page_header) - .child(warning_banner), + .child(warning_banner) + .child(restricted_banner), ) .child( div() @@ -4477,6 +4574,7 @@ pub mod test { search_index: None, list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, } @@ -4603,6 +4701,7 @@ pub mod test { search_index: None, list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, }; diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 735421f858d..6eef1741927 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -17,8 +17,8 @@ use agent_ui::threads_archive_view::{ }; use agent_ui::{ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource, - ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, - TerminalId, ThreadId, ThreadImportModal, channels_with_threads, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewTerminalThread, + NewThread, TerminalId, ThreadId, ThreadImportModal, channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; @@ -50,10 +50,10 @@ use std::rc::Rc; use std::sync::Arc; use theme::ActiveTheme; use ui::{ - AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, Tab, - ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, - render_modifiers, + AgentThreadStatus, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, GradientFade, + HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, + Scrollbars, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, + prelude::*, render_modifiers, }; use util::ResultExt as _; use util::path_list::PathList; @@ -107,6 +107,12 @@ enum SerializedSidebarView { History, } +#[derive(Clone, Copy)] +enum NewEntryTarget { + LastCreatedKind, + Terminal, +} + #[derive(Default, Serialize, Deserialize)] struct SerializedSidebar { #[serde(default)] @@ -220,6 +226,32 @@ impl ThreadEntryWorkspace { } } +fn draft_display_label_for_thread_metadata( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + cx: &App, +) -> Option { + let workspace = match workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace), + ThreadEntryWorkspace::Closed { .. } => None, + }; + agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) +} + +fn thread_metadata_would_render_sidebar_row( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + hidden_draft_thread_ids: &HashSet, + cx: &App, +) -> bool { + if !metadata.is_draft() { + return true; + } + + !hidden_draft_thread_ids.contains(&metadata.thread_id) + && draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() +} + #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, @@ -271,6 +303,7 @@ enum ListEntry { highlight_positions: Vec, has_running_threads: bool, waiting_thread_count: usize, + has_notifications: bool, is_active: bool, has_threads: bool, }, @@ -449,10 +482,6 @@ fn linked_worktree_path_lists_for_workspaces( .collect() } -fn workspace_has_terminal_metadata(workspace: &Entity, cx: &App) -> bool { - workspace_has_terminal_metadata_except(workspace, None, cx) -} - fn workspace_has_terminal_metadata_except( workspace: &Entity, except_terminal_id: Option, @@ -626,6 +655,10 @@ pub struct Sidebar { thread_switcher: Option>, _thread_switcher_subscriptions: Vec, pending_thread_activation: Option, + /// Persists live thread statuses across rebuilds so that Running→Completed + /// transitions can be detected even when the group is collapsed (and + /// thread entries are not present in the list). + live_thread_statuses: HashMap, view: SidebarView, restoring_tasks: HashMap>, recent_projects_popover_handle: PopoverMenuHandle, @@ -734,6 +767,7 @@ impl Sidebar { thread_switcher: None, _thread_switcher_subscriptions: Vec::new(), pending_thread_activation: None, + live_thread_statuses: HashMap::new(), view: SidebarView::default(), restoring_tasks: HashMap::new(), recent_projects_popover_handle: PopoverMenuHandle::default(), @@ -1108,6 +1142,7 @@ impl Sidebar { fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, + target: NewEntryTarget, window: &mut Window, cx: &mut Context, ) { @@ -1136,8 +1171,9 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; - this.update_in(cx, |this, window, cx| { - this.create_new_entry(&workspace, window, cx); + this.update_in(cx, |this, window, cx| match target { + NewEntryTarget::LastCreatedKind => this.create_new_entry(&workspace, window, cx), + NewEntryTarget::Terminal => this.create_new_terminal(&workspace, window, cx), })?; anyhow::Ok(()) }) @@ -1175,21 +1211,13 @@ impl Sidebar { let previous = mem::take(&mut self.contents); - let old_statuses: HashMap = previous - .entries - .iter() - .filter_map(|entry| match entry { - ListEntry::Thread(thread) if thread.is_live => { - let sid = thread.metadata.session_id.clone()?; - Some((sid, thread.status)) - } - _ => None, - }) - .collect(); + let old_statuses = &self.live_thread_statuses; let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; let mut notified_terminals: HashSet = HashSet::new(); + let mut new_live_statuses: HashMap = + HashMap::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); let mut current_terminal_ids: HashSet = HashSet::new(); @@ -1385,19 +1413,18 @@ impl Sidebar { let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); + let hidden_draft_thread_ids: HashSet = group_workspaces + .iter() + .filter_map(|ws| { + ws.read(cx) + .panel::(cx) + .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) + }) + .collect(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); - let ephemeral_drafts: HashSet = group_workspaces - .iter() - .filter_map(|ws| { - ws.read(cx) - .panel::(cx) - .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) - }) - .collect(); - let make_thread_entry = |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); @@ -1503,20 +1530,18 @@ impl Sidebar { } } - if !ephemeral_drafts.is_empty() { - threads.retain(|thread| !ephemeral_drafts.contains(&thread.metadata.thread_id)); + if !hidden_draft_thread_ids.is_empty() { + threads.retain(|thread| { + !hidden_draft_thread_ids.contains(&thread.metadata.thread_id) + }); } for thread in &mut threads { if !thread.is_draft { continue; } - let workspace = match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => Some(workspace), - ThreadEntryWorkspace::Closed { .. } => None, - }; - thread.metadata.title = agent_ui::draft_prompt_store::display_label_for_draft( - workspace, - thread.metadata.thread_id, + thread.metadata.title = draft_display_label_for_thread_metadata( + &thread.metadata, + &thread.workspace, cx, ); } @@ -1539,9 +1564,12 @@ impl Sidebar { // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - if let Some(session_id) = &thread.metadata.session_id { - if let Some(info) = live_info_by_session.get(session_id) { + if let Some(session_id) = thread.metadata.session_id.clone() { + if let Some(&info) = live_info_by_session.get(&session_id) { + let status = info.status; + let thread_id = thread.metadata.thread_id; thread.apply_active_info(info); + new_live_statuses.insert(session_id, (status, thread_id)); } } @@ -1555,8 +1583,10 @@ impl Sidebar { if thread.status == AgentThreadStatus::Completed && !is_active_thread - && session_id.as_ref().and_then(|sid| old_statuses.get(sid)) - == Some(&AgentThreadStatus::Running) + && session_id + .as_ref() + .and_then(|sid| old_statuses.get(sid)) + .is_some_and(|(s, _)| *s == AgentThreadStatus::Running) { notified_threads.insert(thread.metadata.thread_id); } @@ -1572,29 +1602,71 @@ impl Sidebar { b_time.cmp(&a_time) }); } else { - for info in live_infos { + for info in &live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } if info.status == AgentThreadStatus::WaitingForConfirmation { waiting_thread_count += 1; } + // Resolve the thread_id for this session so we can + // track its status and detect transitions even while + // the group is collapsed. + let thread_id = old_statuses + .get(&info.session_id) + .map(|(_, tid)| *tid) + .or_else(|| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&info.session_id) + .map(|m| m.thread_id) + }); + + if let Some(thread_id) = thread_id { + let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s); + new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id)); + if info.status == AgentThreadStatus::Completed + && old_status == Some(AgentThreadStatus::Running) + { + notified_threads.insert(thread_id); + } + } + } + + if is_active + && let Some(ActiveEntry::Thread { thread_id, .. }) = self.active_entry.as_ref() + { + notified_threads.remove(thread_id); } } - let has_threads = if !threads.is_empty() || !terminals.is_empty() { - true - } else { + let has_visible_rows = !threads.is_empty() || !terminals.is_empty(); + let has_stored_thread_rows = !should_load_threads && !has_visible_rows && { let store = ThreadMetadataStore::global(cx).read(cx); store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) }; + let has_threads = has_visible_rows || has_stored_thread_rows; if !query.is_empty() { let workspace_highlight_positions = @@ -1657,6 +1729,14 @@ impl Sidebar { continue; } + // Check for notifications: threads that completed while not active. + let has_thread_notifications = matched_threads + .iter() + .any(|t| notified_threads.contains(&t.metadata.thread_id)); + let has_terminal_notifications = matched_terminals + .iter() + .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), @@ -1664,6 +1744,7 @@ impl Sidebar { highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, + has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); @@ -1676,6 +1757,32 @@ impl Sidebar { &mut current_thread_ids, ); } else { + let has_terminal_notifications = terminals + .iter() + .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); + + // When collapsed, threads aren't loaded into `threads`, so we + // query the store for thread IDs to check notifications and + // to prevent the retain below from purging them. + let has_thread_notifications = if threads.is_empty() && !notified_threads.is_empty() + { + let thread_store = ThreadMetadataStore::global(cx); + let store = thread_store.read(cx); + let group_thread_ids = store + .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) + .chain(store.entries_for_path(group_key.path_list(), group_host.as_ref())) + .map(|m| m.thread_id) + .collect::>(); + current_thread_ids.extend(group_thread_ids.iter()); + group_thread_ids + .iter() + .any(|id| notified_threads.contains(id)) + } else { + threads + .iter() + .any(|t| notified_threads.contains(&t.metadata.thread_id)) + }; + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), @@ -1683,6 +1790,7 @@ impl Sidebar { highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, + has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); @@ -1708,6 +1816,8 @@ impl Sidebar { self.terminal_last_accessed .retain(|id, _| current_terminal_ids.contains(id)); + self.live_thread_statuses = new_live_statuses; + self.contents = SidebarContents { entries, notified_threads, @@ -1821,6 +1931,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + has_notifications, is_active: is_active_group, has_threads, } => { @@ -1844,6 +1955,7 @@ impl Sidebar { highlight_positions, *has_running_threads, *waiting_thread_count, + *has_notifications, *is_active_group, is_selected, *has_threads, @@ -1902,6 +2014,7 @@ impl Sidebar { highlight_positions: &[usize], has_running_threads: bool, waiting_thread_count: usize, + has_notifications: bool, is_active: bool, is_focused: bool, has_threads: bool, @@ -2013,6 +2126,16 @@ impl Sidebar { .tooltip(Tooltip::text(tooltip_text)), ) }) + .when( + has_notifications && !has_running_threads && waiting_thread_count == 0, + |this| { + this.child( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Accent), + ) + }, + ) }) .child( div() @@ -2059,7 +2182,12 @@ impl Sidebar { if let Some(workspace) = this.workspace_for_group(&key, cx) { this.create_new_entry(&workspace, window, cx); } else { - this.open_workspace_and_create_entry(&key, window, cx); + this.open_workspace_and_create_entry( + &key, + NewEntryTarget::LastCreatedKind, + window, + cx, + ); } }, )) @@ -2180,6 +2308,19 @@ impl Sidebar { }) .unwrap_or_default(); + // Compute reorder state at menu-open time so it reflects the + // most recent group ordering. + let (group_index, total_groups) = multi_workspace + .read_with(cx, |mw, _| { + let keys = mw.project_group_keys(); + let index = keys.iter().position(|k| k == &project_group_key); + (index, keys.len()) + }) + .unwrap_or((None, 0)); + let show_reorder_entries = total_groups >= 2; + let can_move_up = group_index.is_some_and(|i| i > 0); + let can_move_down = group_index.is_some_and(|i| i + 1 < total_groups); + let active_workspace = multi_workspace .read_with(cx, |multi_workspace, _cx| { multi_workspace.workspace().clone() @@ -2239,9 +2380,9 @@ impl Sidebar { .child(Label::new("-click").color(Color::Muted)); let label = if has_threads { - "Focus Last Workspace" + "Focus Last Project" } else { - "Focus Workspace" + "Focus Project" }; h_flex() @@ -2353,7 +2494,7 @@ impl Sidebar { ) .icon_size(IconSize::Small) .visible_on_hover(&row_group_name) - .tooltip(Tooltip::text("Close Workspace")) + .tooltip(Tooltip::text("Close Worktree")) .on_click(move |_, window, cx| { cx.stop_propagation(); window.prevent_default(); @@ -2399,19 +2540,57 @@ impl Sidebar { menu }; + let menu = menu.when(show_reorder_entries, |this| { + let move_up_multi_workspace = multi_workspace.clone(); + let move_up_key = project_group_key.clone(); + let move_up_weak_menu = weak_menu.clone(); + let move_down_multi_workspace = multi_workspace.clone(); + let move_down_key = project_group_key.clone(); + let move_down_weak_menu = weak_menu.clone(); + + this.separator() + .item( + ContextMenuEntry::new("Move Up") + .disabled(!can_move_up) + .handler(move |_window, cx| { + move_up_multi_workspace + .update(cx, |mw, cx| { + mw.move_project_group_up(&move_up_key, cx); + }) + .ok(); + move_up_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + .item( + ContextMenuEntry::new("Move Down") + .disabled(!can_move_down) + .handler(move |_window, cx| { + move_down_multi_workspace + .update(cx, |mw, cx| { + mw.move_project_group_down(&move_down_key, cx); + }) + .ok(); + move_down_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + }); + let project_group_key = project_group_key.clone(); let remove_multi_workspace = multi_workspace.clone(); - menu.separator() - .entry("Remove Project", None, move |window, cx| { - remove_multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .remove_project_group(&project_group_key, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - }) + menu.separator().entry("Remove", None, move |window, cx| { + remove_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .remove_project_group(&project_group_key, window, cx) + .detach_and_log_err(cx); + }) + .ok(); + weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }) }); let this = this.clone(); @@ -2463,6 +2642,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + has_notifications, is_active, has_threads, } = self.contents.entries.get(header_idx)? @@ -2492,6 +2672,7 @@ impl Sidebar { &highlight_positions, *has_running_threads, *waiting_thread_count, + *has_notifications, *is_active, is_selected, *has_threads, @@ -3620,6 +3801,7 @@ impl Sidebar { } fn should_load_closed_workspace_for_archive( + &self, folder_paths: &PathList, project_group_key: &ProjectGroupKey, remote_connection: Option<&RemoteConnectionOptions>, @@ -3631,13 +3813,17 @@ impl Sidebar { return false; } + let archive_workspaces = self.archive_workspaces(cx); let thread_store = ThreadMetadataStore::global(cx); let thread_store = thread_store.read(cx); if folder_paths.ordered_paths().any(|path| { - thread_store.path_is_referenced_by_unarchived_threads( + Self::path_is_referenced_by_unarchived_threads_for_archive( + &thread_store, except_thread_id, path, remote_connection, + &archive_workspaces, + cx, ) }) { return false; @@ -3655,6 +3841,208 @@ impl Sidebar { }) } + fn path_is_referenced_by_unarchived_threads_for_archive( + thread_store: &ThreadMetadataStore, + except_thread_id: Option, + path: &Path, + remote_connection: Option<&RemoteConnectionOptions>, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + thread_store.path_is_referenced_by_unarchived_threads_matching( + except_thread_id, + path, + remote_connection, + |thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx), + ) + } + + fn archive_workspaces(&self, cx: &App) -> Vec> { + let multi_workspace = self.multi_workspace.upgrade(); + thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx) + } + + fn count_threads_blocking_worktree_archive( + &self, + path_list: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + cx: &App, + ) -> usize { + let archive_workspaces = self.archive_workspaces(cx); + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(path_list, remote_connection) + .filter(|thread| Some(thread.thread_id) != except_thread_id) + .filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)) + .count() + } + + fn roots_to_archive_for_paths( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + cx: &App, + ) -> Vec { + let workspaces = self.archive_workspaces(cx); + folder_paths + .ordered_paths() + .filter_map(|path| { + thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx) + }) + .filter(|plan| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + !Self::path_is_referenced_by_unarchived_threads_for_archive( + &store, + except_thread_id, + plan.root_path.as_path(), + remote_connection, + &workspaces, + cx, + ) + }) + .filter(|root| { + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + !terminal_store.read(cx).path_is_referenced_by_terminal( + except_terminal_id, + root.root_path.as_path(), + remote_connection, + ) + }) + }) + .collect() + } + + fn linked_worktree_workspace_to_remove( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + roots_to_archive: &[thread_worktree_archive::RootPlan], + cx: &App, + ) -> Option> { + if folder_paths.is_empty() { + return None; + } + + let remaining = self.count_threads_blocking_worktree_archive( + folder_paths, + remote_connection, + except_thread_id, + cx, + ); + + if remaining > 0 { + return None; + } + + let multi_workspace = self.multi_workspace.upgrade()?; + let workspace = + multi_workspace + .read(cx) + .workspace_for_paths(folder_paths, remote_connection, cx)?; + + if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) { + return None; + } + + if !roots_to_archive.is_empty() { + let archive_paths: HashSet<&Path> = roots_to_archive + .iter() + .map(|root| root.root_path.as_path()) + .collect(); + let project = workspace.read(cx).project().clone(); + let visible_worktree_paths = project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect::>(); + return (!visible_worktree_paths.is_empty() + && visible_worktree_paths + .iter() + .all(|path| archive_paths.contains(path.as_ref()))) + .then_some(workspace); + } + + let group_key = workspace.read(cx).project_group_key(cx); + (group_key.path_list() != folder_paths).then_some(workspace) + } + + fn delete_empty_drafts_for_archive_roots( + &self, + roots: &[thread_worktree_archive::RootPlan], + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + roots + .iter() + .map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())), + cx, + ); + } + + fn delete_empty_drafts_for_archive_paths( + &self, + paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + paths + .ordered_paths() + .map(|path| (path.as_path(), remote_connection)), + cx, + ); + } + + fn delete_empty_drafts_for_archive_targets<'a>( + &self, + targets: impl IntoIterator)>, + cx: &mut Context, + ) { + let targets = targets.into_iter().collect::>(); + if targets.is_empty() { + return; + } + + let archive_workspaces = self.archive_workspaces(cx); + let draft_thread_ids = ThreadMetadataStore::global(cx) + .read(cx) + .unarchived_draft_ids_matching(|thread| { + targets.iter().any(|(path, remote_connection)| { + thread.matches_remote_connection(*remote_connection) + && thread.references_folder_path(path) + }) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx) + }); + if draft_thread_ids.is_empty() { + return; + } + + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete_all(draft_thread_ids, cx); + }); + } + + fn thread_blocks_worktree_archive( + thread: &ThreadMetadata, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + if !thread.is_draft() { + return true; + } + + agent_ui::draft_prompt_store::draft_has_user_content( + thread.thread_id, + archive_workspaces, + cx, + ) + } + async fn wait_for_archive_workspace_metadata( workspace: &Entity, cx: &mut gpui::AsyncApp, @@ -3784,7 +4172,7 @@ impl Sidebar { folder_paths, project_group_key, } = workspace - && Self::should_load_closed_workspace_for_archive( + && self.should_load_closed_workspace_for_archive( folder_paths, project_group_key, metadata.remote_connection.as_ref(), @@ -3822,83 +4210,22 @@ impl Sidebar { .and_then(|position| self.neighboring_activatable_entry(position)); let terminal_folder_paths = metadata.folder_paths().clone(); - let roots_to_archive = { - let mut workspaces = self - .multi_workspace - .upgrade() - .map(|multi_workspace| { - multi_workspace - .read(cx) - .workspaces() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - for workspace in thread_worktree_archive::all_open_workspaces(cx) { - if !workspaces.contains(&workspace) { - workspaces.push(workspace); - } - } + let roots_to_archive = self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + cx, + ); - metadata - .folder_paths() - .ordered_paths() - .filter_map(|path| { - thread_worktree_archive::build_root_plan( - path, - metadata.remote_connection.as_ref(), - &workspaces, - cx, - ) - }) - .filter(|plan| { - let store = ThreadMetadataStore::global(cx); - let store = store.read(cx); - !store.path_is_referenced_by_unarchived_threads( - None, - &plan.root_path, - metadata.remote_connection.as_ref(), - ) - }) - .filter(|root| { - TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { - !terminal_store.read(cx).path_is_referenced_by_terminal( - Some(terminal_id), - root.root_path.as_path(), - metadata.remote_connection.as_ref(), - ) - }) - }) - .collect::>() - }; - - let workspace_to_remove = if terminal_folder_paths.is_empty() { - None - } else { - let remaining = ThreadMetadataStore::global(cx) - .read(cx) - .entries_for_path(&terminal_folder_paths, metadata.remote_connection.as_ref()) - .count(); - - if remaining > 0 { - None - } else { - let workspace = self.multi_workspace.upgrade().and_then(|multi_workspace| { - multi_workspace - .read(cx) - .workspace_for_paths(&terminal_folder_paths, None, cx) - }); - - workspace.and_then(|workspace| { - if workspace_has_terminal_metadata_except(&workspace, Some(terminal_id), cx) { - return None; - } - - let group_key = workspace.read(cx).project_group_key(cx); - (group_key.path_list() != &terminal_folder_paths).then_some(workspace) - }) - } - }; + let workspace_to_remove = self.linked_worktree_workspace_to_remove( + &terminal_folder_paths, + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + &roots_to_archive, + cx, + ); let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); @@ -3966,6 +4293,13 @@ impl Sidebar { } this.update_in(cx, |this, window, cx| { + if terminal_workspace_removed { + this.delete_empty_drafts_for_archive_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + cx, + ); + } // If the terminal's workspace has already been removed, // don't synthesize a fallback draft in the detached // AgentPanel. @@ -4156,33 +4490,44 @@ impl Sidebar { ) { let store = ThreadMetadataStore::global(cx); let metadata = store.read(cx).entry_by_session(session_id).cloned(); - let active_workspace = metadata.as_ref().and_then(|metadata| { + let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); + let thread_entry = self.contents.entries.iter().find_map(|entry| match entry { + ListEntry::Thread(thread) => metadata_thread_id + .map_or_else( + || thread.metadata.session_id.as_ref() == Some(session_id), + |thread_id| thread.metadata.thread_id == thread_id, + ) + .then(|| thread.clone()), + _ => None, + }); + let thread_id = metadata_thread_id.or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.thread_id) + }); + let active_workspace = thread_id.and_then(|thread_id| { self.active_entry.as_ref().and_then(|entry| { - if entry.is_active_thread(&metadata.thread_id) { + if entry.is_active_thread(&thread_id) { Some(entry.workspace().clone()) } else { None } }) }); - let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); let thread_folder_paths = metadata .as_ref() .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.folder_paths().clone()) + }) .or_else(|| { active_workspace .as_ref() .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) }); - let thread_entry_workspace = self.contents.entries.iter().find_map(|entry| match entry { - ListEntry::Thread(thread) => thread_id - .map_or_else( - || thread.metadata.session_id.as_ref() == Some(session_id), - |tid| thread.metadata.thread_id == tid, - ) - .then(|| thread.workspace.clone()), - _ => None, - }); + let thread_entry_workspace = thread_entry.map(|thread| thread.workspace); if let ( Some(metadata), @@ -4191,7 +4536,7 @@ impl Sidebar { project_group_key, }), ) = (metadata.as_ref(), thread_entry_workspace) - && Self::should_load_closed_workspace_for_archive( + && self.should_load_closed_workspace_for_archive( &folder_paths, &project_group_key, metadata.remote_connection.as_ref(), @@ -4218,52 +4563,13 @@ impl Sidebar { let roots_to_archive = metadata .as_ref() .map(|metadata| { - let mut workspaces = self - .multi_workspace - .upgrade() - .map(|multi_workspace| { - multi_workspace - .read(cx) - .workspaces() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - for workspace in thread_worktree_archive::all_open_workspaces(cx) { - if !workspaces.contains(&workspace) { - workspaces.push(workspace); - } - } - metadata - .folder_paths() - .ordered_paths() - .filter_map(|path| { - thread_worktree_archive::build_root_plan( - path, - metadata.remote_connection.as_ref(), - &workspaces, - cx, - ) - }) - .filter(|plan| { - thread_id.map_or(true, |tid| { - !store.read(cx).path_is_referenced_by_unarchived_threads( - Some(tid), - &plan.root_path, - metadata.remote_connection.as_ref(), - ) - }) - }) - .filter(|root| { - TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { - !terminal_store.read(cx).path_is_referenced_by_terminal( - None, - root.root_path.as_path(), - metadata.remote_connection.as_ref(), - ) - }) - }) - .collect::>() + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + thread_id, + None, + cx, + ) }) .unwrap_or_default(); @@ -4280,35 +4586,16 @@ impl Sidebar { // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| { - if folder_paths.is_empty() { - return None; - } - let thread_remote_connection = metadata.as_ref().and_then(|m| m.remote_connection.as_ref()); - let remaining = ThreadMetadataStore::global(cx) - .read(cx) - .entries_for_path(folder_paths, thread_remote_connection) - .filter(|t| t.session_id.as_ref() != Some(session_id)) - .count(); - - if remaining > 0 { - return None; - } - - let multi_workspace = self.multi_workspace.upgrade()?; - let workspace = multi_workspace - .read(cx) - .workspace_for_paths(folder_paths, None, cx)?; - - if workspace_has_terminal_metadata(&workspace, cx) { - return None; - } - - let group_key = workspace.read(cx).project_group_key(cx); - let is_linked_worktree = group_key.path_list() != folder_paths; - - is_linked_worktree.then_some(workspace) + self.linked_worktree_workspace_to_remove( + folder_paths, + thread_remote_connection, + thread_id, + None, + &roots_to_archive, + cx, + ) }); // Also find workspaces for root plans that aren't covered by @@ -4370,6 +4657,9 @@ impl Sidebar { }); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); @@ -4381,6 +4671,13 @@ impl Sidebar { } this.update_in(cx, |this, window, cx| { + if let Some(thread_folder_paths) = thread_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + thread_folder_paths, + thread_remote_connection.as_ref(), + cx, + ); + } let in_flight = thread_id.and_then(|tid| { this.start_archive_worktree_task(tid, roots_to_archive, cx) }); @@ -4389,6 +4686,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -4400,6 +4698,9 @@ impl Sidebar { } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; @@ -4415,6 +4716,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -4431,6 +4733,9 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), in_flight, window, cx, @@ -4460,6 +4765,7 @@ impl Sidebar { thread_id: Option, neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, + thread_remote_connection: Option<&RemoteConnectionOptions>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, cx: &mut Context, @@ -4484,11 +4790,10 @@ impl Sidebar { // archived thread from its workspace's panel so that switching // to that workspace later doesn't show a stale thread. if let Some(folder_paths) = thread_folder_paths { - if let Some(workspace) = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)) - { + if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }) { if let Some(panel) = workspace.read(cx).panel::(cx) { let panel_shows_archived = panel .read(cx) @@ -4515,10 +4820,10 @@ impl Sidebar { // No neighbor or its workspace isn't open — just clear the // panel so the group is left empty. if let Some(folder_paths) = thread_folder_paths { - let workspace = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)); + let workspace = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }); if let Some(workspace) = workspace { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { @@ -4539,6 +4844,8 @@ impl Sidebar { return None; } + self.delete_empty_drafts_for_archive_roots(&roots, cx); + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); let task = cx.spawn(async move |_this, cx| { match Self::archive_worktree_roots(roots, cancel_rx, cx).await { @@ -4573,6 +4880,8 @@ impl Sidebar { return; } + self.delete_empty_drafts_for_archive_roots(&roots, cx); + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); cx.spawn(async move |_this, cx| { let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await; @@ -4580,7 +4889,7 @@ impl Sidebar { match outcome { Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {} Err(error) => { - log::error!("Failed to archive worktree after closing terminal: {error:#}"); + log::error!("Failed to archive worktree after closing sidebar item: {error:#}"); } } }) @@ -4669,11 +4978,9 @@ impl Sidebar { AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } if thread.is_draft { - if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace { - let workspace = workspace.clone(); - let draft_id = thread.metadata.thread_id; - self.remove_draft(draft_id, &workspace, window, cx); - } + let workspace = thread.workspace.clone(); + let draft_id = thread.metadata.thread_id; + self.remove_draft(draft_id, &workspace, window, cx); } else if let Some(session_id) = thread.metadata.session_id.clone() { self.archive_thread(&session_id, window, cx); } @@ -5226,9 +5533,12 @@ impl Sidebar { .on_click({ let thread_workspace = thread_workspace.clone(); cx.listener(move |this, _, window, cx| { - if let ThreadEntryWorkspace::Open(workspace) = &thread_workspace { - this.remove_draft(thread_id_for_actions, workspace, window, cx); - } + this.remove_draft( + thread_id_for_actions, + &thread_workspace, + window, + cx, + ); }) }), ) @@ -5416,37 +5726,334 @@ impl Sidebar { if let Some(workspace) = self.workspace_for_group(&key, cx) { self.create_new_entry(&workspace, window, cx); } else { - self.open_workspace_and_create_entry(&key, window, cx); + self.open_workspace_and_create_entry( + &key, + NewEntryTarget::LastCreatedKind, + window, + cx, + ); } } else if let Some(workspace) = self.active_workspace(cx) { self.create_new_entry(&workspace, window, cx); } } - /// Deletes a parked draft thread (its metadata row, any kvp-stored - /// draft prompt) and promotes a sibling in the same group, if any, to - /// the active entry. - fn remove_draft( + fn new_terminal_thread( &mut self, - draft_id: ThreadId, - workspace: &Entity, + _: &NewTerminalThread, window: &mut Window, cx: &mut Context, ) { - workspace.update(cx, |ws, cx| { - if let Some(panel) = ws.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.remove_thread(draft_id, window, cx); - }); + cx.stop_propagation(); + + if let Some(key) = self.selected_group_key() { + self.set_group_expanded(&key, true, cx); + self.selection = None; + if let Some(workspace) = self.workspace_for_group(&key, cx) { + self.create_new_terminal(&workspace, window, cx); + } else { + self.open_workspace_and_create_entry(&key, NewEntryTarget::Terminal, window, cx); } - }); + } else if let Some(workspace) = self.active_workspace(cx) { + self.create_new_terminal(&workspace, window, cx); + } + } + + /// Closed linked-worktree drafts need an open workspace so archive root + /// planning can inspect repositories before deleting the worktree. + fn open_workspace_and_remove_draft( + &mut self, + draft_id: ThreadId, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.remove_draft(draft_id, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn remove_draft( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + let metadata = ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .cloned(); + + if let ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } = workspace + && self.should_load_closed_workspace_for_archive( + folder_paths, + project_group_key, + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), + Some(draft_id), + None, + cx, + ) + { + self.open_workspace_and_remove_draft( + draft_id, + folder_paths.clone(), + project_group_key.clone(), + window, + cx, + ); + return; + } + + let draft_folder_paths = metadata + .as_ref() + .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| match workspace { + ThreadEntryWorkspace::Open(workspace) => { + Some(PathList::new(&workspace.read(cx).root_paths(cx))) + } + ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()), + }); + let draft_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); + let roots_to_archive = metadata + .as_ref() + .map(|metadata| { + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + Some(draft_id), + None, + cx, + ) + }) + .unwrap_or_default(); let was_active = self .active_entry .as_ref() - .is_some_and(|e| e.is_active_thread(&draft_id)); + .is_some_and(|entry| entry.is_active_thread(&draft_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .and_then(|position| self.neighboring_activatable_entry(position)); + + let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| { + self.linked_worktree_workspace_to_remove( + folder_paths, + draft_remote_connection.as_ref(), + Some(draft_id), + None, + &roots_to_archive, + cx, + ) + }); + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + + if !workspaces_to_remove.is_empty() { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let draft_workspace_removed = matches!( + workspace, + ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) + ); + let (fallback_paths, project_group_key) = neighbor + .as_ref() + .map(|neighbor| neighbor.project_location(cx)) + .unwrap_or_else(|| { + workspaces_to_remove + .first() + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); + (key.path_list().clone(), key) + }) + .unwrap_or_default() + }); + + let excluded = workspaces_to_remove.clone(); + let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove( + workspaces_to_remove, + move |this, window, cx| { + let active_workspace = this.workspace().clone(); + this.find_or_create_workspace( + fallback_paths, + project_group_key.host(), + Some(project_group_key), + |options, window, cx| { + connect_remote(active_workspace, options, window, cx) + }, + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) + }); + + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + if !remove_task.await? { + return anyhow::Ok(()); + } + + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + if draft_workspace_removed { + if let Some(draft_folder_paths) = draft_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + draft_folder_paths, + draft_remote_connection.as_ref(), + cx, + ); + } + } + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + !draft_workspace_removed, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else if !close_item_tasks.is_empty() { + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + self.remove_draft_entry( + draft_id, + workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + } + } + + fn remove_draft_entry( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + was_active: bool, + neighbor: Option<&ActivatableEntry>, + activate_panel_draft: bool, + roots_to_archive: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if activate_panel_draft { + panel.remove_thread(draft_id, window, cx); + } else { + panel.remove_thread_without_activating_draft(draft_id, window, cx); + } + }); + true + } else { + false + } + }) + } else { + false + }; + + if !removed_from_panel { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete(draft_id, cx); + }); + } + + self.start_detached_archive_worktree_task(roots_to_archive, cx); + if was_active { self.active_entry = None; + if !activate_panel_draft { + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } } self.update_entries(cx); @@ -6361,6 +6968,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::archive_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) + .on_action(cx.listener(Self::new_terminal_thread)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) .on_action(cx.listener(Self::on_toggle_thread_switcher)) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index cb385828dc6..a7a7263daa8 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -95,6 +95,34 @@ fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id))) } +#[track_caller] +fn assert_project_header_has_threads( + sidebar: &Entity, + project_name: &str, + expected_has_threads: bool, + cx: &mut gpui::VisualTestContext, +) { + sidebar.read_with(cx, |sidebar, _cx| { + let has_threads = sidebar.contents.entries.iter().find_map(|entry| { + if let ListEntry::ProjectHeader { + label, has_threads, .. + } = entry + && label.as_ref() == project_name + { + Some(*has_threads) + } else { + None + } + }); + + assert_eq!( + has_threads, + Some(expected_has_threads), + "expected project header `{project_name}` to have has_threads={expected_has_threads}, got {has_threads:?}" + ); + }); +} + #[track_caller] fn assert_remote_project_integration_sidebar_state( sidebar: &mut Sidebar, @@ -426,6 +454,34 @@ fn save_thread_metadata_with_main_paths( cx.run_until_parked(); } +fn save_draft_metadata_with_main_paths( + title: Option, + folder_paths: PathList, + main_worktree_paths: PathList, + updated_at: DateTime, + cx: &mut TestAppContext, +) -> ThreadId { + let thread_id = ThreadId::new(); + let metadata = ThreadMetadata { + thread_id, + session_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + title_override: None, + updated_at, + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(), + archived: false, + remote_connection: None, + }; + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + thread_id +} + fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); @@ -882,6 +938,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, + has_notifications: false, is_active: true, has_threads: true, }, @@ -1028,6 +1085,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, + has_notifications: false, is_active: false, has_threads: false, }, @@ -1540,6 +1598,70 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); } +#[gpui::test] +async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_project_header_has_threads(&sidebar, "my-project", true, cx); + + let (terminal_metadata, terminal_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id => { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("terminal should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&terminal_metadata, &terminal_workspace, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!(!panel.has_terminal(terminal_id)); + assert!( + panel.active_view_is_new_draft(cx), + "closing the active terminal should leave the panel on a hidden empty draft" + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let project_group_key = multi_workspace.read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.toggle_collapse(&project_group_key, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); +} + #[gpui::test] async fn test_agent_panel_terminal_metadata_remains_visible_after_panel_is_removed( cx: &mut TestAppContext, @@ -1792,6 +1914,8 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) }); let worktree_panel = add_agent_panel(&worktree_workspace, cx); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); let archived_session_id = acp::SessionId::new(Arc::from("archived-wt-thread")); save_thread_metadata( @@ -1824,6 +1948,19 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace &main_project, cx, ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); let terminal_id = worktree_panel .update_in(cx, |panel, window, cx| { @@ -1864,12 +2001,20 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace terminal_metadata_deleted, "terminal metadata should be deleted after close" ); - let unarchived_worktree_threads = cx.update(|_, cx| { - let worktree_path_list = - PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let empty_draft_metadata_deleted = cx.update(|_, cx| { ThreadMetadataStore::global(cx) .read(cx) - .entries_for_path(&worktree_path_list, None) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) .count() }); assert_eq!( @@ -1895,6 +2040,672 @@ async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace ); } +#[gpui::test] +async fn test_terminal_close_event_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = + project::Project::test(fs.clone(), ["/external-worktree".as_ref()], cx).await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last terminal" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_keeps_linked_worktree_workspace_with_live_editor_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + worktree_panel.update_in(cx, |panel, window, cx| { + panel.load_agent_thread( + Agent::Stub, + draft_id, + Some(worktree_folder_paths.clone()), + None, + false, + AgentThreadSource::AgentPanel, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor_text = + worktree_panel.read_with(cx, |panel, cx| panel.editor_text_if_in_memory(draft_id, cx)); + assert_eq!( + editor_text, + Some(None), + "draft should be in memory with empty editor text before editing" + ); + + let message_editor = worktree_panel.read_with(cx, |panel, cx| { + panel + .active_thread_view(cx) + .expect("draft should be loaded in the agent panel") + .read(cx) + .message_editor + .clone() + }); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("keep this draft", window, cx); + }); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + let live_blocks = worktree_panel.read_with(cx, |panel, cx| { + panel.draft_prompt_blocks_if_in_memory(draft_id, cx) + }); + assert!( + matches!( + live_blocks.as_deref(), + Some([acp::ContentBlock::Text(text)]) if text.text == "keep this draft" + ), + "edited draft should still be readable from the panel after opening the terminal" + ); + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 2, + "should start with main and linked worktree workspaces" + ); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after close" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) + .count() + }); + assert_eq!( + unarchived_worktree_threads, 1, + "edited draft should remain as a worktree thread reference" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should stay open while an edited draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain on disk while an edited draft references it" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_linked_worktree_after_last_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let first_draft_id = save_draft_metadata_with_main_paths( + Some("First Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + let second_draft_id = save_draft_metadata_with_main_paths( + Some("Second Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 4, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + first_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "first draft", + ))], + cx, + ) + }) + .await + .expect("first draft prompt should persist"); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + second_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "second draft", + ))], + cx, + ) + }) + .await + .expect("second draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let first_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == first_draft_id + ) + }) + .expect("first draft should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(first_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..4 { + cx.run_until_parked(); + } + + let first_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(first_draft_id) + .is_none() + }); + assert!( + first_draft_metadata_deleted, + "first discarded draft metadata should be deleted" + ); + let second_draft_metadata_kept = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_some() + }); + assert!( + second_draft_metadata_kept, + "remaining contentful draft should still block worktree archival" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should remain while another draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain while another draft references it" + ); + + let second_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == second_draft_id + ) + }) + .expect("second draft should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(second_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let second_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_none() + }); + assert!( + second_draft_metadata_deleted, + "last discarded draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last draft" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its last draft" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Closed Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "closed draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("closed worktree draft should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[draft_index] { + ListEntry::Thread(thread) => match &thread.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree draft should start closed") + } + }, + _ => panic!("expected draft row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded closed worktree draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after discarding its last draft" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "discarding a closed linked worktree draft should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after discarding its last draft" + ); +} + #[gpui::test] async fn test_terminal_close_event_closes_sidebar_terminal(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -2367,6 +3178,19 @@ async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut store.save(metadata, cx); }); }); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); cx.run_until_parked(); @@ -2411,6 +3235,16 @@ async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut terminal_metadata_deleted, "terminal metadata should be deleted after closing from the sidebar" ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); assert!( multi_workspace .read_with(cx, |multi_workspace, cx| { @@ -2504,6 +3338,19 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T &main_project, cx, ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); cx.run_until_parked(); @@ -2549,6 +3396,16 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T Some(true), "thread metadata should remain archived after worktree archival" ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); assert!( multi_workspace .read_with(cx, |multi_workspace, cx| { @@ -2571,6 +3428,127 @@ async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut T ); } +#[gpui::test] +async fn test_archive_selected_thread_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("external-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + save_thread_metadata_with_main_paths( + "external-worktree-thread", + "External Worktree Thread", + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let thread_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&worktree_session_id))) + .expect("worktree thread should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(thread_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let thread_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&worktree_session_id) + .map(|thread| thread.archived) + }); + assert_eq!( + thread_archived, + Some(true), + "thread metadata should remain archived after workspace removal" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after archiving its last thread" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + #[gpui::test] async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( cx: &mut TestAppContext, @@ -12380,6 +13358,215 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m ); } +#[gpui::test] +async fn test_discard_mixed_workspace_draft_closes_only_archived_worktree_items( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/main-repo", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": { + "lib.rs": "pub fn hello() {}", + }, + }), + ) + .await; + + fs.insert_tree( + "/worktrees/main-repo/feature-b/main-repo", + serde_json::json!({ + ".git": "gitdir: /main-repo/.git/worktrees/feature-b", + "src": { + "main.rs": "fn main() { hello(); }", + }, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/main-repo/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "def".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let mixed_project = project::Project::test( + fs.clone(), + [ + "/main-repo".as_ref(), + "/worktrees/main-repo/feature-b/main-repo".as_ref(), + ], + cx, + ) + .await; + + mixed_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_ids: Vec<(WorktreeId, Arc)> = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) + .collect() + }); + + let main_repo_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/main-repo")) + .map(|(id, _)| *id) + .expect("should find main-repo worktree"); + + let feature_b_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo")) + .map(|(id, _)| *id) + .expect("should find feature-b worktree"); + + let main_repo_path = project::ProjectPath { + worktree_id: main_repo_worktree_id, + path: Arc::from(rel_path("src/lib.rs")), + }; + let feature_b_path = project::ProjectPath { + worktree_id: feature_b_worktree_id, + path: Arc::from(rel_path("src/main.rs")), + }; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(main_repo_path.clone(), None, true, window, cx) + }) + .await + .expect("should open main-repo file"); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(feature_b_path.clone(), None, true, window, cx) + }) + .await + .expect("should open feature-b file"); + + let folder_paths = PathList::new(&[ + PathBuf::from("/main-repo"), + PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ]); + let main_worktree_paths = + PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/main-repo")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Mixed Workspace Draft".into()), + folder_paths, + main_worktree_paths, + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "mixed workspace draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("mixed workspace draft should be visible") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "mixed workspace should be preserved" + ); + + let open_paths_after: Vec = workspace.read_with(cx, |workspace, cx| { + workspace + .panes() + .iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .filter_map(|item| item.project_path(cx)) + }) + .collect() + }); + assert!( + open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == main_repo_worktree_id), + "main-repo file should still be open" + ); + assert!( + !open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == feature_b_worktree_id), + "feature-b file should have been closed" + ); + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded draft metadata should be deleted" + ); +} + #[test] fn test_worktree_info_branch_names_for_main_worktrees() { let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]); @@ -12623,6 +13810,164 @@ async fn test_remote_archive_thread_with_active_connection( ); } +#[gpui::test] +async fn test_remote_linked_worktree_workspace_to_remove_uses_remote_connection( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + init_test(cx); + + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let app_state = cx.update(|cx| { + let app_state = workspace::AppState::test(cx); + workspace::init(app_state.clone(), cx); + app_state + }); + + let server_fs = FakeFs::new(server_cx.executor()); + server_fs + .insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + server_fs + .insert_tree( + "/external-worktree", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + server_fs.set_branch_name(Path::new("/project/.git"), Some("main")); + server_fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + server_fs + .add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let (worktree_project, _headless, remote_connection) = start_remote_project( + &server_fs, + Path::new("/external-worktree"), + &app_state, + None, + cx, + server_cx, + ) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + cx.update(|cx| ::set_global(app_state.fs.clone(), cx)); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(worktree_project.clone(), window, cx) + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("remote-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let main_folder_paths = PathList::new(&[PathBuf::from("/project")]); + let worktree_thread_id = ThreadId::new(); + cx.update(|_window, cx| { + let metadata = ThreadMetadata { + thread_id: worktree_thread_id, + session_id: Some(worktree_session_id.clone()), + agent_id: agent::ZED_AGENT_ID.clone(), + title: Some("Remote Worktree Thread".into()), + title_override: None, + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists( + main_folder_paths, + worktree_folder_paths.clone(), + ) + .unwrap(), + archived: false, + remote_connection: Some(remote_connection.clone()), + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths( + &worktree_folder_paths, + Some(&remote_connection), + cx, + ) + }) + .is_some(), + "remote linked-worktree workspace should be open before archiving" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "the test must exercise a remote-only workspace lookup" + ); + assert_ne!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }) + .path_list(), + &worktree_folder_paths, + "remote workspace must be classified as a linked worktree under the main project" + ); + + let workspace_to_remove = sidebar.read_with(cx, |sidebar, cx| { + sidebar + .linked_worktree_workspace_to_remove( + &worktree_folder_paths, + Some(&remote_connection), + Some(worktree_thread_id), + None, + &[], + cx, + ) + .map(|workspace| workspace.entity_id()) + }); + let active_workspace_id = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().entity_id() + }); + assert_eq!( + workspace_to_remove, + Some(active_workspace_id), + "archive helper should resolve the remote linked-worktree workspace" + ); + assert!( + server_fs.is_dir(Path::new("/external-worktree")).await, + "direct helper check should not remove the linked worktree from disk" + ); +} + #[gpui::test] async fn test_remote_archive_thread_with_disconnected_remote( cx: &mut TestAppContext, diff --git a/crates/skill_creator/Cargo.toml b/crates/skill_creator/Cargo.toml new file mode 100644 index 00000000000..2ffd7bb85df --- /dev/null +++ b/crates/skill_creator/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "skill_creator" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/skill_creator.rs" + +[dependencies] +agent_skills.workspace = true +anyhow.workspace = true +editor.workspace = true +fs.workspace = true +gpui.workspace = true +language.workspace = true +menu.workspace = true +platform_title_bar.workspace = true +release_channel.workspace = true +serde_yaml_ng.workspace = true +settings.workspace = true +theme_settings.workspace = true +ui.workspace = true +ui_input.workspace = true +util.workspace = true +workspace.workspace = true +worktree.workspace = true + +[dev-dependencies] +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +serde_json.workspace = true diff --git a/crates/skill_creator/LICENSE-GPL b/crates/skill_creator/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/skill_creator/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs new file mode 100644 index 00000000000..e95fe876ef3 --- /dev/null +++ b/crates/skill_creator/src/skill_creator.rs @@ -0,0 +1,1078 @@ +use agent_skills::{ + AGENTS_DIR_NAME, GLOBAL_SKILLS_DIR_DISPLAY, SKILL_FILE_NAME, SKILLS_DIR_NAME, SkillMetadata, + global_skills_dir, validate_description, validate_name, +}; +use anyhow::{Context as _, Result}; +use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle}; +use fs::Fs; +use gpui::{ + App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, FocusHandle, Focusable, Subscription, + Task, TextStyle, Tiling, TitlebarOptions, WeakEntity, WindowBounds, WindowHandle, + WindowOptions, actions, point, +}; +use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; +use platform_title_bar::PlatformTitleBar; +use release_channel::ReleaseChannel; +use settings::{ActionSequence, Settings}; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use theme_settings::ThemeSettings; +use ui::{ + ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, + prelude::*, +}; +use ui_input::{ErasedEditorEvent, InputField}; +use util::ResultExt; +use workspace::{ + Toast, Workspace, WorkspaceSettings, client_side_decorations, notifications::NotificationId, +}; +use worktree::WorktreeId; + +actions!( + skill_creator, + [SaveSkill, Cancel, FocusNextField, FocusPreviousField,] +); + +const NAME_FIELD_TAB_INDEX: isize = 1; +const DESCRIPTION_FIELD_TAB_INDEX: isize = 2; +const DISABLE_MODEL_INVOCATION_TAB_INDEX: isize = 3; +const SCOPE_FIELD_TAB_INDEX: isize = 4; +const BODY_FIELD_TAB_INDEX: isize = 5; + +pub fn init(_cx: &mut App) {} + +#[derive(Clone, Debug)] +enum ScopeChoice { + Global, + Project { + worktree_id: WorktreeId, + root_name: SharedString, + abs_path: Arc, + }, +} + +impl ScopeChoice { + fn label(&self) -> SharedString { + match self { + ScopeChoice::Global => "Global".into(), + ScopeChoice::Project { root_name, .. } => root_name.clone(), + } + } + + fn key(&self) -> SharedString { + match self { + ScopeChoice::Global => "global".into(), + ScopeChoice::Project { worktree_id, .. } => { + SharedString::from(format!("project-{}", worktree_id.to_usize())) + } + } + } + + /// Absolute path of the `.agents/skills` directory this scope writes to. + fn skills_dir(&self) -> PathBuf { + match self { + ScopeChoice::Global => global_skills_dir(), + ScopeChoice::Project { abs_path, .. } => { + abs_path.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + } + } + } +} + +/// Collect the user-visible worktrees from the originating workspace and +/// turn them into project-scope choices. Returns an empty `Vec` if the +/// workspace can't be read (e.g. it was already dropped). +fn project_scopes_from_workspace( + workspace: &Option>, + cx: &App, +) -> Vec { + let Some(workspace) = workspace.as_ref().and_then(|w| w.upgrade()) else { + return Vec::new(); + }; + let workspace = workspace.read(cx); + let root_paths = workspace.root_paths(cx); + workspace + .visible_worktrees(cx) + .zip(root_paths) + .map(|(worktree, abs_path)| { + let worktree = worktree.read(cx); + ScopeChoice::Project { + worktree_id: worktree.id(), + root_name: SharedString::from(worktree.root_name_str().to_string()), + abs_path, + } + }) + .collect() +} + +/// Open the skills library window. If one is already open, brings it to the +/// foreground. +pub fn open_skill_creator( + workspace: Option>, + language_registry: Arc, + fs: Arc, + on_saved: Option>, + cx: &mut App, +) -> Task>> { + cx.spawn(async move |cx| { + let existing = cx.update(|cx| { + let handle = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()); + if let Some(handle) = handle { + handle + .update(cx, |_, window, _| window.activate_window()) + .ok(); + Some(handle) + } else { + None + } + }); + if let Some(window) = existing { + return Ok(window); + } + + cx.update(|cx| { + let app_id = ReleaseChannel::global(cx).app_id(); + let bounds = Bounds::centered(None, DEFAULT_ADDITIONAL_WINDOW_SIZE, cx); + let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { + Ok(val) if val == "server" => gpui::WindowDecorations::Server, + Ok(val) if val == "client" => gpui::WindowDecorations::Client, + _ => match WorkspaceSettings::get_global(cx).window_decorations { + settings::WindowDecorations::Server => gpui::WindowDecorations::Server, + settings::WindowDecorations::Client => gpui::WindowDecorations::Client, + }, + }; + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions { + title: Some("New Skill".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(12.0), px(12.0))), + }), + app_id: Some(app_id.to_owned()), + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_background: cx.theme().window_background_appearance(), + window_decorations: Some(window_decorations), + window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE), + kind: gpui::WindowKind::Floating, + ..Default::default() + }, + |window, cx| { + cx.new(|cx| { + SkillCreator::new(workspace, language_registry, fs, on_saved, window, cx) + }) + }, + ) + }) + }) +} + +pub struct SkillCreator { + focus_handle: FocusHandle, + title_bar: Option>, + workspace: Option>, + fs: Arc, + on_saved: Option>, + name_editor: Entity, + description_editor: Entity, + body_editor: Entity, + description_length: usize, + scopes: Vec, + selected_scope_key: SharedString, + disable_model_invocation: bool, + name_error: Option<&'static str>, + description_error: Option<&'static str>, + body_error: Option<&'static str>, + save_error: Option, + saving: bool, + // Held so that dropping the entity (e.g. the window closing) cancels + // an in-flight save. Detaching the task instead would let + // `write_skill_to_disk` complete after the UI is gone, silently + // creating a SKILL.md on disk with no toast and no error feedback. + save_task: Option>, + _subscriptions: Vec, +} + +impl SkillCreator { + fn new( + workspace: Option>, + language_registry: Arc, + fs: Arc, + on_saved: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let project_scopes = project_scopes_from_workspace(&workspace, cx); + + // Default to first project scope (project-level) when available; + // otherwise fall back to Global. + let mut scopes: Vec = Vec::with_capacity(project_scopes.len() + 1); + scopes.push(ScopeChoice::Global); + scopes.extend(project_scopes); + let selected_scope_key = scopes + .iter() + .find(|scope| matches!(scope, ScopeChoice::Project { .. })) + .map(|scope| scope.key()) + .unwrap_or_else(|| ScopeChoice::Global.key()); + + let name_editor = cx.new(|cx| { + InputField::new(window, cx, "my-new-skill") + .label("Name") + .tab_index(NAME_FIELD_TAB_INDEX) + .tab_stop(true) + }); + // Focus the name field on open. Without this, no element inside + // the window has focus, so dispatching the `Cancel` action from + // the Cancel button (which walks the focused element's dispatch + // path looking for `on_action` handlers) silently does nothing + // until the user manually clicks into one of the editors. The + // name editor is also the natural first field to type into. + window.focus(&name_editor.focus_handle(cx), cx); + + let description_editor = cx.new(|cx| { + InputField::new( + window, + cx, + "e.g., Fill the PR description following this template.", + ) + .label("Description") + .tab_index(DESCRIPTION_FIELD_TAB_INDEX) + .tab_stop(true) + }); + + let body_editor = cx.new(|cx| { + let buffer = cx.new(|cx| { + let buffer = Buffer::local(String::new(), cx); + buffer.set_language_registry(language_registry.clone()); + buffer + }); + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_placeholder_text("Add skill content…", window, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_use_modal_editing(true); + editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); + editor + }); + + // Attach Markdown language to the body editor asynchronously, since + // `language_for_name` returns a Task. + cx.spawn_in(window, { + let body_editor = body_editor.downgrade(); + let language_registry = language_registry.clone(); + async move |_this, cx| { + let markdown = language_registry.language_for_name("Markdown").await.ok(); + if let Some(markdown) = markdown { + body_editor + .update(cx, |editor, cx| { + editor.buffer().update(cx, |multi_buffer, cx| { + if let Some(buffer) = multi_buffer.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown), cx) + }); + } + }); + }) + .ok(); + } + } + }) + .detach(); + + let name_input_editor = name_editor.read(cx).editor().clone(); + let description_input_editor = description_editor.read(cx).editor().clone(); + let weak = cx.weak_entity(); + let name_subscription = name_input_editor.subscribe( + Box::new(move |event, window, cx| { + weak.update(cx, |this, cx| { + this.handle_name_input_event(&event, window, cx); + }) + .ok(); + }), + window, + cx, + ); + let weak = cx.weak_entity(); + let description_subscription = description_input_editor.subscribe( + Box::new(move |event, window, cx| { + weak.update(cx, |this, cx| { + this.handle_description_input_event(&event, window, cx); + }) + .ok(); + }), + window, + cx, + ); + + let subscriptions = vec![ + name_subscription, + description_subscription, + cx.subscribe_in(&body_editor, window, Self::handle_body_editor_event), + ]; + + Self { + focus_handle, + title_bar: if !cfg!(target_os = "macos") { + Some(cx.new(|cx| PlatformTitleBar::new("skill-creator-title-bar", cx))) + } else { + None + }, + workspace, + fs, + on_saved, + name_editor, + description_editor, + body_editor, + description_length: 0, + scopes, + selected_scope_key, + disable_model_invocation: false, + name_error: None, + description_error: None, + body_error: None, + save_error: None, + saving: false, + save_task: None, + _subscriptions: subscriptions, + } + } + + fn handle_name_input_event( + &mut self, + event: &ErasedEditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, ErasedEditorEvent::BufferEdited) { + self.recompute_name_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn handle_description_input_event( + &mut self, + event: &ErasedEditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, ErasedEditorEvent::BufferEdited) { + self.recompute_description_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn handle_body_editor_event( + &mut self, + _: &Entity, + event: &EditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, EditorEvent::BufferEdited) { + self.recompute_body_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn current_name(&self, cx: &App) -> String { + self.name_editor.read(cx).text(cx) + } + + fn current_description(&self, cx: &App) -> String { + self.description_editor.read(cx).text(cx) + } + + fn current_body(&self, cx: &App) -> String { + self.body_editor.read(cx).text(cx) + } + + fn recompute_name_error(&mut self, cx: &App) { + let name = self.current_name(cx); + self.name_error = validate_name(&name).err(); + } + + fn recompute_description_error(&mut self, cx: &App) { + let description = self.current_description(cx); + self.description_length = description.len(); + self.description_error = validate_description(&description).err(); + } + + fn recompute_body_error(&mut self, cx: &App) { + let body = self.current_body(cx); + self.body_error = if body.trim().is_empty() { + Some("Body is required.") + } else { + None + }; + } + + fn is_valid(&self, cx: &App) -> bool { + validate_name(&self.current_name(cx)).is_ok() + && validate_description(&self.current_description(cx)).is_ok() + && !self.current_body(cx).trim().is_empty() + && self.selected_scope().is_some() + } + + fn selected_scope(&self) -> Option<&ScopeChoice> { + self.scopes + .iter() + .find(|scope| scope.key() == self.selected_scope_key) + } + + fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context) { + // Surface any field-level errors before attempting to save. + self.recompute_name_error(cx); + self.recompute_description_error(cx); + self.recompute_body_error(cx); + + if !self.is_valid(cx) || self.saving { + cx.notify(); + return; + } + + let Some(scope) = self.selected_scope().cloned() else { + self.save_error = Some("Select a scope to save this skill to.".into()); + cx.notify(); + return; + }; + + let name = self.current_name(cx); + let description = self.current_description(cx); + let body = self.current_body(cx); + let disable_model_invocation = self.disable_model_invocation; + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let scope_label = scope.label(); + + self.saving = true; + self.save_error = None; + cx.notify(); + + let task = cx.spawn_in(window, async move |this, cx| { + let result = write_skill_to_disk( + fs.as_ref(), + &scope.skills_dir(), + &name, + &description, + &body, + disable_model_invocation, + ) + .await; + + this.update_in(cx, |this, window, cx| { + this.saving = false; + this.save_task = None; + match result { + Ok(path) => { + if let Some(on_saved) = &this.on_saved { + on_saved(cx); + } + if let Some(workspace) = workspace.as_ref().and_then(|w| w.upgrade()) { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!( + "Saved skill \"{name}\" to {scope_label} ({})", + path.display() + ), + ), + cx, + ); + }); + } + window.remove_window(); + } + Err(err) => { + this.save_error = Some(SharedString::from(err.to_string())); + cx.notify(); + } + } + }) + .log_err(); + }); + self.save_task = Some(task); + } + + fn cancel(&mut self, _: &Cancel, window: &mut Window, _cx: &mut Context) { + // Block dismissal while a save is in flight. Otherwise the + // detached I/O could complete after the window is gone, leaving + // a SKILL.md on disk with no success or error feedback. The + // user can still force-close the window via the platform + // chrome, in which case dropping `self.save_task` cancels the + // pending write. + if self.saving { + return; + } + window.remove_window(); + } + + fn select_scope(&mut self, key: SharedString, cx: &mut Context) { + if self.scopes.iter().any(|scope| scope.key() == key) { + self.selected_scope_key = key; + self.save_error = None; + cx.notify(); + } + } + + fn toggle_disable_model_invocation(&mut self, cx: &mut Context) { + self.disable_model_invocation = !self.disable_model_invocation; + cx.notify(); + } + + fn render_scope_field(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scopes = self.scopes.clone(); + let selected = self.selected_scope().cloned(); + let selected_label: SharedString = match selected.as_ref() { + Some(ScopeChoice::Global) => "Global".into(), + Some(ScopeChoice::Project { root_name, .. }) => { + SharedString::from(format!("{root_name} (project)")) + } + None => "Select a scope\u{2026}".into(), + }; + let sep = std::path::MAIN_SEPARATOR; + let scope_hint: SharedString = match selected.as_ref() { + Some(ScopeChoice::Global) => SharedString::from(format!( + "Available across every Zed project. \ + Saved to {GLOBAL_SKILLS_DIR_DISPLAY}{sep}\u{2039}name\u{203A}{sep}{SKILL_FILE_NAME}." + )), + Some(ScopeChoice::Project { root_name, .. }) => SharedString::from(format!( + "Only available when this project is open. \ + Saved to {root_name}{sep}{AGENTS_DIR_NAME}{sep}{SKILLS_DIR_NAME}{sep}\u{2039}name\u{203A}{sep}{SKILL_FILE_NAME}." + )), + None => "Choose where this skill should live.".into(), + }; + + let selected_label = h_flex() + .child(Label::new(selected_label)) + .into_any_element(); + + let weak = cx.weak_entity(); + + let menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { + for scope in &scopes { + let key = scope.key(); + let weak = weak.clone(); + let entry_label: SharedString = match scope { + ScopeChoice::Global => "Global".into(), + ScopeChoice::Project { root_name, .. } => { + SharedString::from(format!("{root_name} (project)")) + } + }; + menu = menu.entry(entry_label, None, move |_window, cx| { + weak.update(cx, |this, cx| { + this.select_scope(key.clone(), cx); + }) + .log_err(); + }); + } + menu + }); + + h_flex() + .min_w_0() + .w_full() + .gap_6() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(Label::new("Scope")) + .child(Label::new(scope_hint).color(Color::Muted)), + ) + .child( + DropdownMenu::new_with_element("skill-scope-dropdown", selected_label, menu) + .tab_index(SCOPE_FIELD_TAB_INDEX) + .style(DropdownStyle::Outlined) + .trigger_size(ButtonSize::Medium) + .full_width(false), + ) + } + + fn render_optional_params(&self, cx: &mut Context) -> impl IntoElement { + let toggle_state: ToggleState = self.disable_model_invocation.into(); + + SwitchField::new( + "disable-model-invocation", + Some("Disable model invocation"), + Some( + "Hide this skill from the model's catalog. It can still be invoked via slash command." + .into(), + ), + toggle_state, + cx.listener(|this, _state: &ToggleState, _window, cx| { + this.toggle_disable_model_invocation(cx); + }), + ) + .tab_index(DISABLE_MODEL_INVOCATION_TAB_INDEX) + .into_any_element() + } + + fn render_body_field(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme().clone(); + + let has_error = self.body_error.is_some(); + + let focus_handle = self + .body_editor + .focus_handle(cx) + .tab_index(BODY_FIELD_TAB_INDEX) + .tab_stop(true); + + let border_color = if has_error { + theme.status().error_border + } else if focus_handle.contains_focused(window, cx) { + theme.colors().border_focused + } else { + theme.colors().border + }; + + div() + .w_full() + .flex_1() + .min_h(px(160.)) + .p_2p5() + .rounded_md() + .border_1() + .border_color(border_color) + .bg(theme.colors().editor_background) + .track_focus(&focus_handle) + .overflow_hidden() + .child(EditorElement::new( + &self.body_editor, + EditorStyle { + local_player: theme.players().local(), + text: TextStyle { + color: theme.colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + syntax: theme.syntax().clone(), + inlay_hints_style: editor::make_inlay_hints_style(cx), + edit_prediction_styles: editor::make_suggestion_styles(cx), + ..EditorStyle::default() + }, + )) + } + + fn render_action_bar(&self, cx: &mut Context) -> impl IntoElement { + let valid = self.is_valid(cx); + let saving = self.saving; + let main_action = if saving { "Saving…" } else { "Save Skill" }; + + h_flex() + .w_full() + .map(|this| { + if self.save_error.is_some() { + this.justify_between() + } else { + this.justify_end() + } + }) + .gap_2() + .children( + self.save_error + .clone() + .map(|err| Label::new(err).size(LabelSize::Small).color(Color::Error)), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel-skill", "Cancel") + .disabled(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Cancel), cx); + }), + ) + .child( + Button::new("save-skill", main_action) + .style(ButtonStyle::Filled) + .layer(ui::ElevationIndex::ModalSurface) + .disabled(!valid || saving) + .loading(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(SaveSkill), cx); + }), + ), + ) + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let needs_traffic_light_clearance = cfg!(target_os = "macos"); + + h_flex() + .w_full() + .h_10() + .px_4() + .when(needs_traffic_light_clearance, |this| this.pl(px(84.))) + .border_b_1() + .border_color(theme.colors().border) + .child(Headline::new("Skill Creator").size(HeadlineSize::XSmall)) + } + + fn focus_next_field( + &mut self, + _: &FocusNextField, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_next(cx); + } + + fn focus_previous_field( + &mut self, + _: &FocusPreviousField, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_prev(cx); + } + + // When focus is on a non-editor tab stop (dropdown button, switch), + // Tab dispatches the global `menu::SelectNext` rather than our + // custom `FocusNextField`. Catching it here keeps the cycle moving. + fn on_menu_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + } + + fn on_menu_prev( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_prev(cx); + } +} + +impl Focusable for SkillCreator { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SkillCreator { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme_settings::setup_ui_font(window, cx); + let theme = cx.theme().clone(); + + client_side_decorations( + v_flex() + .id("skill-creator") + .key_context("SkillCreator") + .track_focus(&self.focus_handle) + .on_action( + |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| { + for action in &action_sequence.0 { + window.dispatch_action(action.boxed_clone(), cx); + } + }, + ) + .on_action(cx.listener(Self::save_skill)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::focus_next_field)) + .on_action(cx.listener(Self::focus_previous_field)) + .on_action(cx.listener(Self::on_menu_next)) + .on_action(cx.listener(Self::on_menu_prev)) + .size_full() + .overflow_hidden() + .font(ui_font) + .text_color(theme.colors().text) + .bg(theme.colors().panel_background) + .children(self.title_bar.clone()) + .child(self.render_header(cx)) + .child( + v_flex() + .id("skill-creator-form") + .tab_index(0) + .tab_group() + .tab_stop(false) + .flex_1() + .min_h_0() + .gap_4() + .p_4() + .child( + v_flex() + .gap_2() + .child(Label::new("Front-matter")) + .child(self.name_editor.clone()) + .child(self.description_editor.clone()), + ) + .child(self.render_optional_params(cx)) + .child(Divider::horizontal()) + .child(self.render_scope_field(window, cx)) + .child(Divider::horizontal()) + .child( + v_flex() + .flex_1() + .gap_2() + .child(Label::new("Skill Content")) + .child(self.render_body_field(window, cx)), + ), + ) + .child( + h_flex() + .w_full() + .p_2p5() + .border_t_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().panel_background) + .child(self.render_action_bar(cx)), + ), + window, + cx, + Tiling::default(), + ) + } +} + +/// Serialize the SKILL.md file to disk at `//SKILL.md`. +/// +/// Refuses to overwrite an existing directory at `/`. The +/// caller surfaces the resulting error to the user, who picks a different +/// name. +async fn write_skill_to_disk( + fs: &dyn Fs, + skills_dir: &std::path::Path, + name: &str, + description: &str, + body: &str, + disable_model_invocation: bool, +) -> Result { + let skill_dir = skills_dir.join(name); + match fs.metadata(&skill_dir).await { + Ok(Some(metadata)) if metadata.is_dir => { + anyhow::bail!( + "A skill named \"{name}\" already exists at {}. Pick a different name.", + skill_dir.display() + ); + } + Ok(Some(_)) => { + // Something exists at this path, but it isn't a directory — e.g. + // a stray file the user (or another tool) left there. Without + // this branch we'd fall through to `create_dir`, which on the + // real fs returns a generic "File exists" IO error that gives + // the user no idea what's wrong or how to recover. + anyhow::bail!( + "A file (not a skill directory) already exists at {}. \ + Delete it or pick a different skill name.", + skill_dir.display() + ); + } + Ok(None) => {} + Err(err) => { + return Err(err).with_context(|| { + format!( + "failed to check whether {} already exists", + skill_dir.display() + ) + }); + } + } + + let content = format_skill_file(name, description, body, disable_model_invocation)?; + + fs.create_dir(&skill_dir) + .await + .with_context(|| format!("failed to create skill directory {}", skill_dir.display()))?; + let skill_file_path = skill_dir.join(SKILL_FILE_NAME); + fs.write(&skill_file_path, content.as_bytes()) + .await + .with_context(|| format!("failed to write {}", skill_file_path.display()))?; + + Ok(skill_file_path) +} + +fn format_skill_file( + name: &str, + description: &str, + body: &str, + disable_model_invocation: bool, +) -> Result { + let metadata = SkillMetadata { + name: name.to_string(), + description: description.to_string(), + disable_model_invocation, + }; + let frontmatter = serde_yaml_ng::to_string(&metadata) + .context("failed to serialize skill frontmatter as YAML")?; + + let mut content = String::with_capacity(frontmatter.len() + body.len() + 16); + content.push_str("---\n"); + content.push_str(&frontmatter); + content.push_str("---\n"); + let trimmed_body = body.trim(); + if !trimmed_body.is_empty() { + content.push('\n'); + content.push_str(trimmed_body); + content.push('\n'); + } + Ok(content) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_skills::{SkillSource, parse_skill_frontmatter}; + use fs::FakeFs; + use std::path::Path; + + // Name and description validation rules are unit-tested in + // `agent_skills`, which owns `validate_name` / `validate_description` + // / `MAX_SKILL_DESCRIPTION_LEN`. The tests below cover this crate's + // own surface area: SKILL.md formatting and disk-writing. + + #[test] + fn format_skill_file_round_trips_through_parser() { + let content = + format_skill_file("draft-pr", "Push a draft PR", "Do the thing.", false).unwrap(); + let skill = parse_skill_frontmatter( + Path::new("/skills/draft-pr/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("generated frontmatter must round-trip through parse_skill_frontmatter"); + assert_eq!(skill.name, "draft-pr"); + assert_eq!(skill.description, "Push a draft PR"); + assert!(!skill.disable_model_invocation); + } + + #[test] + fn format_skill_file_writes_disable_model_invocation_true() { + let content = format_skill_file("my-skill", "description", "body", true).unwrap(); + assert!(content.contains("disable-model-invocation: true")); + } + + #[test] + fn format_skill_file_omits_body_when_empty() { + let content = format_skill_file("my-skill", "description", " ", false).unwrap(); + // The trailing closing-delimiter newline is the last byte. + assert!(content.ends_with("---\n")); + } + + #[test] + fn format_skill_file_escapes_yaml_specials_in_description() { + // serde_yaml_ng must quote/escape descriptions that contain YAML + // specials so the file round-trips. If we ever swap formatters, + // this test will catch a regression. + let tricky = "contains: a colon, # a hash, and a \"quote\""; + let content = format_skill_file("weird-skill", tricky, "body", false).unwrap(); + let skill = parse_skill_frontmatter( + Path::new("/skills/weird-skill/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("YAML-special characters must round-trip"); + assert_eq!(skill.description, tricky); + } + + #[gpui::test] + async fn write_skill_to_disk_creates_directory_and_file(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/skills", serde_json::json!({})).await; + + let path = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect("write should succeed"); + + assert_eq!(path, Path::new("/skills/draft-pr/SKILL.md")); + let content = fs.load(&path).await.expect("file should exist"); + let skill = parse_skill_frontmatter(&path, &content, SkillSource::Global) + .expect("written file should be parseable"); + assert_eq!(skill.name, "draft-pr"); + assert_eq!(skill.description, "Push a draft PR"); + } + + #[gpui::test] + async fn write_skill_to_disk_refuses_to_overwrite(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/skills", + serde_json::json!({ + "draft-pr": { + "SKILL.md": "---\nname: draft-pr\ndescription: existing\n---\nbody\n" + } + }), + ) + .await; + + let err = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect_err("writing over an existing skill must fail"); + assert!( + err.to_string().contains("already exists"), + "error message should mention the conflict, got: {err}" + ); + } + + #[gpui::test] + async fn write_skill_to_disk_rejects_non_directory_at_skill_path( + cx: &mut gpui::TestAppContext, + ) { + let fs = FakeFs::new(cx.executor()); + // A *file* (not a directory) sitting at `/skills/draft-pr`. With the + // old `is_dir` check this slipped through and we ended up surfacing + // the underlying "File exists" OS error. + fs.insert_tree( + "/skills", + serde_json::json!({ "draft-pr": "i am a stray file" }), + ) + .await; + + let err = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect_err("writing where a file already lives must fail"); + let message = err.to_string(); + assert!( + message.contains("not a skill directory"), + "error should explain the conflict is a non-directory, got: {message}" + ); + // Path separator differs between platforms (`/` on Unix, `\` on + // Windows), so reconstruct the expected `Display` form rather than + // hard-coding a separator. + let expected_path = Path::new("/skills").join("draft-pr"); + let expected_path = expected_path.display().to_string(); + assert!( + message.contains(&expected_path), + "error should include the conflicting path {expected_path:?}, got: {message}" + ); + } +} diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 4e16d69e405..96908479afe 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -192,8 +192,9 @@ impl PtyProcessInfo { } let this = self.clone(); let has_changed = cx.background_executor().spawn(async move { + let previous = this.current.read().clone(); let current = this.load(); - let has_changed = match (this.current.read().as_ref(), current.as_ref()) { + let has_changed = match (previous.as_ref(), current.as_ref()) { (None, None) => false, (Some(prev), Some(now)) => prev.cwd != now.cwd || prev.name != now.name, _ => true, diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index fb3abf41db7..e6e6e94bffc 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -1,71 +1,17 @@ use super::{HoverTarget, HoveredWord, TerminalView}; use anyhow::{Context as _, Result}; use editor::Editor; -use gpui::{App, AppContext, Context, Task, TaskExt, WeakEntity, Window}; -use itertools::Itertools; -use project::{Entry, Metadata}; +use gpui::{Context, Task, TaskExt, WeakEntity, Window}; use std::path::PathBuf; use terminal::PathLikeTarget; -use util::{ - ResultExt, debug_panic, - paths::{PathStyle, PathWithPosition, normalize_lexically}, - rel_path::RelPath, +use util::{ResultExt, debug_panic}; +#[cfg(not(test))] +use workspace::path_link::possible_open_target; +#[cfg(test)] +use workspace::path_link::{ + BackgroundFsChecks, OpenTargetFoundBy, possible_open_target_with_fs_checks, }; -use workspace::{OpenOptions, OpenVisible, Workspace}; - -/// The way we found the open target. This is important to have for test assertions. -/// For example, remote projects never look in the file system. -#[cfg(test)] -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum OpenTargetFoundBy { - WorktreeExact, - WorktreeScan, - FileSystemBackground, -} - -#[cfg(test)] -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum BackgroundFsChecks { - Enabled, - Disabled, -} - -#[derive(Debug, Clone)] -enum OpenTarget { - Worktree(PathWithPosition, Entry, #[cfg(test)] OpenTargetFoundBy), - File(PathWithPosition, Metadata), -} - -impl OpenTarget { - fn is_file(&self) -> bool { - match self { - OpenTarget::Worktree(_, entry, ..) => entry.is_file(), - OpenTarget::File(_, metadata) => !metadata.is_dir, - } - } - - fn is_dir(&self) -> bool { - match self { - OpenTarget::Worktree(_, entry, ..) => entry.is_dir(), - OpenTarget::File(_, metadata) => metadata.is_dir, - } - } - - fn path(&self) -> &PathWithPosition { - match self { - OpenTarget::Worktree(path, ..) => path, - OpenTarget::File(path, _) => path, - } - } - - #[cfg(test)] - fn found_by(&self) -> OpenTargetFoundBy { - match self { - OpenTarget::Worktree(.., found_by) => *found_by, - OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground, - } - } -} +use workspace::{OpenOptions, OpenVisible, Workspace, path_link::OpenTarget}; pub(super) fn hover_path_like_target( workspace: &WeakEntity, @@ -96,11 +42,19 @@ fn possible_hover_target( cx: &mut Context, #[cfg(test)] background_fs_checks: BackgroundFsChecks, ) -> Task<()> { + #[cfg(not(test))] let file_to_open_task = possible_open_target( workspace, - path_like_target, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + ); + #[cfg(test)] + let file_to_open_task = possible_open_target_with_fs_checks( + workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), cx, - #[cfg(test)] background_fs_checks, ); cx.spawn(async move |terminal_view, cx| { @@ -122,297 +76,6 @@ fn possible_hover_target( }) } -fn possible_open_target( - workspace: &WeakEntity, - path_like_target: &PathLikeTarget, - cx: &App, - #[cfg(test)] background_fs_checks: BackgroundFsChecks, -) -> Task> { - let Some(workspace) = workspace.upgrade() else { - return Task::ready(None); - }; - // We have to check for both paths, as on Unix, certain paths with positions are valid file paths too. - // We can be on FS remote part, without real FS, so cannot canonicalize or check for existence the path right away. - let mut potential_paths = Vec::new(); - let cwd = path_like_target.terminal_dir.as_ref(); - let maybe_path = &path_like_target.maybe_path; - let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path)); - let path_with_position = PathWithPosition::parse_str(maybe_path); - let worktree_candidates = workspace - .read(cx) - .worktrees(cx) - .sorted_by_key(|worktree| { - let worktree_root = worktree.read(cx).abs_path(); - match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) { - Some(cwd_child) => cwd_child.components().count(), - None => usize::MAX, - } - }) - .collect::>(); - // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it. - const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; - for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { - if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() { - potential_paths.push(PathWithPosition { - path: stripped.to_owned(), - row: original_path.row, - column: original_path.column, - }); - } - if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() { - potential_paths.push(PathWithPosition { - path: stripped.to_owned(), - row: path_with_position.row, - column: path_with_position.column, - }); - } - } - - let insert_both_paths = original_path != path_with_position; - potential_paths.insert(0, original_path); - if insert_both_paths { - potential_paths.insert(1, path_with_position); - } - - // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix. - // That will be slow, though, so do the fast checks first. - let mut worktree_paths_to_check = Vec::new(); - let mut is_cwd_in_worktree = false; - let mut open_target = None; - 'worktree_loop: for worktree in &worktree_candidates { - let worktree_root = worktree.read(cx).abs_path(); - let mut paths_to_check = Vec::with_capacity(potential_paths.len()); - let relative_cwd = cwd - .and_then(|cwd| cwd.strip_prefix(&worktree_root).ok()) - .and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok()) - .and_then(|cwd_stripped| { - (cwd_stripped.as_ref() != RelPath::empty()).then(|| { - is_cwd_in_worktree = true; - cwd_stripped - }) - }); - - for path_with_position in &potential_paths { - let path_to_check = if worktree_root.ends_with(&path_with_position.path) { - let root_path_with_position = PathWithPosition { - path: worktree_root.to_path_buf(), - row: path_with_position.row, - column: path_with_position.column, - }; - match worktree.read(cx).root_entry() { - Some(root_entry) => { - open_target = Some(OpenTarget::Worktree( - root_path_with_position, - root_entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeExact, - )); - break 'worktree_loop; - } - None => root_path_with_position, - } - } else { - PathWithPosition { - path: path_with_position - .path - .strip_prefix(&worktree_root) - .unwrap_or(&path_with_position.path) - .to_owned(), - row: path_with_position.row, - column: path_with_position.column, - } - }; - - // Normalize the path by joining with cwd if available (handles `.` and `..` segments) - let normalized_path = if path_to_check.path.is_relative() { - relative_cwd.as_ref().and_then(|relative_cwd| { - let joined = relative_cwd - .as_ref() - .as_std_path() - .join(&path_to_check.path); - normalize_lexically(&joined).ok().and_then(|p| { - RelPath::new(&p, PathStyle::local()) - .ok() - .map(std::borrow::Cow::into_owned) - }) - }) - } else { - None - }; - let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok(); - - if !worktree.read(cx).is_single_file() - && let Some(entry) = normalized_path - .as_ref() - .and_then(|p| worktree.read(cx).entry_for_path(p)) - .or_else(|| { - original_path - .as_ref() - .and_then(|p| worktree.read(cx).entry_for_path(p.as_ref())) - }) - { - open_target = Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree.read(cx).absolutize(&entry.path), - row: path_to_check.row, - column: path_to_check.column, - }, - entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeExact, - )); - break 'worktree_loop; - } - - paths_to_check.push(path_to_check); - } - - if !paths_to_check.is_empty() { - worktree_paths_to_check.push((worktree.clone(), paths_to_check)); - } - } - - #[cfg(not(test))] - let enable_background_fs_checks = workspace.read(cx).project().read(cx).is_local(); - #[cfg(test)] - let enable_background_fs_checks = background_fs_checks == BackgroundFsChecks::Enabled; - - if open_target.is_some() { - // We we want to prefer open targets found via background fs checks over worktree matches, - // however we can return early if either: - // - This is a remote project, or - // - If the terminal working directory is inside of at least one worktree - if !enable_background_fs_checks || is_cwd_in_worktree { - return Task::ready(open_target); - } - } - - // Before entire worktree traversal(s), make an attempt to do FS checks if available. - let fs_paths_to_check = - if enable_background_fs_checks { - let fs_cwd_paths_to_check = cwd - .iter() - .flat_map(|cwd| { - let mut paths_to_check = Vec::new(); - for path_to_check in &potential_paths { - let maybe_path = &path_to_check.path; - if path_to_check.path.is_relative() { - paths_to_check.push(PathWithPosition { - path: cwd.join(&maybe_path), - row: path_to_check.row, - column: path_to_check.column, - }); - } - } - paths_to_check - }) - .collect::>(); - fs_cwd_paths_to_check - .into_iter() - .chain( - potential_paths - .into_iter() - .flat_map(|path_to_check| { - let mut paths_to_check = Vec::new(); - let maybe_path = &path_to_check.path; - if maybe_path.starts_with("~") { - if let Some(home_path) = maybe_path.strip_prefix("~").ok().and_then( - |stripped_maybe_path| { - Some(dirs::home_dir()?.join(stripped_maybe_path)) - }, - ) { - paths_to_check.push(PathWithPosition { - path: home_path, - row: path_to_check.row, - column: path_to_check.column, - }); - } - } else { - paths_to_check.push(PathWithPosition { - path: maybe_path.clone(), - row: path_to_check.row, - column: path_to_check.column, - }); - if maybe_path.is_relative() { - for worktree in &worktree_candidates { - if !worktree.read(cx).is_single_file() { - paths_to_check.push(PathWithPosition { - path: worktree.read(cx).abs_path().join(maybe_path), - row: path_to_check.row, - column: path_to_check.column, - }); - } - } - } - } - paths_to_check - }) - .collect::>(), - ) - .collect() - } else { - Vec::new() - }; - - let fs = workspace.read(cx).project().read(cx).fs().clone(); - let background_fs_checks_task = cx.background_spawn(async move { - for mut path_to_check in fs_paths_to_check { - if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() - && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() - { - if open_target - .as_ref() - .map(|open_target| open_target.path().path != fs_path_to_check) - .unwrap_or(true) - { - path_to_check.path = fs_path_to_check; - return Some(OpenTarget::File(path_to_check, metadata)); - } - - break; - } - } - - open_target - }); - - cx.spawn(async move |cx| { - background_fs_checks_task.await.or_else(|| { - for (worktree, worktree_paths_to_check) in worktree_paths_to_check { - if let Some(found_entry) = - worktree.update(cx, |worktree, _| -> Option { - let traversal = - worktree.traverse_from_path(true, true, false, RelPath::empty()); - for entry in traversal { - if let Some(path_in_worktree) = - worktree_paths_to_check.iter().find(|path_to_check| { - RelPath::new(&path_to_check.path, PathStyle::local()) - .is_ok_and(|path| entry.path.ends_with(&path)) - }) - { - return Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree.absolutize(&entry.path), - row: path_in_worktree.row, - column: path_in_worktree.column, - }, - entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeScan, - )); - } - } - None - }) - { - return Some(found_entry); - } - } - None - }) - }) -} - pub(super) fn open_path_like_target( workspace: &WeakEntity, terminal_view: &mut TerminalView, @@ -455,13 +118,25 @@ fn possibly_open_target( cx.spawn_in(window, async move |terminal_view, cx| { let Some(open_target) = terminal_view .update(cx, |_, cx| { - possible_open_target( - &workspace, - &path_like_target, - cx, - #[cfg(test)] - background_fs_checks, - ) + #[cfg(not(test))] + { + possible_open_target( + &workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + ) + } + #[cfg(test)] + { + possible_open_target_with_fs_checks( + &workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + background_fs_checks, + ) + } })? .await else { @@ -530,7 +205,7 @@ fn possibly_open_target( #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{AppContext as _, TestAppContext}; use project::Project; use serde_json::json; use std::path::{Path, PathBuf}; @@ -540,6 +215,7 @@ mod tests { terminal_settings::{AlternateScroll, CursorShape}, }; use util::path; + use util::paths::PathStyle; use workspace::{AppState, MultiWorkspace}; async fn init_test( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 55e6929d832..cc0430bf853 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -513,13 +513,16 @@ impl TerminalView { .action("Paste Text", Box::new(PasteText)) .action("Select All", Box::new(SelectAll)) .action("Clear", Box::new(Clear)) - .when(assistant_enabled, |menu| { - menu.separator() - .action("Inline Assist", Box::new(InlineAssist::default())) - .when(has_selection, |menu| { - menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) - }) - }) + .when( + assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), + |menu| { + menu.separator() + .action("Inline Assist", Box::new(InlineAssist::default())) + .when(has_selection, |menu| { + menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) + }, + ) .separator() .action( "Close Terminal Tab", diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index a1d65dddc46..5303f2952e8 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -80,6 +80,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ], ), ("backup", &["bak"]), + ("ballerina", &["bal"]), ("bicep", &["bicep"]), ("bun", &["lockb"]), ("c", &["c", "h"]), @@ -312,6 +313,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ const FILE_ICONS: &[(&str, &str)] = &[ ("astro", "icons/file_icons/astro.svg"), ("audio", "icons/file_icons/audio.svg"), + ("ballerina", "icons/file_icons/ballerina.svg"), ("bicep", "icons/file_icons/file.svg"), ("bun", "icons/file_icons/bun.svg"), ("c", "icons/file_icons/c.svg"), diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index c9f220a923c..3d36f08aa58 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -56,6 +56,7 @@ pub struct ThemeSettings { agent_ui_font_size: Option, /// The agent buffer font size. Determines the size of user messages in the agent panel. agent_buffer_font_size: Option, + git_commit_buffer_font_size: Option, /// The font family to use for rendering in the markdown preview. /// Falls back to the UI font family if unset. markdown_preview_font_family: Option, @@ -118,6 +119,11 @@ pub struct AgentBufferFontSize(Pixels); impl Global for AgentBufferFontSize {} +#[derive(Default)] +pub struct GitCommitBufferFontSize(Pixels); + +impl Global for GitCommitBufferFontSize {} + /// Represents the selection of a theme, which can be either static or dynamic. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(untagged)] @@ -410,6 +416,14 @@ impl ThemeSettings { .unwrap_or_else(|| self.buffer_font_size(cx)) } + pub fn git_commit_buffer_font_size(&self, cx: &App) -> Pixels { + cx.try_global::() + .map(|size| size.0) + .or(self.git_commit_buffer_font_size) + .map(clamp_font_size) + .unwrap_or_else(|| self.buffer_font_size(cx)) + } + /// Returns the font family to use in the markdown preview, /// falling back to the UI font family when unset. pub fn markdown_preview_font_family(&self) -> &SharedString { @@ -458,6 +472,10 @@ impl ThemeSettings { self.agent_buffer_font_size } + pub fn git_commit_buffer_font_size_settings(&self) -> Option { + self.git_commit_buffer_font_size + } + /// Returns the buffer's line height. pub fn line_height(&self) -> f32 { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) @@ -609,6 +627,22 @@ pub fn reset_agent_buffer_font_size(cx: &mut App) { } } +pub fn adjust_git_commit_buffer_font_size(cx: &mut App, f: impl FnOnce(Pixels) -> Pixels) { + let git_commit_buffer_font_size = ThemeSettings::get_global(cx).git_commit_buffer_font_size(cx); + let adjusted_size = cx + .try_global::() + .map_or(git_commit_buffer_font_size, |adjusted_size| adjusted_size.0); + cx.set_global(GitCommitBufferFontSize(clamp_font_size(f(adjusted_size)))); + cx.refresh_windows(); +} + +pub fn reset_git_commit_buffer_font_size(cx: &mut App) { + if cx.has_global::() { + cx.remove_global::(); + cx.refresh_windows(); + } +} + /// Ensures font size is within the valid range. pub fn clamp_font_size(size: Pixels) -> Pixels { size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) @@ -658,6 +692,7 @@ impl settings::Settings for ThemeSettings { buffer_line_height: content.buffer_line_height.unwrap().into(), agent_ui_font_size: content.agent_ui_font_size.map(|s| s.into_gpui()), agent_buffer_font_size: content.agent_buffer_font_size.map(|s| s.into_gpui()), + git_commit_buffer_font_size: content.git_commit_buffer_font_size.map(|s| s.into_gpui()), markdown_preview_font_family: content .markdown_preview_font_family .as_ref() diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs index b5bf1a60283..192dfa54c47 100644 --- a/crates/theme_settings/src/theme_settings.rs +++ b/crates/theme_settings/src/theme_settings.rs @@ -28,12 +28,14 @@ pub use crate::schema::{ }; use crate::settings::adjust_buffer_font_size; pub use crate::settings::{ - AgentBufferFontSize, AgentUiFontSize, BufferLineHeight, FontFamilyName, IconThemeName, - IconThemeSelection, ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, - adjust_agent_buffer_font_size, adjust_agent_ui_font_size, adjust_ui_font_size, - adjusted_font_size, appearance_to_mode, clamp_font_size, default_theme, - observe_buffer_font_size_adjustment, reset_agent_buffer_font_size, reset_agent_ui_font_size, - reset_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, + AgentBufferFontSize, AgentUiFontSize, BufferLineHeight, FontFamilyName, + GitCommitBufferFontSize, IconThemeName, IconThemeSelection, ThemeAppearanceMode, ThemeName, + ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size, adjust_agent_ui_font_size, + adjust_git_commit_buffer_font_size, adjust_ui_font_size, adjusted_font_size, + appearance_to_mode, clamp_font_size, default_theme, observe_buffer_font_size_adjustment, + reset_agent_buffer_font_size, reset_agent_ui_font_size, reset_buffer_font_size, + reset_git_commit_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, + setup_ui_font, }; pub use theme::UiDensity; @@ -87,6 +89,8 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_git_commit_buffer_font_size_settings = + settings.git_commit_buffer_font_size_settings(); let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); let mut prev_theme_overrides = ( @@ -101,6 +105,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { let ui_font_size_settings = settings.ui_font_size_settings(); let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let git_commit_buffer_font_size_settings = settings.git_commit_buffer_font_size_settings(); let theme_name = settings.theme.name(SystemAppearance::global(cx).0); let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); let theme_overrides = ( @@ -128,6 +133,11 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { reset_agent_buffer_font_size(cx); } + if git_commit_buffer_font_size_settings != prev_git_commit_buffer_font_size_settings { + prev_git_commit_buffer_font_size_settings = git_commit_buffer_font_size_settings; + reset_git_commit_buffer_font_size(cx); + } + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { prev_theme_name = theme_name; prev_theme_overrides = theme_overrides; diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 61ffd3f8175..4a762b18eef 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -40,7 +40,7 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true -feature_flags.workspace = true + git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } icons.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index c15f840e69d..b2f2b4f436d 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -25,7 +25,6 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, Context, Element, Entity, Focusable, @@ -52,7 +51,8 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, + MultiWorkspace, ToggleWorktreeSecurity, Workspace, + notifications::{NotifyResultExt, NotifyTaskExt as _}, }; use zed_actions::OpenRemote; @@ -455,22 +455,7 @@ impl TitleBar { titlebar }); - // The banner label stays static ("Introducing: Skills") regardless - // of whether the user had Rules to migrate; the explainer modal - // is where the migration-specific summary surfaces. Keeping the - // label static avoids the rebuild-on-migration-completion plumbing - // we'd otherwise need to dodge the title-bar-vs-migration race. - let banner = Some(cx.new(|cx| { - OnboardingBanner::new( - "Skills Migration Announcement", - IconName::Sparkle, - "Skills", - Some("Introducing:".into()), - zed_actions::agent::OpenRulesToSkillsMigrationInfo.boxed_clone(), - cx, - ) - .visible_when(|cx| cx.has_flag::()) - })); + let banner = None; let mut this = Self { platform_titlebar, @@ -641,13 +626,8 @@ impl TitleBar { } pub fn render_restricted_mode(&self, cx: &mut Context) -> Option { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); if !has_restricted_worktrees { return None; } @@ -1180,6 +1160,7 @@ impl TitleBar { let show_update_button = self.update_version.read(cx).show_update_in_menu_bar(); let user_store = self.user_store.clone(); + let workspace = self.workspace.clone(); let user_store_read = user_store.read(cx); let user = user_store_read.current_user(); @@ -1246,6 +1227,7 @@ impl TitleBar { let current_organization = current_organization.clone(); let organizations = organizations.clone(); let user_store = user_store.clone(); + let workspace = workspace.clone(); let ai_enabled = !project::DisableAiSettings::get_global(cx).disable_ai; let current_layout = AgentSettings::get_layout(cx); @@ -1337,11 +1319,13 @@ impl TitleBar { { let user_store = user_store.clone(); let organization = organization.clone(); - move |_window, cx| { - user_store.update(cx, |user_store, cx| { + let workspace = workspace.clone(); + move |window, cx| { + let task = user_store.update(cx, |user_store, cx| { user_store - .set_current_organization(organization.clone(), cx); + .set_current_organization(organization.clone(), cx) }); + task.detach_and_notify_err(workspace.clone(), window, cx); } }, ); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 4ae0e6d2e46..7999a300648 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -20,7 +20,9 @@ gpui.workspace = true gpui_macros.workspace = true icons.workspace = true itertools.workspace = true +log.workspace = true menu.workspace = true +num-format.workspace = true schemars.workspace = true serde.workspace = true smallvec.workspace = true @@ -35,5 +37,8 @@ windows.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } +[package.metadata.cargo-machete] +ignored = ["log"] + [features] default = [] diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index 91b5385c45b..479ae55ceb5 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,11 +1,11 @@ mod agent_setup_button; mod ai_setting_item; mod configured_api_card; -mod parallel_agents_illustration; +mod skills_illustration; mod thread_item; pub use agent_setup_button::*; pub use ai_setting_item::*; pub use configured_api_card::*; -pub use parallel_agents_illustration::*; +pub use skills_illustration::*; pub use thread_item::*; diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs index bfb55e4c7da..6651ee1b769 100644 --- a/crates/ui/src/components/ai/ai_setting_item.rs +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -10,6 +10,7 @@ pub enum AiSettingItemStatus { Running, Error, AuthRequired, + ClientSecretRequired, Authenticating, } @@ -21,6 +22,7 @@ impl AiSettingItemStatus { Self::Running => "Server is active.", Self::Error => "Server has an error.", Self::AuthRequired => "Authentication required.", + Self::ClientSecretRequired => "Client secret required.", Self::Authenticating => "Waiting for authorization…", } } @@ -31,7 +33,7 @@ impl AiSettingItemStatus { Self::Starting | Self::Authenticating => Some(Color::Muted), Self::Running => Some(Color::Success), Self::Error => Some(Color::Error), - Self::AuthRequired => Some(Color::Warning), + Self::AuthRequired | Self::ClientSecretRequired => Some(Color::Warning), } } diff --git a/crates/ui/src/components/ai/parallel_agents_illustration.rs b/crates/ui/src/components/ai/parallel_agents_illustration.rs deleted file mode 100644 index e7694e9359f..00000000000 --- a/crates/ui/src/components/ai/parallel_agents_illustration.rs +++ /dev/null @@ -1,272 +0,0 @@ -use crate::{DiffStat, Divider, prelude::*}; -use gpui::{Animation, AnimationExt, pulsating_between}; -use std::time::Duration; - -#[derive(IntoElement)] -pub struct ParallelAgentsIllustration; - -impl ParallelAgentsIllustration { - pub fn new() -> Self { - Self - } -} - -impl RenderOnce for ParallelAgentsIllustration { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let icon_container = || h_flex().size_4().flex_shrink_0().justify_center(); - - let loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| { - div() - .h(rems_from_px(5.)) - .w(width) - .rounded_full() - .bg(cx.theme().colors().element_selected) - .with_animation( - id, - Animation::new(Duration::from_millis(duration_ms)) - .repeat() - .with_easing(pulsating_between(0.1, 0.8)), - |label, delta| label.opacity(delta), - ) - }; - - let skeleton_bar = |width: DefiniteLength| { - div().h(rems_from_px(5.)).w(width).rounded_full().bg(cx - .theme() - .colors() - .text_muted - .opacity(0.05)) - }; - - let time = - |time: SharedString| Label::new(time).size(LabelSize::XSmall).color(Color::Muted); - - let worktree = |worktree: SharedString| { - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .color(Color::Muted) - .size(IconSize::Indicator), - ) - .child( - Label::new(worktree) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }; - - let dot_separator = || { - Label::new("•") - .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5) - }; - - let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec| { - v_flex() - .when(selected, |this| { - this.bg(cx.theme().colors().element_active.opacity(0.2)) - }) - .p_1() - .child( - h_flex() - .w_full() - .gap_1() - .child( - icon_container() - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), - ) - .map(|this| { - if selected { - this.child( - Label::new(title) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - } else { - this.child(skeleton_bar(relative(0.7))) - } - }), - ) - .child( - h_flex() - .opacity(0.8) - .w_full() - .gap_1() - .child(icon_container()) - .children(data), - ) - }; - - let agents = v_flex() - .col_span(3) - .bg(cx.theme().colors().elevated_surface_background) - .child(agent( - "Fix branch label".into(), - IconName::ZedAgent, - true, - vec![ - worktree("bug-fix".into()).into_any_element(), - dot_separator().into_any_element(), - DiffStat::new("ds", 5, 2) - .label_size(LabelSize::XSmall) - .into_any_element(), - dot_separator().into_any_element(), - time("2m".into()).into_any_element(), - ], - )) - .child(Divider::horizontal()) - .child(agent( - "Improve thread id".into(), - IconName::AiClaude, - false, - vec![ - DiffStat::new("ds", 120, 84) - .label_size(LabelSize::XSmall) - .into_any_element(), - dot_separator().into_any_element(), - time("16m".into()).into_any_element(), - ], - )) - .child(Divider::horizontal()) - .child(agent( - "Refactor archive view".into(), - IconName::AiOpenAi, - false, - vec![ - worktree("silent-forest".into()).into_any_element(), - dot_separator().into_any_element(), - time("37m".into()).into_any_element(), - ], - )); - - let thread_view = v_flex() - .col_span(3) - .h_full() - .flex_1() - .border_l_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().panel_background) - .child( - h_flex() - .px_1p5() - .py_0p5() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .child( - Label::new("Fix branch label") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - Icon::new(IconName::Plus) - .size(IconSize::Indicator) - .color(Color::Muted), - ), - ) - .child( - div().p_1().child( - v_flex() - .px_1() - .py_1p5() - .gap_1() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().editor_background) - .rounded_sm() - .shadow_sm() - .child(skeleton_bar(relative(0.7))) - .child(skeleton_bar(relative(0.2))), - ), - ) - .child( - v_flex() - .p_2() - .gap_1() - .child(loading_bar("a", relative(0.55), 2200)) - .child(loading_bar("b", relative(0.75), 2000)) - .child(loading_bar("c", relative(0.25), 2400)), - ); - - let file_row = |indent: usize, is_folder: bool, bar_width: Rems| { - let indent_px = rems_from_px((indent as f32) * 4.0); - - h_flex() - .px_2() - .py_px() - .gap_1() - .pl(indent_px) - .child( - icon_container().child( - Icon::new(if is_folder { - IconName::FolderOpen - } else { - IconName::FileRust - }) - .size(IconSize::Indicator) - .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))), - ), - ) - .child( - div().h_1p5().w(bar_width).rounded_sm().bg(cx - .theme() - .colors() - .text - .opacity(if is_folder { 0.15 } else { 0.1 })), - ) - }; - - let project_panel = v_flex() - .col_span(1) - .h_full() - .flex_1() - .border_l_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().panel_background) - .child( - v_flex() - .child(file_row(0, true, rems_from_px(42.0))) - .child(file_row(1, true, rems_from_px(28.0))) - .child(file_row(2, false, rems_from_px(52.0))) - .child(file_row(2, false, rems_from_px(36.0))) - .child(file_row(2, false, rems_from_px(44.0))) - .child(file_row(1, true, rems_from_px(34.0))) - .child(file_row(2, false, rems_from_px(48.0))) - .child(file_row(2, true, rems_from_px(26.0))) - .child(file_row(3, false, rems_from_px(40.0))) - .child(file_row(3, false, rems_from_px(56.0))) - .child(file_row(1, false, rems_from_px(38.0))) - .child(file_row(0, true, rems_from_px(30.0))) - .child(file_row(1, false, rems_from_px(46.0))) - .child(file_row(1, false, rems_from_px(32.0))), - ); - - let workspace = div() - .absolute() - .top_8() - .grid() - .grid_cols(7) - .w(rems_from_px(380.)) - .rounded_t_sm() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .shadow_md() - .child(agents) - .child(thread_view) - .child(project_panel); - - h_flex() - .relative() - .h(rems_from_px(180.)) - .bg(cx.theme().colors().editor_background.opacity(0.6)) - .justify_center() - .items_end() - .rounded_t_md() - .overflow_hidden() - .bg(gpui::black().opacity(0.2)) - .child(workspace) - } -} diff --git a/crates/ui/src/components/ai/skills_illustration.rs b/crates/ui/src/components/ai/skills_illustration.rs new file mode 100644 index 00000000000..8f2de0078d6 --- /dev/null +++ b/crates/ui/src/components/ai/skills_illustration.rs @@ -0,0 +1,86 @@ +use crate::prelude::*; +use gpui::{linear_color_stop, linear_gradient}; + +#[derive(IntoElement)] +pub struct SkillsIllustration; + +impl SkillsIllustration { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for SkillsIllustration { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let skill_crease = |label: SharedString, source: SharedString| { + h_flex() + .py_1() + .px_1p5() + .gap_1p5() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().element_active.opacity(0.5)) + .justify_center() + .rounded_md() + .shadow_sm() + .child( + Icon::new(IconName::Sparkle) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .child(Label::new(label).size(LabelSize::XSmall).buffer_font(cx)) + .child( + Label::new(format!("({source})")) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx), + ) + }; + + let skill_list = v_flex() + .absolute() + .top_8() + .gap_2p5() + .items_center() + .child( + h_flex() + .gap_2p5() + .child(skill_crease("img-gen".into(), "studio".into())) + .child(skill_crease("frontend-design".into(), "global".into())), + ) + .child( + h_flex() + .gap_2p5() + .child(skill_crease("brainstorming".into(), "global".into())) + .child(skill_crease("borrow-checker-expert".into(), "zed".into())), + ) + .child( + h_flex() + .gap_2p5() + .child(skill_crease("grill-with-docs".into(), "global".into())) + .child(skill_crease("video-edit".into(), "studio".into())), + ); + + let gradient_bg = cx.theme().colors().editor_background; + let gradient_fade = div() + .absolute() + .rounded_t_md() + .inset_0() + .bg(linear_gradient( + 0., + linear_color_stop(gradient_bg.opacity(0.8), 0.), + linear_color_stop(gradient_bg.opacity(0.0), 1.), + )); + + v_flex() + .relative() + .h(rems_from_px(150.)) + .justify_end() + .items_center() + .rounded_t_md() + .overflow_hidden() + .bg(gpui::black().opacity(0.2)) + .child(skill_list) + .child(gradient_fade) + } +} diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index c2e76b171e7..5fd88903470 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -1,5 +1,6 @@ use crate::Tooltip; use crate::prelude::*; +use num_format::{Locale, ToFormattedString}; #[derive(IntoElement, RegisterComponent)] pub struct DiffStat { @@ -35,16 +36,19 @@ impl DiffStat { impl RenderOnce for DiffStat { fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { let tooltip = self.tooltip; + let added = self.added.to_formatted_string(&Locale::en); + let removed = self.removed.to_formatted_string(&Locale::en); + h_flex() .id(self.id) .gap_1() .child( - Label::new(format!("+\u{2009}{}", self.added)) + Label::new(format!("+\u{2009}{added}")) .color(Color::Success) .size(self.label_size), ) .child( - Label::new(format!("\u{2012}\u{2009}{}", self.removed)) + Label::new(format!("\u{2012}\u{2009}{removed}")) .color(Color::Error) .size(self.label_size), ) @@ -73,7 +77,7 @@ impl Component for DiffStat { let diff_stat_example = vec![single_example( "Default", container() - .child(DiffStat::new("id", 1, 2)) + .child(DiffStat::new("id", 1_234, 5_678)) .into_any_element(), )]; diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 73e03f82dfd..6d5b1d5645a 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -1,6 +1,7 @@ use std::ops::Range; use gpui::{FontWeight, HighlightStyle, StyleRefinement, StyledText}; +use gpui_util::debug_panic; use crate::{LabelCommon, LabelLike, LabelSize, LineHeightStyle, prelude::*}; @@ -14,14 +15,21 @@ pub struct HighlightedLabel { impl HighlightedLabel { /// Constructs a label with the given characters highlighted. /// Characters are identified by UTF-8 byte position. - pub fn new(label: impl Into, highlight_indices: Vec) -> Self { + #[track_caller] + pub fn new(label: impl Into, mut highlight_indices: Vec) -> Self { let label = label.into(); - for &run in &highlight_indices { - assert!( - label.is_char_boundary(run), - "highlight index {run} is not a valid UTF-8 boundary" + + if let Some(index) = highlight_indices + .iter() + .find(|&i| !label.is_char_boundary(*i)) + { + let location = std::panic::Location::caller(); + debug_panic!( + "highlight index {index} is not a valid UTF-8 boundary (called from {location})" ); + highlight_indices.clear(); } + Self { base: LabelLike::new(), label, diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 1ebf51aa293..92baae1d37f 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -389,6 +389,15 @@ impl Vim { }; let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); + + if self.search.cmd_f_search { + self.search.cmd_f_search = false; + if self.mode.is_visual() { + self.switch_mode(Mode::Normal, false, window, cx); + } + self.sync_vim_settings(window, cx); + } + let prior_selections = self.editor_selections(window, cx); let success = pane.update(cx, |pane, cx| { @@ -433,6 +442,15 @@ impl Vim { }; let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); + + if self.search.cmd_f_search { + self.search.cmd_f_search = false; + if self.mode.is_visual() { + self.switch_mode(Mode::Normal, false, window, cx); + } + self.sync_vim_settings(window, cx); + } + let prior_selections = self.editor_selections(window, cx); let vim = cx.entity(); @@ -1017,6 +1035,46 @@ mod test { cx.assert_state("one «oneˇ» one one", Mode::Insert); } + #[gpui::test] + async fn test_n_after_cmd_f_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇone two one two one", Mode::Normal); + cx.run_until_parked(); + + // Use cmd+f to search (non-vim style) + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + // Now use n to go to next match — should move cursor, not create selection + cx.simulate_keystrokes("n"); + cx.run_until_parked(); + cx.assert_state("one two ˇone two one", Mode::Normal); + + cx.simulate_keystrokes("n"); + cx.run_until_parked(); + cx.assert_state("one two one two ˇone", Mode::Normal); + } + + #[gpui::test] + async fn test_star_after_cmd_f_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇone two one two one", Mode::Normal); + cx.run_until_parked(); + + // Use cmd+f to search (non-vim style) + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + // Now use * to search under cursor — should move cursor, not create selection + cx.simulate_keystrokes("*"); + cx.run_until_parked(); + cx.assert_state("one two ˇone two one", Mode::Normal); + } + #[gpui::test] async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index bc53167b158..077e098bb3a 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -897,6 +897,9 @@ impl Vim { } }); if !match_exists { + self.update_editor(cx, |_, editor, _| { + editor.set_collapse_matches(true); + }); self.clear_operator(window, cx); self.stop_replaying(cx); return; diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 0200f1b7d57..4bee4ba8c08 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -36,6 +36,7 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +dirs.workspace = true futures-lite.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index b4ba998c771..61344668eb2 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -852,6 +852,46 @@ impl MultiWorkspace { } } + pub fn move_project_group_up(&mut self, key: &ProjectGroupKey, cx: &mut Context) -> bool { + let Some(index) = self + .project_groups + .iter() + .position(|group| group.key == *key) + else { + return false; + }; + if index == 0 { + return false; + } + self.project_groups.swap(index - 1, index); + cx.emit(MultiWorkspaceEvent::ProjectGroupsChanged); + self.serialize(cx); + cx.notify(); + true + } + + pub fn move_project_group_down( + &mut self, + key: &ProjectGroupKey, + cx: &mut Context, + ) -> bool { + let Some(index) = self + .project_groups + .iter() + .position(|group| group.key == *key) + else { + return false; + }; + if index + 1 >= self.project_groups.len() { + return false; + } + self.project_groups.swap(index, index + 1); + cx.emit(MultiWorkspaceEvent::ProjectGroupsChanged); + self.serialize(cx); + cx.notify(); + true + } + pub fn workspaces_for_project_group( &self, key: &ProjectGroupKey, diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index ce54765e3ff..689160b435f 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -402,6 +402,7 @@ impl Render for LanguageServerPrompt { .text_size(TextSize::Small.rems(cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .on_url_click(|link, _, cx| cx.open_url(&link)), diff --git a/crates/workspace/src/path_link.rs b/crates/workspace/src/path_link.rs new file mode 100644 index 00000000000..e707a7de603 --- /dev/null +++ b/crates/workspace/src/path_link.rs @@ -0,0 +1,422 @@ +use crate::Workspace; +use gpui::{App, AppContext, Task, WeakEntity}; +use itertools::Itertools; +use project::{Entry, Metadata}; +use std::path::{Path, PathBuf}; +use util::{ + paths::{PathStyle, PathWithPosition, normalize_lexically}, + rel_path::RelPath, +}; + +#[cfg(any(test, feature = "test-support"))] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum OpenTargetFoundBy { + WorktreeExact, + WorktreeScan, + FileSystemBackground, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BackgroundFsChecks { + Enabled, + Disabled, +} + +#[derive(Debug, Clone)] +pub enum OpenTarget { + Worktree( + PathWithPosition, + Entry, + #[cfg(any(test, feature = "test-support"))] OpenTargetFoundBy, + ), + File(PathWithPosition, Metadata), +} + +impl OpenTarget { + pub fn is_file(&self) -> bool { + match self { + OpenTarget::Worktree(_, entry, ..) => entry.is_file(), + OpenTarget::File(_, metadata) => !metadata.is_dir, + } + } + + pub fn is_dir(&self) -> bool { + match self { + OpenTarget::Worktree(_, entry, ..) => entry.is_dir(), + OpenTarget::File(_, metadata) => metadata.is_dir, + } + } + + pub fn path(&self) -> &PathWithPosition { + match self { + OpenTarget::Worktree(path, ..) => path, + OpenTarget::File(path, _) => path, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn found_by(&self) -> OpenTargetFoundBy { + match self { + OpenTarget::Worktree(.., found_by) => *found_by, + OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground, + } + } +} + +pub fn sanitize_path_text(text: &str) -> &str { + let start = first_unbalanced_open_paren(text).unwrap_or(0); + let mut sanitized = &text[start..]; + let (open_parens, mut close_parens) = + sanitized + .chars() + .fold((0, 0), |(opens, closes), character| match character { + '(' => (opens + 1, closes), + ')' => (opens, closes + 1), + _ => (opens, closes), + }); + + while let Some(last_char) = sanitized.chars().last() { + let should_remove = match last_char { + '.' | ',' | ':' | ';' => true, + '(' => true, + ')' if close_parens > open_parens => { + close_parens -= 1; + true + } + _ => false, + }; + + if should_remove { + sanitized = &sanitized[..sanitized.len() - last_char.len_utf8()]; + } else { + break; + } + } + + sanitized +} + +/// Returns the byte offset just past the first unbalanced `(` in `text`, or +/// `None` if all parentheses are balanced. +pub fn first_unbalanced_open_paren(text: &str) -> Option { + let mut balance: i32 = 0; + let mut first_unmatched = None; + for (index, character) in text.char_indices() { + match character { + '(' => { + if balance == 0 { + first_unmatched = Some(index + character.len_utf8()); + } + balance += 1; + } + ')' => { + balance -= 1; + if balance <= 0 { + balance = 0; + first_unmatched = None; + } + } + _ => {} + } + } + first_unmatched.filter(|_| balance > 0) +} + +pub fn possible_open_target( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, +) -> Task> { + possible_open_target_internal(workspace, maybe_path, cwd, cx, None) +} + +#[cfg(any(test, feature = "test-support"))] +pub fn possible_open_target_with_fs_checks( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, + background_fs_checks: BackgroundFsChecks, +) -> Task> { + possible_open_target_internal(workspace, maybe_path, cwd, cx, Some(background_fs_checks)) +} + +fn possible_open_target_internal( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, + background_fs_checks: Option, +) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(None); + }; + + let mut potential_paths = Vec::new(); + let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path)); + let path_with_position = PathWithPosition::parse_str(maybe_path); + let worktree_candidates = workspace + .read(cx) + .worktrees(cx) + .sorted_by_key(|worktree| { + let worktree_root = worktree.read(cx).abs_path(); + match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) { + Some(cwd_child) => cwd_child.components().count(), + None => usize::MAX, + } + }) + .collect::>(); + + const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; + for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { + if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() { + potential_paths.push(PathWithPosition { + path: stripped.to_owned(), + row: original_path.row, + column: original_path.column, + }); + } + if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() { + potential_paths.push(PathWithPosition { + path: stripped.to_owned(), + row: path_with_position.row, + column: path_with_position.column, + }); + } + } + + let insert_both_paths = original_path != path_with_position; + potential_paths.insert(0, original_path); + if insert_both_paths { + potential_paths.insert(1, path_with_position); + } + + let mut worktree_paths_to_check = Vec::new(); + let mut is_cwd_in_worktree = false; + let mut open_target = None; + 'worktree_loop: for worktree in &worktree_candidates { + let worktree_root = worktree.read(cx).abs_path(); + let mut paths_to_check = Vec::with_capacity(potential_paths.len()); + let relative_cwd = cwd + .and_then(|cwd| cwd.strip_prefix(&worktree_root).ok()) + .and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok()) + .and_then(|cwd_stripped| { + (cwd_stripped.as_ref() != RelPath::empty()).then(|| { + is_cwd_in_worktree = true; + cwd_stripped + }) + }); + + for path_with_position in &potential_paths { + let path_to_check = if worktree_root.ends_with(&path_with_position.path) { + let root_path_with_position = PathWithPosition { + path: worktree_root.to_path_buf(), + row: path_with_position.row, + column: path_with_position.column, + }; + match worktree.read(cx).root_entry() { + Some(root_entry) => { + open_target = Some(OpenTarget::Worktree( + root_path_with_position, + root_entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeExact, + )); + break 'worktree_loop; + } + None => root_path_with_position, + } + } else { + PathWithPosition { + path: path_with_position + .path + .strip_prefix(&worktree_root) + .unwrap_or(&path_with_position.path) + .to_owned(), + row: path_with_position.row, + column: path_with_position.column, + } + }; + + let normalized_path = if path_to_check.path.is_relative() { + relative_cwd.as_ref().and_then(|relative_cwd| { + let joined = relative_cwd + .as_ref() + .as_std_path() + .join(&path_to_check.path); + normalize_lexically(&joined).ok().and_then(|path| { + RelPath::new(&path, PathStyle::local()) + .ok() + .map(std::borrow::Cow::into_owned) + }) + }) + } else { + None + }; + let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok(); + + if !worktree.read(cx).is_single_file() + && let Some(entry) = normalized_path + .as_ref() + .and_then(|path| worktree.read(cx).entry_for_path(path)) + .or_else(|| { + original_path + .as_ref() + .and_then(|path| worktree.read(cx).entry_for_path(path.as_ref())) + }) + { + open_target = Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree.read(cx).absolutize(&entry.path), + row: path_to_check.row, + column: path_to_check.column, + }, + entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeExact, + )); + break 'worktree_loop; + } + + paths_to_check.push(path_to_check); + } + + if !paths_to_check.is_empty() { + worktree_paths_to_check.push((worktree.clone(), paths_to_check)); + } + } + + let enable_background_fs_checks = background_fs_checks + .map(|background_fs_checks| background_fs_checks == BackgroundFsChecks::Enabled) + .unwrap_or_else(|| workspace.read(cx).project().read(cx).is_local()); + + if open_target.is_some() { + if !enable_background_fs_checks || is_cwd_in_worktree { + return Task::ready(open_target); + } + } + + let fs_paths_to_check = if enable_background_fs_checks { + let fs_cwd_paths_to_check = cwd + .iter() + .flat_map(|cwd| { + let mut paths_to_check = Vec::new(); + for path_to_check in &potential_paths { + let maybe_path = &path_to_check.path; + if path_to_check.path.is_relative() { + paths_to_check.push(PathWithPosition { + path: cwd.join(maybe_path), + row: path_to_check.row, + column: path_to_check.column, + }); + } + } + paths_to_check + }) + .collect::>(); + fs_cwd_paths_to_check + .into_iter() + .chain( + potential_paths + .into_iter() + .flat_map(|path_to_check| { + let mut paths_to_check = Vec::new(); + let maybe_path = &path_to_check.path; + if maybe_path.starts_with("~") { + if let Some(home_path) = maybe_path + .strip_prefix("~") + .ok() + .and_then(|stripped| Some(dirs::home_dir()?.join(stripped))) + { + paths_to_check.push(PathWithPosition { + path: home_path, + row: path_to_check.row, + column: path_to_check.column, + }); + } + } else { + paths_to_check.push(PathWithPosition { + path: maybe_path.clone(), + row: path_to_check.row, + column: path_to_check.column, + }); + if maybe_path.is_relative() { + for worktree in &worktree_candidates { + if !worktree.read(cx).is_single_file() { + paths_to_check.push(PathWithPosition { + path: worktree.read(cx).abs_path().join(maybe_path), + row: path_to_check.row, + column: path_to_check.column, + }); + } + } + } + } + paths_to_check + }) + .collect::>(), + ) + .collect() + } else { + Vec::new() + }; + + let fs = workspace.read(cx).project().read(cx).fs().clone(); + let background_fs_checks_task = cx.background_spawn(async move { + for mut path_to_check in fs_paths_to_check { + if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() + && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() + { + if open_target + .as_ref() + .map(|open_target| open_target.path().path != fs_path_to_check) + .unwrap_or(true) + { + path_to_check.path = fs_path_to_check; + return Some(OpenTarget::File(path_to_check, metadata)); + } + + break; + } + } + + open_target + }); + + cx.spawn(async move |cx| { + background_fs_checks_task.await.or_else(|| { + for (worktree, worktree_paths_to_check) in worktree_paths_to_check { + if let Some(found_entry) = + worktree.update(cx, |worktree, _| -> Option { + let traversal = + worktree.traverse_from_path(true, true, false, RelPath::empty()); + for entry in traversal { + if let Some(path_in_worktree) = + worktree_paths_to_check.iter().find(|path_to_check| { + RelPath::new(&path_to_check.path, PathStyle::local()) + .is_ok_and(|path| entry.path.ends_with(&path)) + }) + { + return Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree.absolutize(&entry.path), + row: path_in_worktree.row, + column: path_in_worktree.column, + }, + entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeScan, + )); + } + } + None + }) + { + return Some(found_entry); + } + } + None + }) + }) +} diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 2130a1d1eca..378968fd1c0 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -56,11 +56,17 @@ impl ModalView for SecurityModal { fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context) -> DismissDecision { match self.trusted { - Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"), - Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"), - None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"), + Some(false) => { + telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + Some(true) => { + telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + // Block dismiss via escape or clicking outside; user must pick an action + None => DismissDecision::Dismiss(false), } - DismissDecision::Dismiss(true) } } @@ -358,7 +364,12 @@ impl SecurityModal { if self.restricted_paths != new_restricted_worktrees { self.trust_parents = false; self.restricted_paths = new_restricted_worktrees; - cx.notify(); + if self.restricted_paths.is_empty() { + self.trusted = Some(true); + self.dismiss(cx); + } else { + cx.notify(); + } } } } else if !self.restricted_paths.is_empty() { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90e497eb4e2..dbe132add88 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -13,9 +13,10 @@ pub mod pane_group; pub mod path_list { pub use util::path_list::{PathList, SerializedPathList}; } +pub mod path_link; mod persistence; pub mod searchable; -mod security_modal; +pub mod security_modal; pub mod shared_screen; pub use shared_screen::SharedScreen; pub mod focus_follows_mouse; @@ -670,7 +671,11 @@ fn prompt_and_open_paths( create_new_window: bool, cx: &mut App, ) { - if let Some(workspace_window) = local_workspace_windows(cx).into_iter().next() { + if let Some(workspace_window) = + workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) + .into_iter() + .next() + { workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); @@ -2118,6 +2123,15 @@ impl Workspace { .log_err(); } + // Auto-show the security modal if the project has restricted worktrees + window + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(false, window, cx); + }); + }) + .log_err(); + Ok(OpenResult { window, workspace, @@ -6672,6 +6686,12 @@ impl Workspace { ActiveCallEvent::LocalScreenShareStopped => { self.handle_auto_watch_local_share_stopped(window, cx); } + ActiveCallEvent::RoomLeft => { + if self.auto_watch.enabled() { + self.auto_watch = AutoWatch::Off; + cx.notify(); + } + } } } @@ -8010,13 +8030,10 @@ impl Workspace { }); } } else { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = TrustedWorktrees::has_restricted_worktrees( + &self.project().read(cx).worktree_store(), + cx, + ); if has_restricted_worktrees { let project = self.project().read(cx); let remote_host = project @@ -8126,6 +8143,7 @@ pub enum ActiveCallEvent { RemoteVideoTracksChanged { participant_id: PeerId }, LocalScreenShareStarted, LocalScreenShareStopped, + RoomLeft, } fn leader_border_for_pane( @@ -9471,7 +9489,7 @@ pub async fn get_any_active_multi_workspace( activate_any_workspace_window(&mut cx).context("could not open zed") } -fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { +pub fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { if let Some(workspace_window) = cx .active_window() @@ -9492,10 +9510,6 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option Vec> { - workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) -} - pub fn workspace_windows_for_location( serialized_location: &SerializedWorkspaceLocation, cx: &App, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index a21ea8f639e..f1dfaec5c85 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -8,7 +8,8 @@ use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; use fs::{ - Fs, MTime, PathEvent, RemoveOptions, TrashedEntry, Watcher, copy_recursive, read_dir_items, + Fs, MTime, PathEvent, PathEventKind, RemoveOptions, TrashedEntry, Watcher, copy_recursive, + read_dir_items, }; use futures::{ FutureExt as _, Stream, StreamExt, @@ -4374,6 +4375,8 @@ impl BackgroundScanner { events = Self::normalized_events_for_worktree(&state, &root_canonical_path, events); } + log::debug!("raw events for process_events: {events:?}"); + fn skip_ix(ranges: &mut SmallVec<[Range; 4]>, ix: usize) { if let Some(last_range) = ranges.last_mut() && last_range.end == ix @@ -4418,20 +4421,29 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - let skip = skipped_files_in_dot_git.iter().any(|skipped| { + let is_ignored = skipped_files_in_dot_git.iter().any(|skipped| { OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() }) || skipped_dirs_in_dot_git .iter() - .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) - || path_in_git_dir == Path::new("") - && self.fs.is_dir(&dot_git_abs_path).await; - if skip { + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)); + let is_dot_git = path_in_git_dir == Path::new("") + && matches!(event.kind, Some(PathEventKind::Changed)) + && self.fs.is_dir(&dot_git_abs_path).await; + if is_ignored { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" ); skip_ix(&mut ranges_to_drop, ix); continue; } + if is_dot_git { + log::debug!( + "ignoring event {abs_path:?} for .git directory itself (kind: {:?})", + event.kind + ); + skip_ix(&mut ranges_to_drop, ix); + continue; + } if !dot_git_abs_paths.contains(&dot_git_abs_path) { dot_git_abs_paths.push(dot_git_abs_path); diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index ef5180a7083..f8e07ffa0b4 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -3584,6 +3584,34 @@ async fn test_dot_git_dir_event_does_not_suppress_children( "should emit UpdatedGitRepositories for a .git/index event" ); } + + { + let mut events = cx.events(&worktree); + fs.pause_events(); + fs.emit_fs_event(dot_git, Some(PathEventKind::Rescan)); + fs.unpause_events_and_flush(); + executor.run_until_parked(); + + let got_git_update = drain_git_repo_updates(&mut events); + assert!( + got_git_update, + "should emit UpdatedGitRepositories for a .git rescan event" + ); + } + + { + let mut events = cx.events(&worktree); + fs.pause_events(); + fs.emit_fs_event(project_dir, Some(PathEventKind::Rescan)); + fs.unpause_events_and_flush(); + executor.run_until_parked(); + + let got_git_update = drain_git_repo_updates(&mut events); + assert!( + got_git_update, + "should emit UpdatedGitRepositories for a .git rescan event" + ); + } } fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver) -> bool { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d0b5227afb1..79bb5cca724 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.4.0" +version = "1.5.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index affe1521a68..3042510f2c6 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -909,6 +909,7 @@ fn main() { wsl, diff_all: diff_all_mode, dev_container: args.dev_container, + ..Default::default() }) } @@ -925,6 +926,14 @@ fn main() { .ok() .and_then(|request| OpenRequest::parse(request, cx).log_err()) { + Some(request) if request.is_focus_app_only() => cx.spawn({ + let app_state = app_state.clone(); + async move |cx| { + if let Err(e) = restore_or_create_workspace(app_state, cx).await { + fail_to_open_window_async(e, cx) + } + } + }), Some(request) => { handle_open_request(request, app_state.clone(), cx); Task::ready(()) @@ -978,6 +987,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) .detach(); } + OpenRequestKind::FocusApp => { + cx.spawn(async move |cx| { + if workspace::activate_any_workspace_window(cx).is_some() { + return anyhow::Ok(()); + } + restore_or_create_workspace(app_state, cx).await + }) + .detach_and_log_err(cx); + } OpenRequestKind::Extension { extension_id } => { cx.spawn(async move |cx| { let workspace = @@ -1001,6 +1019,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let multi_workspace = workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + let panels_task = multi_workspace.update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, _| workspace.take_panels_task()) + })?; + if let Some(task) = panels_task { + task.await.log_err(); + } + multi_workspace.update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(window, cx) { @@ -1011,6 +1038,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx, ); }); + } else { + log::warn!( + "zed://agent received but the AgentPanel is not registered \ + (is `disable_ai` enabled?)" + ); } }); }) @@ -1233,6 +1265,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }); } OpenRequestKind::GitCommit { sha } => { + let base_open_options = zed::open_options_for_request( + request.open_behavior, + &workspace::SerializedWorkspaceLocation::Local, + cx, + ); cx.spawn(async move |cx| { let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; @@ -1241,7 +1278,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut &[], false, app_state, - workspace::OpenOptions::default(), + base_open_options, cx, ) .await?; @@ -1283,16 +1320,12 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } if let Some(connection_options) = request.remote_connection { + let open_behavior = request.open_behavior; + let location = workspace::SerializedWorkspaceLocation::Remote(connection_options.clone()); + let base_open_options = zed::open_options_for_request(open_behavior, &location, cx); cx.spawn(async move |cx| { let paths: Vec = request.open_paths.into_iter().map(PathBuf::from).collect(); - open_remote_project( - connection_options, - paths, - app_state, - workspace::OpenOptions::default(), - cx, - ) - .await + open_remote_project(connection_options, paths, app_state, base_open_options, cx).await }) .detach_and_log_err(cx); return; @@ -1302,6 +1335,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let dev_container = request.dev_container; if !request.open_paths.is_empty() || !request.diff_paths.is_empty() { let app_state = app_state.clone(); + let base_open_options = zed::open_options_for_request( + request.open_behavior, + &workspace::SerializedWorkspaceLocation::Local, + cx, + ); task = Some(cx.spawn(async move |cx| { let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; @@ -1312,7 +1350,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut app_state, workspace::OpenOptions { open_in_dev_container: dev_container, - ..Default::default() + ..base_open_options }, cx, ) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index fedf9d8cf24..9bfdd9b1fde 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1551,7 +1551,7 @@ fn open_about_window(cx: &mut App) { window_bounds: Some(WindowBounds::centered(window_size, cx)), is_resizable: false, is_minimizable: false, - kind: WindowKind::Normal, + kind: WindowKind::Floating, app_id: Some(ReleaseChannel::global(cx).app_id().to_owned()), ..Default::default() }, @@ -5253,6 +5253,7 @@ mod tests { "search", "settings_editor", "settings_profile_selector", + "skill_creator", "snippets", "stash_picker", "svg", diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 0ac86e9d2b5..4a4f5fca518 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -42,6 +42,7 @@ pub struct OpenRequest { pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub remote_connection: Option, + pub open_behavior: Option, } pub enum OpenRequestKind { @@ -51,6 +52,7 @@ pub enum OpenRequestKind { Box, ), ), + FocusApp, Extension { extension_id: String, }, @@ -82,6 +84,7 @@ impl std::fmt::Debug for OpenRequestKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::CliConnection(_) => write!(f, "CliConnection(..)"), + Self::FocusApp => write!(f, "FocusApp"), Self::Extension { extension_id } => f .debug_struct("Extension") .field("extension_id", extension_id) @@ -118,12 +121,22 @@ impl std::fmt::Debug for OpenRequestKind { } impl OpenRequest { + pub fn is_focus_app_only(&self) -> bool { + matches!(self.kind, Some(OpenRequestKind::FocusApp)) + && self.open_paths.is_empty() + && self.diff_paths.is_empty() + && self.remote_connection.is_none() + && self.join_channel.is_none() + && self.open_channel_notes.is_empty() + } + pub fn parse(request: RawOpenRequest, cx: &App) -> Result { let mut this = Self::default(); this.diff_paths = request.diff_paths; this.diff_all = request.diff_all; this.dev_container = request.dev_container; + this.open_behavior = request.open_behavior; if let Some(wsl) = request.wsl { let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -167,6 +180,8 @@ impl OpenRequest { } } else if let Some(agent_path) = url.strip_prefix("zed://agent") { this.parse_agent_url(agent_path) + } else if url == "zed://" || url == "zed://open" || url == "zed://open/" { + this.kind = Some(OpenRequestKind::FocusApp); } else if let Some(schema_path) = url.strip_prefix("zed://schemas/") { this.kind = Some(OpenRequestKind::BuiltinJsonSchema { schema_path: schema_path.to_string(), @@ -210,7 +225,8 @@ impl OpenRequest { } fn parse_agent_url(&mut self, agent_path: &str) { - // Format: "" or "?prompt=" + // Format: "" or "?prompt=". + let agent_path = agent_path.strip_prefix('/').unwrap_or(agent_path); let external_source_prompt = agent_path.strip_prefix('?').and_then(|query| { url::form_urlencoded::parse(query.as_bytes()) .find_map(|(key, value)| (key == "prompt").then_some(value)) @@ -368,6 +384,7 @@ pub struct RawOpenRequest { pub diff_all: bool, pub dev_container: bool, pub wsl: Option, + pub open_behavior: Option, } impl Global for OpenListener {} @@ -557,6 +574,7 @@ pub async fn handle_cli_connection( diff_all, dev_container, wsl, + open_behavior: Some(open_behavior), }, cx, ) { @@ -721,6 +739,52 @@ async fn resolve_open_behavior( None } +pub(crate) fn open_options_for_request( + open_behavior: Option, + location: &SerializedWorkspaceLocation, + cx: &App, +) -> workspace::OpenOptions { + open_behavior.map_or_else(workspace::OpenOptions::default, |open_behavior| { + open_options_for_behavior(open_behavior, location, cx) + }) +} + +pub(crate) fn open_options_for_behavior( + open_behavior: cli::OpenBehavior, + location: &SerializedWorkspaceLocation, + cx: &App, +) -> workspace::OpenOptions { + // If reuse flag is passed, open a new workspace in an existing window. + let requesting_window = if open_behavior == cli::OpenBehavior::Reuse { + workspace::workspace_windows_for_location(location, cx) + .into_iter() + .next() + } else { + None + }; + workspace::OpenOptions { + workspace_matching: match open_behavior { + cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => { + workspace::WorkspaceMatching::None + } + cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory, + _ => workspace::WorkspaceMatching::MatchExact, + }, + add_dirs_to_sidebar: match open_behavior { + cli::OpenBehavior::ExistingWindow => true, + // For the default value, we consult the settings to decide + // whether to open in a new window or existing window. + cli::OpenBehavior::Default => { + workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior + == settings::CliDefaultOpenBehavior::ExistingWindow + } + _ => false, + }, + requesting_window, + ..Default::default() + } +} + async fn open_workspaces( paths: Vec, diff_paths: Vec<[String; 2]>, @@ -773,39 +837,13 @@ async fn open_workspaces( let mut errored = false; for (location, workspace_paths) in grouped_locations { - // If reuse flag is passed, open a new workspace in an existing window. - let replace_window = if open_behavior == cli::OpenBehavior::Reuse { - cx.update(|cx| { - workspace::workspace_windows_for_location(&location, cx) - .into_iter() - .next() - }) - } else { - None - }; + let base_open_options = + cx.update(|cx| open_options_for_behavior(open_behavior, &location, cx)); let open_options = workspace::OpenOptions { - workspace_matching: match open_behavior { - cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => { - workspace::WorkspaceMatching::None - } - cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory, - _ => workspace::WorkspaceMatching::MatchExact, - }, - add_dirs_to_sidebar: match open_behavior { - cli::OpenBehavior::ExistingWindow => true, - // For the default value, we consult the settings to decide - // whether to open in a new window or existing window. - cli::OpenBehavior::Default => cx.update(|cx| { - workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior - == settings::CliDefaultOpenBehavior::ExistingWindow - }), - _ => false, - }, - requesting_window: replace_window, wait, env: env.clone(), open_in_dev_container: dev_container, - ..Default::default() + ..base_open_options }; match location { @@ -1146,6 +1184,25 @@ mod tests { } } + #[gpui::test] + fn test_parse_ssh_url_preserves_open_behavior(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["ssh://me@host:/".into()], + open_behavior: Some(cli::OpenBehavior::AlwaysNew), + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + assert_eq!(request.open_behavior, Some(cli::OpenBehavior::AlwaysNew)); + } + #[gpui::test] fn test_reject_ssh_urls(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -1168,6 +1225,24 @@ mod tests { } } + #[gpui::test] + fn test_open_options_for_behavior_always_new(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + let options = cx.update(|cx| { + open_options_for_behavior( + cli::OpenBehavior::AlwaysNew, + &SerializedWorkspaceLocation::Local, + cx, + ) + }); + assert_eq!( + options.workspace_matching, + workspace::WorkspaceMatching::None + ); + assert!(!options.add_dirs_to_sidebar); + assert!(options.requesting_window.is_none()); + } + #[gpui::test] fn test_parse_agent_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -1230,6 +1305,63 @@ mod tests { } } + #[gpui::test] + fn test_parse_agent_url_with_trailing_slash(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent/?prompt=hello".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { + assert_eq!( + external_source_prompt + .as_ref() + .map(ExternalSourcePrompt::as_str), + Some("hello") + ); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_focus_app_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + for url in ["zed://", "zed://open", "zed://open/"] { + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![url.into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + assert!( + matches!(request.kind, Some(OpenRequestKind::FocusApp)), + "expected FocusApp for {url}, got {:?}", + request.kind + ); + assert!( + request.is_focus_app_only(), + "expected is_focus_app_only for {url}" + ); + } + } + #[gpui::test] fn test_parse_agent_url_with_empty_prompt(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -2095,6 +2227,25 @@ mod tests { } } + fn make_cli_url_open_request( + urls: Vec, + open_behavior: cli::OpenBehavior, + ) -> CliRequest { + CliRequest::Open { + paths: vec![], + urls, + diff_paths: vec![], + diff_all: false, + wsl: None, + wait: false, + open_behavior, + env: None, + user_data_dir: None, + dev_container: false, + cwd: None, + } + } + /// Runs the real [`cli::run_cli_response_loop`] on an OS thread against /// the Zed-side `handle_cli_connection` on the GPUI foreground executor, /// using `allow_parking` so the test scheduler tolerates cross-thread @@ -2401,6 +2552,35 @@ mod tests { assert_eq!(cx.windows().len(), 2); } + #[gpui::test] + async fn test_e2e_explicit_new_flag_with_file_url_opens_new_window(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), json!({ "file.txt": "content" })) + .await; + + open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await; + assert_eq!(cx.windows().len(), 1); + + let file_url = format!( + "file://{}", + urlencoding::encode(path!("/project/file.txt")).into_owned() + ); + let (status, prompt_shown) = run_cli_with_zed_handler( + cx, + app_state, + make_cli_url_open_request(vec![file_url], cli::OpenBehavior::AlwaysNew), + None, + ); + + assert_eq!(status, 0); + assert!(!prompt_shown, "no prompt should be shown with -n flag"); + assert_eq!(cx.windows().len(), 2); + } + #[gpui::test] async fn test_e2e_paths_in_existing_workspace_no_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 7df7e83d258..062fd5f0f28 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -12,7 +12,10 @@ use gpui::{ StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*, }; use language::LanguageRegistry; -use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{ + CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle, + WrapButtonVisibility, +}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; @@ -429,6 +432,7 @@ impl TelemetryLogView { } else { CopyButtonVisibility::Hidden }, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }), ), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index bcfaad5e8ab..1c6bcec766e 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -87,7 +87,6 @@ pub enum ExtensionCategoryFilter { Grammars, LanguageServers, ContextServers, - AgentServers, Snippets, DebugAdapters, } @@ -514,10 +513,6 @@ pub mod agent { ResetAgentZoom, /// Pastes clipboard content without any formatting. PasteRaw, - /// Opens the "Skills have replaced Rules" explainer modal, - /// describing the one-time migration of non-Default Rules to - /// global Skills. Dispatched from the title-bar banner. - OpenRulesToSkillsMigrationInfo, ] ); @@ -574,6 +569,8 @@ pub mod assistant { #[action(deprecated_aliases = ["assistant::ToggleFocus"])] ToggleFocus, FocusAgent, + /// Opens the skill creator window for creating a new skill. + OpenSkillCreator, ] ); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 606a75542a5..eca2ca57ae4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -18,6 +18,7 @@ - [Parallel Agents](./ai/parallel-agents.md) - [Inline Assistant](./ai/inline-assistant.md) - [Edit Prediction](./ai/edit-prediction.md) +- [Skills](./ai/skills.md) - [Rules](./ai/rules.md) - [Model Context Protocol](./ai/mcp.md) - [Configuration](./ai/configuration.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 0187d3a0b24..df3cafb1e82 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -40,6 +40,7 @@ From the "New Thread…" menu you can: - Pick **Zed Agent** or any installed [external agent](./external-agents.md) to start a new thread with that agent. - Choose **New From Summary** to start a fresh Zed Agent thread seeded with a summary of the current conversation — useful for compacting long threads as you approach the context window limit. +- Choose **Terminal** to open a terminal thread directly in the Agent Panel — see [Terminal Threads](#terminal-threads) for details. {#action agent::NewExternalAgentThread} creates a new thread with the specified external agent id. @@ -119,16 +120,128 @@ To see which files specifically have been edited, expand the accordion bar that You can accept or reject each individual change hunk, or the whole set of changes made by the agent. -Edit diffs also appear in singleton buffers. -If your active tab had edits made by the AI, you'll see diffs with the same accept/reject controls as in the multi-buffer. -You can turn this off, though, through the `agent.single_file_review` setting. +Edit diffs can also appear inline in individual files with the same +keep/reject hunk controls as the multi-buffer review pane. This temporarily overrides the buffer's git diff while review is active. Enable it by setting `agent.single_file_review` to `true` in your settings: + +```json +{ + "agent": { + "single_file_review": true + } +} +``` + +## Terminal Threads {#terminal-threads} + +The Agent Panel can host terminal threads alongside your agent threads. Each terminal thread appears as its own entry in the [Threads Sidebar](./parallel-agents.md#threads-sidebar) with a terminal icon, letting you switch between conversations and shell sessions from the same list. + +External agents like Claude Agent and Codex can also run as terminal threads. Some support terminal signals — such as bell notifications or title updates — that Zed uses to show useful context in the sidebar. + +### Opening a Terminal Thread {#opening-a-terminal-thread} + +Open the menu using the agent selector button on the left (in the empty state) or the `+` icon in the top-right of the panel toolbar, and choose **Terminal**. The terminal thread opens in the panel body, just like switching to a thread. You can open as many as you like — each gets its own sidebar entry. + +### Terminal Thread Titles {#terminal-thread-titles} + +The terminal title in the toolbar updates automatically to reflect the running shell or process. You can also set a custom name by clicking the title or the pencil icon that appears on hover. + +### Notifications {#terminal-thread-notifications} + +When a terminal produces a bell character while not in focus, Zed notifies you the same way it does when an agent finishes — with a visual pop-up and an optional sound. Clicking the notification brings the terminal into focus and clears the indicator. The same `agent.notify_when_agent_waiting` and `agent.play_sound_when_agent_done` settings apply. + +### Closing Terminal Threads {#closing-terminal-threads} + +Unlike agent threads, terminal threads are closed rather than archived — they don't go to Thread History. To close one, hover over it in the Threads Sidebar and click the **×** button, or select it and press {#kb agent::ArchiveSelectedThread}. + +### Claude Code Notifications {#claude-code-notifications} + +Claude Code can notify you when it finishes a task or pauses for permission. To enable this, set `preferredNotifChannel` to `"terminal_bell"` in your Claude Code user settings: + +```json +{ + "preferredNotifChannel": "terminal_bell" +} +``` + +You can also set this from within Claude Code by running `/config`, selecting `Local Notifications`, and choosing `Terminal Bell`. + +> If you run Claude Code inside tmux, bell notifications may not reach the outer terminal unless passthrough is enabled. Add this to `~/.tmux.conf`: +> +> ``` +> set -g allow-passthrough on +> ``` + +For more, see the [Claude Code documentation](https://code.claude.com/docs/en/terminal-config). + +### Amp Notifications {#amp-notifications} + +Amp updates terminal titles automatically and can also notify you when it needs your attention. To enable notifications in Zed terminal threads, add `AMP_FORCE_BEL=1` to your terminal environment settings: + +```json [settings] +{ + "terminal": { + "env": { + "AMP_FORCE_BEL": "1" + } + } +} +``` + +Restart Amp after adding the environment variable. + +### OpenCode Notifications {#opencode-notifications} + +OpenCode can update terminal titles automatically. For Zed notifications, add an OpenCode plugin that emits a terminal bell when OpenCode needs your attention. + +Create `.opencode/plugins/zed-bell.js` in your project, or `~/.config/opencode/plugins/zed-bell.js` to use it globally: + +```js +export const ZedBell = async () => { + return { + event: async ({ event }) => { + if (event.type === "session.idle" || event.type === "permission.asked") { + process.stdout.write("\x07"); + } + }, + }; +}; +``` + +Restart OpenCode after adding the plugin. + +### Pi Notifications {#pi-notifications} + +Pi can use an extension to emit a notification when it finishes a turn. Create `.pi/extensions/zed-bell.ts` in your project, or `~/.pi/agent/extensions/zed-bell.ts` to use it globally: + +```ts +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("agent_end", async () => { + process.stdout.write("\x07"); + }); +} +``` + +Restart Pi after adding the extension, or run `/reload` if the extension is in one of Pi's auto-discovered extension locations. + +### Codex Terminal Titles {#codex-terminal-titles} + +Codex can update the terminal title as it works, which Zed uses to show useful context for Codex terminal threads in the sidebar — such as the project, current status, branch, model, or task progress. + +To configure this from within Codex, run `/title` and use the picker to choose which fields appear and in what order. Codex saves the selection to `tui.terminal_title` in `~/.codex/config.toml`. You can also edit it directly: + +```toml +[tui] +terminal_title = ["spinner", "project-name", "run-state", "thread-title"] +``` ## Adding Context {#adding-context} The agent can search your codebase to find relevant context, but providing it explicitly improves response quality and reduces latency. Add context by typing `@` in the message editor. -You can mention files, directories, symbols, previous threads, rules files, and diagnostics. +You can mention files, directories, symbols, previous threads, skills, and diagnostics. When you paste multi-line code selections copied from a buffer, Zed automatically formats them as @-mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly. diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 219f2fae1da..d5fc6750e83 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -30,6 +30,8 @@ For example, - You use $12 of incremental tokens in the month of February, with the first $10 spent on February 15. You'll receive an invoice for $10 on February 15. - On March 1, you receive an invoice for $12: $10 (March Pro subscription) and $2 in leftover token spend, since your usage didn't cross the $10 threshold. +For high-volume users, the threshold automatically scales up over time to keep invoicing manageable, so subsequent invoices may trigger at larger increments rather than every $10. + ### Payment failures {#payment-failures} If payment of an invoice fails, Zed will block usage of our hosted models until the payment is complete. Email [billing-support@zed.dev](mailto:billing-support@zed.dev) for assistance. diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3a8455a327c..3c08a960da8 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -656,6 +656,8 @@ By default, models from all subscription types are shown. Optionally, you can hi } ``` +**Note:** Zed only bundles configuration for long-term OpenCode Free models! Free models that are only available for a limited time are not included in Zed. To use such models, create a Custom Model using the configuration settings published on [the OpenCode website](https://opencode.ai/docs/zen#pricing) and on [models.dev](https://github.com/anomalyco/models.dev/tree/dev/providers/opencode/models). + #### Custom Models {#opencode-custom-models} The Zed agent comes pre-configured with OpenCode models. If you wish to use newer models or models with custom endpoints, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)): diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 0859d47c127..413b67ba595 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -18,7 +18,7 @@ Zed's AI features run inside a native, GPU-accelerated application built in Rust ## Agentic editing -The [Threads Sidebar](./parallel-agents.md#threads-sidebar) is where you organize agent work. Start a thread, give it a task, and the agent reads, edits, and runs code in your project. You can run multiple threads at once, each using a different agent and working against different projects. See [Tools](./tools.md) for the capabilities available to Zed's built-in agent. +The [Threads Sidebar](./parallel-agents.md#threads-sidebar) is where you organize agent work. Start a thread, give it a task, and the agent reads, edits, and runs code in your project. You can also open terminal threads directly in the sidebar alongside your agent threads. Run multiple agent threads and terminal threads at once, each using a different agent and working against different projects. See [Tools](./tools.md) for the capabilities available to Zed's built-in agent. The [Agent Panel](./agent-panel.md) is the conversation view for the active thread. Use it to send prompts, review changes, add context, and interact with the agent as it works. diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md index 17d61528eea..6c8fccea8d5 100644 --- a/docs/src/ai/parallel-agents.md +++ b/docs/src/ai/parallel-agents.md @@ -1,11 +1,11 @@ --- title: Parallel Agents - Zed -description: Run multiple agent threads concurrently using the Threads Sidebar, manage them across projects, and isolate work using Git worktrees. +description: Run multiple agent threads and terminal threads concurrently using the Threads Sidebar, manage them across projects, and isolate work using Git worktrees. --- # Parallel Agents -Parallel Agents lets you run multiple agent threads at once, each working independently with its own agent, context window, and conversation history. The Threads Sidebar is where you start, manage, and switch between them. +Parallel Agents lets you run multiple agent threads and terminal threads at once from the Threads Sidebar. Each thread works independently with its own agent, context window, and conversation history. Terminal threads appear alongside agent threads in the same sidebar, so you can switch between them without leaving the Agent Panel. Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar}. @@ -15,6 +15,8 @@ Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar}. The sidebar shows your threads grouped by project. Each project gets its own section with a header. Threads appear below with their title, status indicator, and which agent is running them. Threads running in linked Git worktrees appear under the same project as their main worktree. See [Worktree Isolation](#worktree-isolation). +Terminal threads also appear as entries in the sidebar alongside agent threads, identified by a terminal icon. Click one to switch to it. See [terminal threads](./agent-panel.md#terminal-threads) for details. + To focus the sidebar without toggling it, use {#kb multi_workspace::FocusWorkspaceSidebar}. To search your threads, press {#kb agents_sidebar::FocusSidebarFilter} while the sidebar is focused. ### Switching Threads {#switching-threads} diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 1fb47aa562d..9258be157e6 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -3,10 +3,12 @@ title: AI Rules in Zed - .rules, .cursorrules, CLAUDE.md description: Configure AI behavior in Zed with .rules files, .cursorrules, CLAUDE.md, AGENTS.md, and the Rules Library for project-level instructions. --- -# Using Rules {#using-rules} +# Rules {#rules} Rules are prompts that can be inserted either automatically at the beginning of each [Agent Panel](./agent-panel.md) interaction, through `.rules` files available in your project's file tree, or on-demand, through @-mentioning, via the Rules Library. +> **Note:** Starting in Zed v1.4.0, on-demand rules (and the rules library) have been replaced by [Skills](./skills.md). Skills are the recommended way to package reusable agent instructions. Learn more about [the rules -> skills migration](#migrating-to-skills). + ## `.rules` files Zed supports including `.rules` files at the root of a project's file tree, and they act as project-level instructions that are auto-included in all of your interactions with the Agent Panel. @@ -30,6 +32,8 @@ It's a full editor with syntax highlighting and all standard keybindings. You can also use the inline assistant right in the rules editor, allowing you to get quick LLM support for writing rules. +> **Note:** Starting in Zed v1.4.0, the rules library has been replaced by [Skills](./skills.md). Skills are the recommended way to package reusable agent instructions. Learn more about [the rules -> skills migration](#migrating-to-skills). + ### Opening the Rules Library 1. Open the Agent Panel. @@ -66,12 +70,11 @@ All rules in the Rules Library can be set as a default rule, which means they’ You can set any rule as the default by clicking the paper clip icon button in the top-right of the rule editor in the Rules Library. -## Migrating from Prompt Library +## Migrating to Skills {#migrating-to-skills} -Previously, the Rules Library was called the "Prompt Library". -The new rules system replaces the Prompt Library except in a few specific cases, which are outlined below. +When you update to Zed v1.4.0, your existing Rules are migrated to Skills automatically: -### Slash Commands in Rules +- **Non-default Rules** become global skills in `~/.agents/skills/`, each with `disable-model-invocation: true`. They remain user-invocable via `/skill-name` or `@`-mention. +- **Default Rules** are appended to your global `AGENTS.md` file (`~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows), preserving their behavior of being included in every conversation. -Previously, it was possible to use slash commands (now @-mentions) in custom prompts (now rules). -There is currently no support for using @-mentions in rules files. +A banner in the title bar announces the migration when it runs. Your original Rule data is not deleted, so downgrading to an earlier version of Zed leaves your Rules intact. diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md new file mode 100644 index 00000000000..2662b33e160 --- /dev/null +++ b/docs/src/ai/skills.md @@ -0,0 +1,203 @@ +--- +title: Agent Skills - Zed +description: Extend Zed's AI agent with reusable, on-demand skill files for specialized tasks. +--- + +# Skills {#skills} + +Skills are reusable instruction packages that give the agent specialized knowledge for specific tasks: test-driven development workflows, document processing, database integrations, or your team's internal coding standards. + +A skill is a folder containing a `SKILL.md` file with metadata and instructions. The agent sees a catalog of all installed skills and can load one on demand, or you can invoke any skill directly from the message editor with a slash command. + +## Adding Skills {#adding-skills} + +### Create your own {#create-your-own} + +Zed includes a built-in `create-skill` skill — invoke it with `/create-skill` and the agent walks you through the process. + +You can also open the Skill Creator from the Agent Panel using {#kb agent::OpenRulesLibrary}, or by clicking `...` and selecting **Skills**. Outside the panel, use the {#action agent::OpenSkillCreator} action from the command palette. It opens a window where you fill in the skill's name, description, scope (global or project-local), body, and optionally toggle `disable-model-invocation`. + +See [Skill format](#skill-format) below for the full format reference. + +### From the skills.sh Registry {#from-the-registry} + +[skills.sh](https://skills.sh) is a community registry of open-source skills. You'll find skills for popular frameworks, tools, workflows, and more: + +- [`find-skills`](https://skills.sh/vercel-labs/skills/find-skills): discover and install skills from the open ecosystem +- [`frontend-design`](https://skills.sh/anthropics/skills/frontend-design): production-grade frontend interfaces with design polish +- [`pdf`](https://skills.sh/anthropics/skills/pdf): PDF text extraction, merging, splitting, form filling, and OCR + +To install a skill, copy the skill's folder into `~/.agents/skills/` for global use, or into your project's `.agents/skills/` folder for project-local use. + +## Managing Skills {#managing-skills} + +Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). + +The **User** tab shows your global skills. The **Project** tab shows skills for the current project. + +For each skill you can: + +- **Open** — opens the skill's `SKILL.md` file in the editor +- **Delete** — removes the skill folder from disk + +If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. + +## Managing Skills {#managing-skills} + +Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). + +The **User** tab shows your global skills. The **Project** tab shows skills for the current project. + +For each skill you can: + +- **Open** — opens the skill's `SKILL.md` file in the editor +- **Delete** — removes the skill folder from disk + +If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. + +## Using Skills {#using-skills} + +By default, the agent picks up skills autonomously. It sees a catalog of every installed skill (name and description) in its system prompt, and calls the `skill` tool when a task matches a skill's description. + +When the agent invokes a skill, Zed prompts you to allow or deny it, using the same permission flow as other tools. You can set per-skill defaults in [Tool Permissions](./tool-permissions.md) so you're not prompted for skills you always trust. + +### Manual Invocation {#manual-invocation} + +You can also load a skill manually: + +- **Slash command**: type `/` in the message editor and select a skill by name +- **@-mention**: type `@skill` in the message editor and select a skill from the completion menu + +Both inject the skill's instructions as context. The loaded skill appears as a crease button in the thread, which you can click to open the skill file. + +### Preventing Autonomous Invocation {#disable-model-invocation} + +Add `disable-model-invocation: true` to a skill's frontmatter to stop the agent from picking it up autonomously. +The skill still appears as a slash command, so you stay in control of when it runs. + +This is useful for workflows you don't want the agent triggering automatically, like deploy or release procedures. + +```yaml +--- +name: deploy +description: Deploy the current branch to production. +disable-model-invocation: true +--- +``` + +## Skill Format {#skill-format} + +### Folder Structure {#folder-structure} + +A skill is a named folder containing a `SKILL.md` file: + +``` +my-skill/ +├── SKILL.md # Required: metadata and instructions +├── scripts/ # Optional: scripts the agent can run +├── references/ # Optional: additional documentation +└── assets/ # Optional: templates and static files +``` + +The folder name must match the `name` field in `SKILL.md`. + +### SKILL.md format {#skill-md-format} + +`SKILL.md` starts with YAML frontmatter, followed by Markdown instructions. + +**Minimal example:** + +```markdown +--- +name: my-skill +description: What this skill does and when to use it. +--- + +## Instructions + +Step-by-step instructions for the agent... +``` + +#### Frontmatter Fields {#frontmatter-fields} + +| Field | Required | Description | +| -------------------------- | -------- | -------------------------------------------------------------------------------------------- | +| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Must match the folder name. | +| `description` | Yes | What the skill does and when to use it. Max 1024 characters. | +| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (slash command only). | + +> **Tip:** Write descriptions that help the agent recognize when a skill is relevant. Include specific task types and trigger phrases: "Use when handling PDFs, extracting text, or filling forms" is better than "Helps with PDFs." + +We plan to include other fields promoted by [the Agent Skills specification](https://agentskills.io/specification) in the near future. + +#### Name Validation {#name-validation} + +The `name` field must: + +- Contain only lowercase letters (`a-z`), numbers, and hyphens +- Not start or end with a hyphen +- Not contain consecutive hyphens (`--`) +- Be 1 to 64 characters + +Skills with invalid names fail to load and surface an error in the UI. + +### Bundled Resources {#bundled-resources} + +Keep the body of `SKILL.md` under 500 lines. Move detailed material to reference files and link to them from the body: + +```markdown +See [reference guide](references/REFERENCE.md) for complete API details. + +Run the extraction script: +scripts/extract.py +``` + +The agent loads these files on demand using the `read_file` and `list_directory` tools. Global skills under `~/.agents/skills/` are accessible to the agent even though they're outside your project. + +### Writing Effective Instructions {#writing-instructions} + +Skills use [progressive disclosure](https://agentskills.io/specification#progressive-disclosure): the agent sees only the name and description until it activates a skill, then loads the full body. Structure your skill to take advantage of this: + +- Put the most important instructions near the top of the body +- Keep `SKILL.md` under 500 lines; move detailed references to `references/` +- Scripts that the agent needs to run go in `scripts/` + +See the [Agent Skills specification](https://agentskills.io/specification) for the full format reference. + +## Where Skills Live {#where-skills-live} + +Zed loads skills from two locations: + +| Scope | Path | When it applies | +| ------------- | ---------------------------- | ------------------------ | +| Global | `~/.agents/skills/` | Every project | +| Project-local | `/.agents/skills/` | Only the current project | + +Each skill is a direct child of the skills root. Nesting skills inside subfolders is not supported. + +### Project-local Skills and Trust {#project-local-trust} + +Project-local skills only load from [trusted worktrees](../worktree-trust.md). Skills from a freshly cloned or untrusted project are excluded from the catalog and slash commands until you grant trust. + +This prevents a malicious project from injecting instructions into your agent's system prompt before you've reviewed what the project ships. + +### Override Behavior {#override-behavior} + +If a global and a project-local skill share the same name, the project-local skill takes precedence. This lets a project customize or replace a global skill for its own context. + +### Editing Skill Files {#editing-skill-files} + +The agent cannot edit `SKILL.md` files or their bundled resources without your explicit authorization, even in a trusted project. This prevents a compromised conversation from modifying the skills that govern future conversations. + +## Limitations {#limitations} + +- **Flat layout only.** Skills must be direct children of the skills root. Nested folders like `~/.agents/skills/group/my-skill/` are not discovered. +- **50KB catalog budget.** The total size of all skill names and descriptions is capped at 50KB. Skills that don't fit are dropped from the catalog with a warning in the UI. Keep descriptions concise. +- **No remote registry.** Zed does not fetch skills from URLs or support custom search paths. Skills come from `~/.agents/skills/` and `/.agents/skills/` only. Use a symlink if you need to point at another location. +- **Live reload.** Adding, removing, or editing a `SKILL.md` takes effect immediately without restarting your session. Changes to a skill's `name` or `description` invalidate the model's prompt cache for the current session. + +## See also + +- [Agent Panel](./agent-panel.md) +- [Tool Permissions](./tool-permissions.md) +- [Agent Skills specification](https://agentskills.io/specification) diff --git a/docs/src/languages/opentofu.md b/docs/src/languages/opentofu.md index 7809099c055..e6bcdd2c3ef 100644 --- a/docs/src/languages/opentofu.md +++ b/docs/src/languages/opentofu.md @@ -7,7 +7,7 @@ description: "Configure OpenTofu language support in Zed, including language ser OpenTofu support is available through the [OpenTofu extension](https://github.com/ashpool37/zed-extension-opentofu). -- Tree-sitter: [MichaHoffmann/tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Tree-sitter: [tree-sitter-grammars/tree-sitter-hcl](https://github.com/tree-sitter-grammars/tree-sitter-hcl) - Language Server: [opentofu/tofu-ls](https://github.com/opentofu/tofu-ls) ## Configuration diff --git a/docs/src/languages/terraform.md b/docs/src/languages/terraform.md index a7f87d20f46..a7e12b68187 100644 --- a/docs/src/languages/terraform.md +++ b/docs/src/languages/terraform.md @@ -7,7 +7,7 @@ description: "Configure Terraform language support in Zed, including language se Terraform support is available through the [Terraform extension](https://github.com/zed-extensions/terraform). -- Tree-sitter: [MichaHoffmann/tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Tree-sitter: [tree-sitter-grammars/tree-sitter-hcl](https://github.com/tree-sitter-grammars/tree-sitter-hcl) - Language Server: [hashicorp/terraform-ls](https://github.com/hashicorp/terraform-ls) ## Configuration diff --git a/script/bundle-linux b/script/bundle-linux index 3487feaf32b..b510365acb4 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -151,10 +151,12 @@ cp "${target_dir}/${target_triple}/release/zed" "${zed_dir}/libexec/zed-editor" cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed" # Libs +# Bundle libstdc++ so older supported systems can run binaries built with our +# toolchain even when their system libstdc++.so.6 lacks required GLIBCXX symbols. find_libs() { ldd ${target_dir}/${target_triple}/release/zed |\ cut -d' ' -f3 |\ - grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' + grep -v '\<\(libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' } mkdir -p "${zed_dir}/lib" diff --git a/script/trigger-docs-build b/script/trigger-docs-build new file mode 100755 index 00000000000..3d429e0097d --- /dev/null +++ b/script/trigger-docs-build @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +which gh >/dev/null || brew install gh + +case "${1:-}" in + preview | stable) + channel="$1" + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +case "${2:-}" in + "") + from_main=false + ;; + --from-main) + from_main=true + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +version=$(./script/get-released-version "$channel") +branch=$(echo "$version" | sed -E 's/^([0-9]+)\.([0-9]+)\.[0-9]+$/v\1.\2.x/') +workflow_ref="$branch" +if [ "$from_main" = true ]; then + workflow_ref="main" +fi + +echo "Triggering docs build for $channel ($branch) using workflow from $workflow_ref" +echo "This will publish docs from $branch before the next release." +echo "Only continue if $branch has no unreleased feature-specific docs." +read -r -p "Continue? [y/N] " confirmation +case "$confirmation" in + y | Y | yes | YES) + ;; + *) + echo "Cancelled." + exit 1 + ;; +esac + +gh workflow run "deploy_docs.yml" --ref "$workflow_ref" -f channel="$channel" -f checkout_ref="$branch" +echo "Follow along at: https://github.com/zed-industries/zed/actions/workflows/deploy_docs.yml" diff --git a/tooling/xtask/src/tasks/setup_webrtc.rs b/tooling/xtask/src/tasks/setup_webrtc.rs index 756a3767838..5dbf5bcaa96 100644 --- a/tooling/xtask/src/tasks/setup_webrtc.rs +++ b/tooling/xtask/src/tasks/setup_webrtc.rs @@ -219,31 +219,47 @@ fn update_cargo_config(webrtc_path: &Path) -> Result<()> { .or_else(|| std::env::var_os("USERPROFILE")) .context("could not determine home directory")?; let config_path = PathBuf::from(home).join(".cargo").join("config.toml"); - if config_path.exists() { - bail!( - "{} already exists; refusing to modify it. \ - Add `[env]\\n{ENV_VAR} = \"{}\"` yourself, \ - or re-run with --no-cargo-config.", - config_path.display(), - webrtc_path.display(), - ); - } if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } - let mut doc = DocumentMut::new(); - let mut env_table = Table::new(); - env_table.set_implicit(false); - let path_str = webrtc_path - .to_str() - .context("webrtc path is not valid UTF-8")?; - env_table.insert(ENV_VAR, value(path_str)); - doc.insert("env", Item::Table(env_table)); + let existing_content = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("reading {}", config_path.display()))? + } else { + String::new() + }; + + let mut doc = existing_content + .parse::() + .with_context(|| format!("parsing existing {}", config_path.display()))?; + + let env_table = doc + .entry("env") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .context("`env` entry is not a table")?; + + let cleaned_path = clean_webrtc_path(webrtc_path)?; + env_table.insert(ENV_VAR, value(cleaned_path.clone())); fs::write(&config_path, doc.to_string()) .with_context(|| format!("writing {}", config_path.display()))?; - eprintln!("Wrote {} with {ENV_VAR}={path_str}", config_path.display()); + + eprintln!( + "Updated {} with {ENV_VAR}={cleaned_path}", + config_path.display() + ); Ok(()) } + +fn clean_webrtc_path(path: &Path) -> Result { + let path_str = path.to_str().context("webrtc path is not valid UTF-8")?; + let mut cleaned = path_str.to_string(); + if cleaned.starts_with(r"\\?\") { + cleaned = cleaned[4..].to_string(); + } + cleaned = cleaned.replace('\\', "/"); + Ok(cleaned) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 56aeb677eac..f93415f2077 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -14,7 +14,7 @@ use crate::tasks::workflows::{ vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7"; +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "2a00db06ce6d01089bfafd207b6348078e980df9"; // This should follow the set target in crates/extension/src/extension_builder.rs const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2";