diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index d8ddb679ad..a38354c317 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -1,7 +1,7 @@ name: "Close Stale Issues" on: schedule: - - cron: "0 7,9,11 * * 2" + - cron: "0 7,9,11 * * 3" workflow_dispatch: jobs: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 8cd415e0c7..140e91eb9a 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -182,8 +182,7 @@ jobs: runner: buildjet-16vcpu-ubuntu-2204 install_nix: true - os: arm Mac - # TODO: once other macs are provisioned for nix, remove that constraint from the runner - runner: [macOS, ARM64, nix] + runner: [macOS, ARM64, test] install_nix: false - os: arm Linux runner: buildjet-16vcpu-ubuntu-2204-arm diff --git a/Cargo.lock b/Cargo.lock index 2cbdc0a2a2..3f75c3ae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,8 +491,8 @@ dependencies = [ "prompt_store", "proto", "rand 0.8.5", + "release_channel", "rope", - "scripting_tool", "serde", "serde_json", "settings", @@ -699,6 +699,7 @@ dependencies = [ "collections", "derive_more", "gpui", + "icons", "language", "language_model", "parking_lot", @@ -3198,6 +3199,7 @@ dependencies = [ "extension", "futures 0.3.31", "gpui", + "icons", "language_model", "log", "parking_lot", @@ -3244,9 +3246,7 @@ name = "copilot" version = "0.1.0" dependencies = [ "anyhow", - "async-compression", "async-std", - "async-tar", "chrono", "client", "clock", @@ -3274,7 +3274,6 @@ dependencies = [ "serde", "serde_json", "settings", - "smol", "strum", "task", "theme", @@ -4521,12 +4520,6 @@ dependencies = [ "regex", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" version = "0.10.2" @@ -5646,7 +5639,6 @@ dependencies = [ "askpass", "assistant_settings", "buffer_diff", - "chrono", "collections", "command_palette_hooks", "component", @@ -6499,6 +6491,14 @@ dependencies = [ "cc", ] +[[package]] +name = "icons" +version = "0.1.0" +dependencies = [ + "serde", + "strum", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -7312,6 +7312,7 @@ dependencies = [ "google_ai", "gpui", "http_client", + "icons", "image", "log", "open_ai", @@ -7324,7 +7325,6 @@ dependencies = [ "strum", "telemetry_events", "thiserror 2.0.12", - "ui", "util", ] @@ -7931,25 +7931,6 @@ dependencies = [ "url", ] -[[package]] -name = "lua-src" -version = "547.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" -dependencies = [ - "cc", -] - -[[package]] -name = "luajit-src" -version = "210.5.12+a4f56a4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" -dependencies = [ - "cc", - "which 7.0.2", -] - [[package]] name = "lyon" version = "1.0.1" @@ -8365,34 +8346,6 @@ dependencies = [ "strum", ] -[[package]] -name = "mlua" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3f763c1041eff92ffb5d7169968a327e1ed2ebfe425dac0ee5a35f29082534b" -dependencies = [ - "bstr", - "either", - "futures-util", - "mlua-sys", - "num-traits", - "parking_lot", - "rustc-hash 2.1.1", -] - -[[package]] -name = "mlua-sys" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1901c1a635a22fe9250ffcc4fcc937c16b47c2e9e71adba8784af8bca1f69594" -dependencies = [ - "cc", - "cfg-if", - "lua-src", - "luajit-src", - "pkg-config", -] - [[package]] name = "msvc_spectre_libs" version = "0.1.2" @@ -9675,7 +9628,7 @@ name = "perplexity" version = "0.1.0" dependencies = [ "serde", - "zed_extension_api 0.3.0", + "zed_extension_api 0.4.0", ] [[package]] @@ -12201,30 +12154,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" -[[package]] -name = "scripting_tool" -version = "0.1.0" -dependencies = [ - "anyhow", - "buffer_diff", - "clock", - "collections", - "futures 0.3.31", - "gpui", - "language", - "log", - "mlua", - "parking_lot", - "project", - "rand 0.8.5", - "regex", - "schemars", - "serde", - "serde_json", - "settings", - "util", -] - [[package]] name = "scrypt" version = "0.11.0" @@ -14276,10 +14205,11 @@ version = "0.1.0" dependencies = [ "auto_update", "call", + "chrono", "client", "collections", + "db", "feature_flags", - "git_ui", "gpui", "http_client", "notifications", @@ -14300,7 +14230,6 @@ dependencies = [ "windows 0.61.1", "workspace", "zed_actions", - "zeta", ] [[package]] @@ -15008,6 +14937,7 @@ dependencies = [ "chrono", "component", "gpui", + "icons", "itertools 0.14.0", "linkme", "menu", @@ -16186,18 +16116,6 @@ dependencies = [ "winsafe", ] -[[package]] -name = "which" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2774c861e1f072b3aadc02f8ba886c26ad6321567ecc294c935434cad06f1283" -dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", -] - [[package]] name = "whoami" version = "1.5.2" @@ -17464,6 +17382,7 @@ dependencies = [ "theme_extension", "theme_selector", "time", + "title_bar", "toolchain_selector", "tree-sitter-md", "tree-sitter-rust", @@ -17513,7 +17432,7 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "serde_json", @@ -17571,7 +17490,7 @@ dependencies = [ name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.3.0", + "zed_extension_api 0.4.0", ] [[package]] @@ -17722,7 +17641,6 @@ dependencies = [ "anyhow", "arrayvec", "call", - "chrono", "client", "clock", "collections", diff --git a/Cargo.toml b/Cargo.toml index da56a70944..fa8b799a9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ members = [ "crates/html_to_markdown", "crates/http_client", "crates/http_client_tls", + "crates/icons", "crates/image_viewer", "crates/indexed_docs", "crates/inline_completion", @@ -124,7 +125,6 @@ members = [ "crates/rope", "crates/rpc", "crates/schema_generator", - "crates/scripting_tool", "crates/search", "crates/semantic_index", "crates/semantic_version", @@ -275,6 +275,7 @@ gpui_tokio = { path = "crates/gpui_tokio" } html_to_markdown = { path = "crates/html_to_markdown" } http_client = { path = "crates/http_client" } http_client_tls = { path = "crates/http_client_tls" } +icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } indexed_docs = { path = "crates/indexed_docs" } inline_completion = { path = "crates/inline_completion" } @@ -329,7 +330,6 @@ reqwest_client = { path = "crates/reqwest_client" } rich_text = { path = "crates/rich_text" } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -scripting_tool = { path = "crates/scripting_tool" } search = { path = "crates/search" } semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } diff --git a/assets/icons/arrow_right_left.svg b/assets/icons/arrow_right_left.svg new file mode 100644 index 0000000000..30331960c9 --- /dev/null +++ b/assets/icons/arrow_right_left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/clipboard.svg b/assets/icons/clipboard.svg new file mode 100644 index 0000000000..5c8842f3b7 --- /dev/null +++ b/assets/icons/clipboard.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/cog.svg b/assets/icons/cog.svg new file mode 100644 index 0000000000..03c0a290b7 --- /dev/null +++ b/assets/icons/cog.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6b848292aa..ee507aa45f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -195,7 +195,7 @@ "ctrl-shift-g": "search::SelectPreviousMatch", "ctrl-alt-/": "assistant::ToggleModelSelector", "ctrl-k h": "assistant::DeployHistory", - "ctrl-k l": "assistant::DeployPromptLibrary", + "ctrl-k l": "assistant::OpenPromptLibrary", "new": "assistant::NewChat", "ctrl-t": "assistant::NewChat", "ctrl-n": "assistant::NewChat" diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 63f258c5bc..3cd69d1444 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -241,7 +241,7 @@ "cmd-shift-g": "search::SelectPreviousMatch", "cmd-alt-/": "assistant::ToggleModelSelector", "cmd-k h": "assistant::DeployHistory", - "cmd-k l": "assistant::DeployPromptLibrary", + "cmd-k l": "assistant::OpenPromptLibrary", "cmd-t": "assistant::NewChat", "cmd-n": "assistant::NewChat" } diff --git a/assets/prompts/assistant_system_prompt.hbs b/assets/prompts/assistant_system_prompt.hbs index ecc0dfd09b..a377a1ae85 100644 --- a/assets/prompts/assistant_system_prompt.hbs +++ b/assets/prompts/assistant_system_prompt.hbs @@ -11,7 +11,7 @@ You should only perform actions that modify the user’s system if explicitly re Be concise and direct in your responses. -The user has opened a project that contains the following root directories/files: +The user has opened a project that contains the following root directories/files. Whenever you specify a path in the project, it must be a relative path which begins with one of these root directories/files: {{#each worktrees}} - `{{root_name}}` (absolute path: `{{abs_path}}`) diff --git a/assets/settings/default.json b/assets/settings/default.json index b63f22247f..eca9fa5a68 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -25,7 +25,7 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "copilot" + "edit_prediction_provider": "zed" }, // The name of a font to use for rendering text in the editor "buffer_font_family": "Zed Plex Mono", @@ -184,6 +184,11 @@ // Whether to show the signature help after completion or a bracket pair inserted. // If `auto_signature_help` is enabled, this setting will be treated as enabled also. "show_signature_help_after_edits": false, + // What to do when go to definition yields no results. + // + // 1. Do nothing: `none` + // 2. Find references for the same symbol: `find_all_references` (default) + "go_to_definition_fallback": "find_all_references", // Whether to show wrap guides (vertical rulers) in the editor. // Setting this to true will show a guide at the 'preferred_line_length' value // if 'soft_wrap' is set to 'preferred_line_length', and will show any @@ -633,11 +638,15 @@ "name": "Code Writer", "tools": { "bash": true, + "copy-path": true, + "create-file": true, "delete-path": true, "diagnostics": true, - "edit-files": true, + "find-replace-file": true, + "edit-files": false, "fetch": true, "list-directory": true, + "move-path": true, "now": true, "path-search": true, "read-file": true, @@ -645,7 +654,8 @@ "thinking": true } } - } + }, + "notify_when_agent_waiting": true }, // The settings for slash commands. "slash_commands": { @@ -1255,6 +1265,14 @@ "allowed": true } }, + "LaTeX": { + "format_on_save": "on", + "formatter": "language_server", + "language_servers": ["texlab", "..."], + "prettier": { + "allowed": false + } + }, "Markdown": { "format_on_save": "off", "use_on_type_format": false, diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 29833d1229..6621899b7c 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -38,7 +38,7 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, pane, DraggedSelection, Pane, ShowConfiguration, ToggleZoom, Workspace, }; -use zed_actions::assistant::{DeployPromptLibrary, InlineAssist, ToggleFocus}; +use zed_actions::assistant::{InlineAssist, OpenPromptLibrary, ToggleFocus}; pub fn init(cx: &mut App) { workspace::FollowableViewRegistry::register::(cx); @@ -259,7 +259,7 @@ impl AssistantPanel { menu.context(focus_handle.clone()) .action("New Chat", Box::new(NewChat)) .action("History", Box::new(DeployHistory)) - .action("Prompt Library", Box::new(DeployPromptLibrary)) + .action("Prompt Library", Box::new(OpenPromptLibrary)) .action("Configure", Box::new(ShowConfiguration)) .action(zoom_label, Box::new(ToggleZoom)) })) @@ -1028,7 +1028,7 @@ impl AssistantPanel { fn deploy_prompt_library( &mut self, - _: &DeployPromptLibrary, + _: &OpenPromptLibrary, _window: &mut Window, cx: &mut Context, ) { diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 07b214bed0..d198bd19ae 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -61,8 +61,8 @@ project.workspace = true prompt_library.workspace = true prompt_store.workspace = true proto.workspace = true +release_channel.workspace = true rope.workspace = true -scripting_tool.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 2d648f7027..72a678d085 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -3,8 +3,10 @@ use crate::thread::{ ThreadEvent, ThreadFeedback, }; use crate::thread_store::ThreadStore; -use crate::tool_use::{PendingToolUseStatus, ToolType, ToolUse, ToolUseStatus}; -use crate::ui::ContextPill; +use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus}; +use crate::ui::{ContextPill, ToolReadyPopUp, ToolReadyPopupEvent}; + +use assistant_settings::AssistantSettings; use collections::HashMap; use editor::{Editor, MultiBuffer}; use gpui::{ @@ -12,11 +14,11 @@ use gpui::{ Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty, Entity, Focusable, Length, ListAlignment, ListOffset, ListState, ScrollHandle, StyleRefinement, Subscription, Task, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + WindowHandle, }; use language::{Buffer, LanguageRegistry}; use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role}; use markdown::{Markdown, MarkdownStyle}; -use scripting_tool::{ScriptingTool, ScriptingToolInput}; use settings::Settings as _; use std::sync::Arc; use std::time::Duration; @@ -37,12 +39,12 @@ pub struct ActiveThread { messages: Vec, list_state: ListState, rendered_messages_by_id: HashMap, - rendered_scripting_tool_uses: HashMap>, rendered_tool_use_labels: HashMap>, editing_message: Option<(MessageId, EditMessageState)>, expanded_tool_uses: HashMap, expanded_thinking_segments: HashMap<(MessageId, usize), bool>, last_error: Option, + pop_ups: Vec>, _subscriptions: Vec, } @@ -233,7 +235,6 @@ impl ActiveThread { save_thread_task: None, messages: Vec::new(), rendered_messages_by_id: HashMap::default(), - rendered_scripting_tool_uses: HashMap::default(), rendered_tool_use_labels: HashMap::default(), expanded_tool_uses: HashMap::default(), expanded_thinking_segments: HashMap::default(), @@ -246,6 +247,7 @@ impl ActiveThread { }), editing_message: None, last_error: None, + pop_ups: Vec::new(), _subscriptions: subscriptions, }; @@ -260,26 +262,6 @@ impl ActiveThread { cx, ); } - - for tool_use in thread - .read(cx) - .scripting_tool_uses_for_message(message.id, cx) - { - this.render_tool_use_label_markdown( - tool_use.id.clone(), - tool_use.ui_text.clone(), - window, - cx, - ); - - this.render_scripting_tool_use_markdown( - tool_use.id.clone(), - tool_use.ui_text.as_ref(), - tool_use.input.clone(), - window, - cx, - ); - } } this @@ -360,36 +342,6 @@ impl ActiveThread { self.rendered_messages_by_id.remove(id); } - /// Renders the input of a scripting tool use to Markdown. - /// - /// Does nothing if the tool use does not correspond to the scripting tool. - fn render_scripting_tool_use_markdown( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: &str, - tool_input: serde_json::Value, - window: &mut Window, - cx: &mut Context, - ) { - if tool_name != ScriptingTool::NAME { - return; - } - - let lua_script = serde_json::from_value::(tool_input) - .map(|input| input.lua_script) - .unwrap_or_default(); - - let lua_script = render_markdown( - format!("```lua\n{lua_script}\n```").into(), - self.language_registry.clone(), - window, - cx, - ); - - self.rendered_scripting_tool_uses - .insert(tool_use_id, lua_script); - } - fn render_tool_use_label_markdown( &mut self, tool_use_id: LanguageModelToolUseId, @@ -422,7 +374,14 @@ impl ActiveThread { ThreadEvent::StreamedCompletion | ThreadEvent::SummaryChanged => { self.save_thread(cx); } - ThreadEvent::DoneStreaming => {} + ThreadEvent::DoneStreaming => { + if !self.thread().read(cx).is_generating() { + self.show_notification("Your changes have been applied.", window, cx); + } + } + ThreadEvent::ToolConfirmationNeeded => { + self.show_notification("There's a tool confirmation needed.", window, cx); + } ThreadEvent::StreamedAssistantText(message_id, text) => { if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { rendered_message.append_text(text, window, cx); @@ -476,13 +435,6 @@ impl ActiveThread { window, cx, ); - self.render_scripting_tool_use_markdown( - tool_use.id, - tool_use.name.as_ref(), - tool_use.input.clone(), - window, - cx, - ); } } ThreadEvent::ToolFinished { @@ -556,6 +508,59 @@ impl ActiveThread { } } + fn show_notification( + &mut self, + caption: impl Into, + window: &mut Window, + cx: &mut Context<'_, ActiveThread>, + ) { + if !window.is_window_active() + && self.pop_ups.is_empty() + && AssistantSettings::get_global(cx).notify_when_agent_waiting + { + let caption = caption.into(); + + for screen in cx.displays() { + let options = ToolReadyPopUp::window_options(screen, cx); + + if let Some(screen_window) = cx + .open_window(options, |_, cx| { + cx.new(|_| ToolReadyPopUp::new(caption.clone())) + }) + .log_err() + { + if let Some(pop_up) = screen_window.entity(cx).log_err() { + cx.subscribe_in(&pop_up, window, { + |this, _, event, window, cx| match event { + ToolReadyPopupEvent::Accepted => { + let handle = window.window_handle(); + cx.activate(true); // Switch back to the Zed application + + // If there are multiple Zed windows, activate the correct one. + cx.defer(move |cx| { + handle + .update(cx, |_view, window, _cx| { + window.activate_window(); + }) + .log_err(); + }); + + this.dismiss_notifications(cx); + } + ToolReadyPopupEvent::Dismissed => { + this.dismiss_notifications(cx); + } + } + }) + .detach(); + + self.pop_ups.push(screen_window); + } + } + } + } + } + /// Spawns a task to save the active thread. /// /// Only one task to save the thread will be in flight at a time. @@ -725,13 +730,9 @@ impl ActiveThread { let checkpoint = thread.checkpoint_for_message(message_id); let context = thread.context_for_message(message_id); let tool_uses = thread.tool_uses_for_message(message_id, cx); - let scripting_tool_uses = thread.scripting_tool_uses_for_message(message_id, cx); // Don't render user messages that are just there for returning tool results. - if message.role == Role::User - && (thread.message_has_tool_results(message_id) - || thread.message_has_scripting_tool_results(message_id)) - { + if message.role == Role::User && thread.message_has_tool_results(message_id) { return Empty.into_any(); } @@ -996,32 +997,23 @@ impl ActiveThread { ) .child(div().p_2().child(message_content)), ), - Role::Assistant => { - v_flex() - .id(("message-container", ix)) - .ml_2() - .pl_2() - .pr_4() - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .child(message_content) - .when( - !tool_uses.is_empty() || !scripting_tool_uses.is_empty(), - |parent| { - parent.child( - v_flex() - .children( - tool_uses - .into_iter() - .map(|tool_use| self.render_tool_use(tool_use, cx)), - ) - .children(scripting_tool_uses.into_iter().map(|tool_use| { - self.render_scripting_tool_use(tool_use, cx) - })), - ) - }, + Role::Assistant => v_flex() + .id(("message-container", ix)) + .ml_2() + .pl_2() + .pr_4() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .child(message_content) + .when(!tool_uses.is_empty(), |parent| { + parent.child( + v_flex().children( + tool_uses + .into_iter() + .map(|tool_use| self.render_tool_use(tool_use, cx)), + ), ) - } + }), Role::System => div().id(("message-container", ix)).py_1().px_2().child( v_flex() .bg(colors.editor_background) @@ -1095,6 +1087,7 @@ impl ActiveThread { parent.child( h_flex() + .pt_2p5() .px_2p5() .w_full() .gap_1() @@ -1323,21 +1316,6 @@ impl ActiveThread { let lighter_border = cx.theme().colors().border.opacity(0.5); - let tool_icon = match tool_use.name.as_ref() { - "bash" => IconName::Terminal, - "delete-path" => IconName::Trash, - "diagnostics" => IconName::Warning, - "edit-files" => IconName::Pencil, - "fetch" => IconName::Globe, - "list-directory" => IconName::Folder, - "now" => IconName::Info, - "path-search" => IconName::SearchCode, - "read-file" => IconName::Eye, - "regex-search" => IconName::Regex, - "thinking" => IconName::Brain, - _ => IconName::Terminal, - }; - div().py_2().child( v_flex() .rounded_lg() @@ -1363,7 +1341,7 @@ impl ActiveThread { h_flex() .gap_1p5() .child( - Icon::new(tool_icon) + Icon::new(tool_use.icon) .size(IconSize::XSmall) .color(Color::Muted), ) @@ -1537,145 +1515,6 @@ impl ActiveThread { ) } - fn render_scripting_tool_use( - &self, - tool_use: ToolUse, - cx: &mut Context, - ) -> impl IntoElement { - let is_open = self - .expanded_tool_uses - .get(&tool_use.id) - .copied() - .unwrap_or_default(); - - div().px_2p5().child( - v_flex() - .gap_1() - .rounded_lg() - .border_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .justify_between() - .py_0p5() - .pl_1() - .pr_2() - .bg(cx.theme().colors().editor_foreground.opacity(0.02)) - .map(|element| { - if is_open { - element.border_b_1().rounded_t_md() - } else { - element.rounded_md() - } - }) - .border_color(cx.theme().colors().border) - .child( - h_flex() - .gap_1() - .child(Disclosure::new("tool-use-disclosure", is_open).on_click( - cx.listener({ - let tool_use_id = tool_use.id.clone(); - move |this, _event, _window, _cx| { - let is_open = this - .expanded_tool_uses - .entry(tool_use_id.clone()) - .or_insert(false); - - *is_open = !*is_open; - } - }), - )) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::Terminal) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .text_ui_sm(cx) - .children( - self.rendered_tool_use_labels - .get(&tool_use.id) - .cloned(), - ) - .truncate(), - ), - ), - ) - .child( - Label::new(match tool_use.status { - ToolUseStatus::Pending => "Pending", - ToolUseStatus::Running => "Running", - ToolUseStatus::Finished(_) => "Finished", - ToolUseStatus::Error(_) => "Error", - ToolUseStatus::NeedsConfirmation => "Asking Permission", - }) - .size(LabelSize::XSmall) - .buffer_font(cx), - ), - ) - .map(|parent| { - if !is_open { - return parent; - } - - let lua_script_markdown = - self.rendered_scripting_tool_uses.get(&tool_use.id).cloned(); - - parent.child( - v_flex() - .child( - v_flex() - .gap_0p5() - .py_1() - .px_2p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(Label::new("Input:")) - .map(|parent| { - if let Some(markdown) = lua_script_markdown { - parent.child(markdown) - } else { - parent.child(Label::new( - "Failed to render script input to Markdown", - )) - } - }), - ) - .map(|parent| match tool_use.status { - ToolUseStatus::Finished(output) => parent.child( - v_flex() - .gap_0p5() - .py_1() - .px_2p5() - .child(Label::new("Result:")) - .child(Label::new(output)), - ), - ToolUseStatus::Error(err) => parent.child( - v_flex() - .gap_0p5() - .py_1() - .px_2p5() - .child(Label::new("Error:")) - .child(Label::new(err)), - ), - ToolUseStatus::Pending | ToolUseStatus::Running => parent, - ToolUseStatus::NeedsConfirmation => parent.child( - v_flex() - .gap_0p5() - .py_1() - .px_2p5() - .child(Label::new("Asking Permission")), - ), - }), - ) - }), - ) - } - fn render_rules_item(&self, cx: &Context) -> AnyElement { let Some(system_prompt_context) = self.thread.read(cx).system_prompt_context().as_ref() else { @@ -1751,7 +1590,7 @@ impl ActiveThread { c.ui_text.clone(), c.input.clone(), &c.messages, - c.tool_type.clone(), + c.tool.clone(), cx, ); }); @@ -1761,13 +1600,12 @@ impl ActiveThread { fn handle_deny_tool( &mut self, tool_use_id: LanguageModelToolUseId, - tool_type: ToolType, _: &ClickEvent, _window: &mut Window, cx: &mut Context, ) { self.thread.update(cx, |thread, cx| { - thread.deny_tool_use(tool_use_id, tool_type, cx); + thread.deny_tool_use(tool_use_id, cx); }); } @@ -1802,7 +1640,7 @@ impl ActiveThread { thread .tools_needing_confirmation() - .map(|(tool_type, tool)| { + .map(|tool| { div() .m_3() .p_2() @@ -1844,7 +1682,6 @@ impl ActiveThread { move |this, event, window, cx| { this.handle_deny_tool( tool_id.clone(), - tool_type.clone(), event, window, cx, @@ -1862,6 +1699,16 @@ impl ActiveThread { .into_any() }) } + + fn dismiss_notifications(&mut self, cx: &mut Context<'_, ActiveThread>) { + for window in self.pop_ups.drain(..) { + window + .update(cx, |_, window, _| { + window.remove_window(); + }) + .ok(); + } + } } impl Render for ActiveThread { diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 5224b097cc..6e97bd9d8e 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -32,6 +32,7 @@ use prompt_store::PromptBuilder; use settings::Settings as _; pub use crate::active_thread::ActiveThread; +use crate::assistant_configuration::AddContextServerModal; pub use crate::assistant_panel::{AssistantPanel, ConcreteAssistantPanelDelegate}; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread::{Message, RequestKind, Thread, ThreadEvent}; @@ -46,6 +47,7 @@ actions!( RemoveAllContext, OpenHistory, OpenConfiguration, + AddContextServer, RemoveSelectedThread, Chat, ChatMode, @@ -86,6 +88,7 @@ pub fn init( client.telemetry().clone(), cx, ); + cx.observe_new(AddContextServerModal::register).detach(); feature_gate_assistant2_actions(cx); } diff --git a/crates/assistant2/src/assistant_configuration.rs b/crates/assistant2/src/assistant_configuration.rs index c03b7c13d5..b215709be2 100644 --- a/crates/assistant2/src/assistant_configuration.rs +++ b/crates/assistant2/src/assistant_configuration.rs @@ -1,3 +1,5 @@ +mod add_context_server_modal; + use std::sync::Arc; use assistant_tool::{ToolSource, ToolWorkingSet}; @@ -5,13 +7,14 @@ use collections::HashMap; use context_server::manager::ContextServerManager; use gpui::{Action, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription}; use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; -use ui::{ - prelude::*, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, Switch, Tooltip, -}; +use ui::{prelude::*, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, Switch}; use util::ResultExt as _; -use zed_actions::assistant::DeployPromptLibrary; use zed_actions::ExtensionCategoryFilter; +pub(crate) use add_context_server_modal::AddContextServerModal; + +use crate::AddContextServer; + pub struct AssistantConfiguration { focus_handle: FocusHandle, configuration_views_by_provider: HashMap, @@ -170,7 +173,6 @@ impl AssistantConfiguration { v_flex() .p(DynamicSpacing::Base16.rems(cx)) - .mt_1() .gap_2() .flex_1() .child( @@ -309,8 +311,9 @@ impl AssistantConfiguration { .icon(IconName::Plus) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) - .disabled(true) - .tooltip(Tooltip::text("Not yet implemented")), + .on_click(|_event, window, cx| { + window.dispatch_action(AddContextServer.boxed_clone(), cx) + }), ), ) .child( @@ -352,33 +355,6 @@ impl Render for AssistantConfiguration { .bg(cx.theme().colors().panel_background) .size_full() .overflow_y_scroll() - .child( - v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .gap_2() - .child( - v_flex() - .gap_0p5() - .child(Headline::new("Prompt Library").size(HeadlineSize::Small)) - .child( - Label::new("Create reusable prompts and tag which ones you want sent in every LLM interaction.") - .color(Color::Muted), - ), - ) - .child( - Button::new("open-prompt-library", "Open Prompt Library") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .full_width() - .icon(IconName::Book) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(|_event, window, cx| { - window.dispatch_action(DeployPromptLibrary.boxed_clone(), cx) - }), - ), - ) - .child(Divider::horizontal().color(DividerColor::Border)) .child(self.render_context_servers_section(cx)) .child(Divider::horizontal().color(DividerColor::Border)) .child( diff --git a/crates/assistant2/src/assistant_configuration/add_context_server_modal.rs b/crates/assistant2/src/assistant_configuration/add_context_server_modal.rs new file mode 100644 index 0000000000..1cfb97f4be --- /dev/null +++ b/crates/assistant2/src/assistant_configuration/add_context_server_modal.rs @@ -0,0 +1,164 @@ +use context_server::{ContextServerSettings, ServerCommand, ServerConfig}; +use editor::Editor; +use gpui::{prelude::*, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity}; +use serde_json::json; +use settings::update_settings_file; +use ui::{prelude::*, Modal, ModalFooter, ModalHeader, Section, Tooltip}; +use workspace::{ModalView, Workspace}; + +use crate::AddContextServer; + +pub struct AddContextServerModal { + workspace: WeakEntity, + name_editor: Entity, + command_editor: Entity, +} + +impl AddContextServerModal { + pub fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + _cx: &mut Context, + ) { + workspace.register_action(|workspace, _: &AddContextServer, window, cx| { + let workspace_handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + Self::new(workspace_handle, window, cx) + }) + }); + } + + pub fn new( + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let name_editor = cx.new(|cx| Editor::single_line(window, cx)); + let command_editor = cx.new(|cx| Editor::single_line(window, cx)); + + name_editor.update(cx, |editor, cx| { + editor.set_placeholder_text("Context server name", cx); + }); + + command_editor.update(cx, |editor, cx| { + editor.set_placeholder_text("Command to run the context server", cx); + }); + + Self { + name_editor, + command_editor, + workspace, + } + } + + fn confirm(&mut self, cx: &mut Context) { + let name = self.name_editor.read(cx).text(cx).trim().to_string(); + let command = self.command_editor.read(cx).text(cx).trim().to_string(); + + if name.is_empty() || command.is_empty() { + return; + } + + let mut command_parts = command.split(' ').map(|part| part.trim().to_string()); + let Some(path) = command_parts.next() else { + return; + }; + let args = command_parts.collect::>(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + update_settings_file::(fs.clone(), cx, |settings, _| { + settings.context_servers.insert( + name.into(), + ServerConfig { + command: Some(ServerCommand { + path, + args, + env: None, + }), + settings: Some(json!({})), + }, + ); + }); + }); + } + + cx.emit(DismissEvent); + } + + fn cancel(&mut self, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl ModalView for AddContextServerModal {} + +impl Focusable for AddContextServerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.name_editor.focus_handle(cx).clone() + } +} + +impl EventEmitter for AddContextServerModal {} + +impl Render for AddContextServerModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_name_empty = self.name_editor.read(cx).text(cx).trim().is_empty(); + let is_command_empty = self.command_editor.read(cx).text(cx).trim().is_empty(); + + div() + .elevation_3(cx) + .w(rems(34.)) + .key_context("AddContextServerModal") + .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(cx))) + .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx))) + .capture_any_mouse_down(cx.listener(|this, _, window, cx| { + this.focus_handle(cx).focus(window); + })) + .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) + .child( + Modal::new("add-context-server", None) + .header(ModalHeader::new().headline("Add Context Server")) + .section( + Section::new() + .child( + v_flex() + .gap_1() + .child(Label::new("Name")) + .child(self.name_editor.clone()), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Command")) + .child(self.command_editor.clone()), + ), + ) + .footer( + ModalFooter::new() + .start_slot( + Button::new("cancel", "Cancel").on_click( + cx.listener(|this, _event, _window, cx| this.cancel(cx)), + ), + ) + .end_slot( + Button::new("add-server", "Add Server") + .disabled(is_name_empty || is_command_empty) + .map(|button| { + if is_name_empty { + button.tooltip(Tooltip::text("Name is required")) + } else if is_command_empty { + button.tooltip(Tooltip::text("Command is required")) + } else { + button + } + }) + .on_click( + cx.listener(|this, _event, _window, cx| this.confirm(cx)), + ), + ), + ), + ) + } +} diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index c38aaff44f..3c08bb60b4 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -14,9 +14,9 @@ use client::zed_urls; use editor::{Editor, MultiBuffer}; use fs::Fs; use gpui::{ - prelude::*, Action, AnyElement, App, AsyncWindowContext, Corner, Entity, EventEmitter, - FocusHandle, Focusable, FontWeight, KeyContext, Pixels, Subscription, Task, UpdateGlobal, - WeakEntity, + action_with_deprecated_aliases, prelude::*, Action, AnyElement, App, AsyncWindowContext, + Corner, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, Pixels, + Subscription, Task, UpdateGlobal, WeakEntity, }; use language::LanguageRegistry; use language_model::{LanguageModelProviderTosView, LanguageModelRegistry}; @@ -29,7 +29,7 @@ use ui::{prelude::*, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, Ta use util::ResultExt as _; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; -use zed_actions::assistant::{DeployPromptLibrary, ToggleFocus}; +use zed_actions::assistant::ToggleFocus; use crate::active_thread::ActiveThread; use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent}; @@ -43,6 +43,12 @@ use crate::{ OpenHistory, }; +action_with_deprecated_aliases!( + assistant, + OpenPromptLibrary, + ["assistant::DeployPromptLibrary"] +); + pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { @@ -65,6 +71,14 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); } }) + .register_action(|workspace, _: &OpenPromptLibrary, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| { + panel.deploy_prompt_library(&OpenPromptLibrary, window, cx) + }); + } + }) .register_action(|workspace, _: &OpenConfiguration, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -303,7 +317,7 @@ impl AssistantPanel { fn deploy_prompt_library( &mut self, - _: &DeployPromptLibrary, + _: &OpenPromptLibrary, _window: &mut Window, cx: &mut Context, ) { diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs index 013fe717b2..d9dba7d0c8 100644 --- a/crates/assistant2/src/context_picker.rs +++ b/crates/assistant2/src/context_picker.rs @@ -1,19 +1,28 @@ +mod completion_provider; mod fetch_context_picker; mod file_context_picker; mod thread_context_picker; +use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; use anyhow::{anyhow, Result}; -use editor::Editor; +use editor::display_map::{Crease, FoldId}; +use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset}; use file_context_picker::render_file_context_entry; -use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; +use gpui::{ + App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, +}; +use multi_buffer::MultiBufferRow; use project::ProjectPath; use thread_context_picker::{render_thread_context_entry, ThreadContextEntry}; -use ui::{prelude::*, ContextMenu, ContextMenuEntry, ContextMenuItem}; +use ui::{ + prelude::*, ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, +}; use workspace::{notifications::NotifyResultExt, Workspace}; +pub use crate::context_picker::completion_provider::ContextPickerCompletionProvider; use crate::context_picker::fetch_context_picker::FetchContextPicker; use crate::context_picker::file_context_picker::FileContextPicker; use crate::context_picker::thread_context_picker::ThreadContextPicker; @@ -34,10 +43,31 @@ enum ContextPickerMode { Thread, } +impl TryFrom<&str> for ContextPickerMode { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "file" => Ok(Self::File), + "fetch" => Ok(Self::Fetch), + "thread" => Ok(Self::Thread), + _ => Err(format!("Invalid context picker mode: {}", value)), + } + } +} + impl ContextPickerMode { + pub fn mention_prefix(&self) -> &'static str { + match self { + Self::File => "file", + Self::Fetch => "fetch", + Self::Thread => "thread", + } + } + pub fn label(&self) -> &'static str { match self { - Self::File => "File/Directory", + Self::File => "Files & Directories", Self::Fetch => "Fetch", Self::Thread => "Thread", } @@ -63,7 +93,6 @@ enum ContextPickerState { pub(super) struct ContextPicker { mode: ContextPickerState, workspace: WeakEntity, - editor: WeakEntity, context_store: WeakEntity, thread_store: Option>, confirm_behavior: ConfirmBehavior, @@ -74,7 +103,6 @@ impl ContextPicker { workspace: WeakEntity, thread_store: Option>, context_store: WeakEntity, - editor: WeakEntity, confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, @@ -88,7 +116,6 @@ impl ContextPicker { workspace, context_store, thread_store, - editor, confirm_behavior, } } @@ -109,10 +136,7 @@ impl ContextPicker { .enumerate() .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); - let mut modes = vec![ContextPickerMode::File, ContextPickerMode::Fetch]; - if self.allow_threads() { - modes.push(ContextPickerMode::Thread); - } + let modes = supported_context_picker_modes(&self.thread_store); let menu = menu .when(has_recent, |menu| { @@ -174,7 +198,6 @@ impl ContextPicker { FileContextPicker::new( context_picker.clone(), self.workspace.clone(), - self.editor.clone(), self.context_store.clone(), self.confirm_behavior, window, @@ -278,7 +301,7 @@ impl ContextPicker { }; let task = context_store.update(cx, |context_store, cx| { - context_store.add_file_from_path(project_path.clone(), cx) + context_store.add_file_from_path(project_path.clone(), true, cx) }); cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx)) @@ -308,7 +331,7 @@ impl ContextPicker { cx.spawn(async move |this, cx| { let thread = open_thread_task.await?; context_store.update(cx, |context_store, cx| { - context_store.add_thread(thread, cx); + context_store.add_thread(thread, true, cx); })?; this.update(cx, |_this, cx| cx.notify()) @@ -328,7 +351,7 @@ impl ContextPicker { let mut current_files = context_store.file_paths(cx); - if let Some(active_path) = Self::active_singleton_buffer_path(&workspace, cx) { + if let Some(active_path) = active_singleton_buffer_path(&workspace, cx) { current_files.insert(active_path); } @@ -384,16 +407,6 @@ impl ContextPicker { recent } - - fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option { - let active_item = workspace.active_item(cx)?; - - let editor = active_item.to_any().downcast::().ok()?.read(cx); - let buffer = editor.buffer().read(cx).as_singleton()?; - - let path = buffer.read(cx).file()?.path().to_path_buf(); - Some(path) - } } impl EventEmitter for ContextPicker {} @@ -429,3 +442,212 @@ enum RecentEntry { }, Thread(ThreadContextEntry), } + +fn supported_context_picker_modes( + thread_store: &Option>, +) -> Vec { + let mut modes = vec![ContextPickerMode::File, ContextPickerMode::Fetch]; + if thread_store.is_some() { + modes.push(ContextPickerMode::Thread); + } + modes +} + +fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option { + let active_item = workspace.active_item(cx)?; + + let editor = active_item.to_any().downcast::().ok()?.read(cx); + let buffer = editor.buffer().read(cx).as_singleton()?; + + let path = buffer.read(cx).file()?.path().to_path_buf(); + Some(path) +} + +fn recent_context_picker_entries( + context_store: Entity, + thread_store: Option>, + workspace: Entity, + cx: &App, +) -> Vec { + let mut recent = Vec::with_capacity(6); + + let mut current_files = context_store.read(cx).file_paths(cx); + + let workspace = workspace.read(cx); + + if let Some(active_path) = active_singleton_buffer_path(workspace, cx) { + current_files.insert(active_path); + } + + let project = workspace.project().read(cx); + + recent.extend( + workspace + .recent_navigation_history_iter(cx) + .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf())) + .take(4) + .filter_map(|(project_path, _)| { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|worktree| RecentEntry::File { + project_path, + path_prefix: worktree.read(cx).root_name().into(), + }) + }), + ); + + let mut current_threads = context_store.read(cx).thread_ids(); + + if let Some(active_thread) = workspace + .panel::(cx) + .map(|panel| panel.read(cx).active_thread(cx)) + { + current_threads.insert(active_thread.read(cx).id().clone()); + } + + if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) { + recent.extend( + thread_store + .read(cx) + .threads() + .into_iter() + .filter(|thread| !current_threads.contains(&thread.id)) + .take(2) + .map(|thread| { + RecentEntry::Thread(ThreadContextEntry { + id: thread.id, + summary: thread.summary, + }) + }), + ); + } + + recent +} + +pub(crate) fn insert_crease_for_mention( + excerpt_id: ExcerptId, + crease_start: text::Anchor, + content_len: usize, + crease_label: SharedString, + crease_icon_path: SharedString, + editor_entity: Entity, + window: &mut Window, + cx: &mut App, +) { + editor_entity.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + + let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else { + return; + }; + + let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); + + let placeholder = FoldPlaceholder { + render: render_fold_icon_button( + crease_icon_path, + crease_label, + editor_entity.downgrade(), + ), + ..Default::default() + }; + + let render_trailer = + move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); + + let crease = Crease::inline( + start..end, + placeholder.clone(), + fold_toggle("mention"), + render_trailer, + ); + + editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + }); +} + +fn render_fold_icon_button( + icon_path: SharedString, + label: SharedString, + editor: WeakEntity, +) -> Arc, &mut App) -> AnyElement> { + Arc::new({ + move |fold_id, fold_range, cx| { + let is_in_text_selection = editor.upgrade().is_some_and(|editor| { + editor.update(cx, |editor, cx| { + let snapshot = editor + .buffer() + .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx)); + + let is_in_pending_selection = || { + editor + .selections + .pending + .as_ref() + .is_some_and(|pending_selection| { + pending_selection + .selection + .range() + .includes(&fold_range, &snapshot) + }) + }; + + let mut is_in_complete_selection = || { + editor + .selections + .disjoint_in_range::(fold_range.clone(), cx) + .into_iter() + .any(|selection| { + // This is needed to cover a corner case, if we just check for an existing + // selection in the fold range, having a cursor at the start of the fold + // marks it as selected. Non-empty selections don't cause this. + let length = selection.end - selection.start; + length > 0 + }) + }; + + is_in_pending_selection() || is_in_complete_selection() + }) + }); + + ButtonLike::new(fold_id) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(icon_path.clone()) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(label.clone()) + .size(LabelSize::Small) + .single_line(), + ), + ) + .into_any_element() + } + }) +} + +fn fold_toggle( + name: &'static str, +) -> impl Fn( + MultiBufferRow, + bool, + Arc, + &mut Window, + &mut App, +) -> AnyElement { + move |row, is_folded, fold, _window, _cx| { + Disclosure::new((name, row.0 as u64), !is_folded) + .toggle_state(is_folded) + .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) + .into_any_element() + } +} diff --git a/crates/assistant2/src/context_picker/completion_provider.rs b/crates/assistant2/src/context_picker/completion_provider.rs new file mode 100644 index 0000000000..8570c47449 --- /dev/null +++ b/crates/assistant2/src/context_picker/completion_provider.rs @@ -0,0 +1,1024 @@ +use std::cell::RefCell; +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::Result; +use editor::{CompletionProvider, Editor, ExcerptId}; +use file_icons::FileIcons; +use gpui::{App, Entity, Task, WeakEntity}; +use http_client::HttpClientWithUrl; +use language::{Buffer, CodeLabel, HighlightId}; +use lsp::CompletionContext; +use project::{Completion, CompletionIntent, ProjectPath, WorktreeId}; +use rope::Point; +use text::{Anchor, ToPoint}; +use ui::prelude::*; +use workspace::Workspace; + +use crate::context::AssistantContext; +use crate::context_store::ContextStore; +use crate::thread_store::ThreadStore; + +use super::fetch_context_picker::fetch_url_content; +use super::thread_context_picker::ThreadContextEntry; +use super::{recent_context_picker_entries, supported_context_picker_modes, ContextPickerMode}; + +pub struct ContextPickerCompletionProvider { + workspace: WeakEntity, + context_store: WeakEntity, + thread_store: Option>, + editor: WeakEntity, +} + +impl ContextPickerCompletionProvider { + pub fn new( + workspace: WeakEntity, + context_store: WeakEntity, + thread_store: Option>, + editor: WeakEntity, + ) -> Self { + Self { + workspace, + context_store, + thread_store, + editor, + } + } + + fn default_completions( + excerpt_id: ExcerptId, + source_range: Range, + context_store: Entity, + thread_store: Option>, + editor: Entity, + workspace: Entity, + cx: &App, + ) -> Vec { + let mut completions = Vec::new(); + + completions.extend( + recent_context_picker_entries( + context_store.clone(), + thread_store.clone(), + workspace.clone(), + cx, + ) + .iter() + .filter_map(|entry| match entry { + super::RecentEntry::File { + project_path, + path_prefix: _, + } => Self::completion_for_path( + project_path.clone(), + true, + false, + excerpt_id, + source_range.clone(), + editor.clone(), + context_store.clone(), + workspace.clone(), + cx, + ), + super::RecentEntry::Thread(thread_context_entry) => { + let thread_store = thread_store + .as_ref() + .and_then(|thread_store| thread_store.upgrade())?; + Some(Self::completion_for_thread( + thread_context_entry.clone(), + excerpt_id, + source_range.clone(), + true, + editor.clone(), + context_store.clone(), + thread_store, + )) + } + }), + ); + + completions.extend( + supported_context_picker_modes(&thread_store) + .iter() + .map(|mode| { + Completion { + old_range: source_range.clone(), + new_text: format!("@{} ", mode.mention_prefix()), + label: CodeLabel::plain(mode.label().to_string(), None), + icon_path: Some(mode.icon().path().into()), + documentation: None, + source: project::CompletionSource::Custom, + // This ensures that when a user accepts this completion, the + // completion menu will still be shown after "@category " is + // inserted + confirm: Some(Arc::new(|_, _, _| true)), + } + }), + ); + completions + } + + fn full_path_for_entry( + worktree_id: WorktreeId, + path: &Path, + workspace: Entity, + cx: &App, + ) -> Option { + let worktree = workspace + .read(cx) + .project() + .read(cx) + .worktree_for_id(worktree_id, cx)? + .read(cx); + + let mut full_path = PathBuf::from(worktree.root_name()); + full_path.push(path); + Some(full_path) + } + + fn build_code_label_for_full_path( + worktree_id: WorktreeId, + path: &Path, + workspace: Entity, + cx: &App, + ) -> Option { + let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); + let mut label = CodeLabel::default(); + let worktree = workspace + .read(cx) + .project() + .read(cx) + .worktree_for_id(worktree_id, cx)?; + + let entry = worktree.read(cx).entry_for_path(&path)?; + let file_name = path.file_name()?.to_string_lossy(); + label.push_str(&file_name, None); + if entry.is_dir() { + label.push_str("/ ", None); + } else { + label.push_str(" ", None); + }; + + let mut path_hint = PathBuf::from(worktree.read(cx).root_name()); + if let Some(path_to_entry) = path.parent() { + path_hint.push(path_to_entry); + } + label.push_str(&path_hint.to_string_lossy(), comment_id); + + label.filter_range = 0..label.text().len(); + + Some(label) + } + + fn completion_for_thread( + thread_entry: ThreadContextEntry, + excerpt_id: ExcerptId, + source_range: Range, + recent: bool, + editor: Entity, + context_store: Entity, + thread_store: Entity, + ) -> Completion { + let icon_for_completion = if recent { + IconName::HistoryRerun + } else { + IconName::MessageCircle + }; + let new_text = format!("@thread {}", thread_entry.summary); + let new_text_len = new_text.len(); + Completion { + old_range: source_range.clone(), + new_text, + label: CodeLabel::plain(thread_entry.summary.to_string(), None), + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(icon_for_completion.path().into()), + confirm: Some(confirm_completion_callback( + IconName::MessageCircle.path().into(), + thread_entry.summary.clone(), + excerpt_id, + source_range.start, + new_text_len, + editor.clone(), + move |cx| { + let thread_id = thread_entry.id.clone(); + let context_store = context_store.clone(); + let thread_store = thread_store.clone(); + cx.spawn(async move |cx| { + let thread = thread_store + .update(cx, |thread_store, cx| { + thread_store.open_thread(&thread_id, cx) + })? + .await?; + context_store.update(cx, |context_store, cx| { + context_store.add_thread(thread, false, cx) + }) + }) + .detach_and_log_err(cx); + }, + )), + } + } + + fn completion_for_fetch( + source_range: Range, + url_to_fetch: SharedString, + excerpt_id: ExcerptId, + editor: Entity, + context_store: Entity, + http_client: Arc, + ) -> Completion { + let new_text = format!("@fetch {}", url_to_fetch); + let new_text_len = new_text.len(); + Completion { + old_range: source_range.clone(), + new_text, + label: CodeLabel::plain(url_to_fetch.to_string(), None), + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(IconName::Globe.path().into()), + confirm: Some(confirm_completion_callback( + IconName::Globe.path().into(), + url_to_fetch.clone(), + excerpt_id, + source_range.start, + new_text_len, + editor.clone(), + move |cx| { + let context_store = context_store.clone(); + let http_client = http_client.clone(); + let url_to_fetch = url_to_fetch.clone(); + cx.spawn(async move |cx| { + if context_store.update(cx, |context_store, _| { + context_store.includes_url(&url_to_fetch).is_some() + })? { + return Ok(()); + } + let content = cx + .background_spawn(fetch_url_content( + http_client, + url_to_fetch.to_string(), + )) + .await?; + context_store.update(cx, |context_store, _| { + context_store.add_fetched_url(url_to_fetch.to_string(), content) + }) + }) + .detach_and_log_err(cx); + }, + )), + } + } + + fn completion_for_path( + project_path: ProjectPath, + is_recent: bool, + is_directory: bool, + excerpt_id: ExcerptId, + source_range: Range, + editor: Entity, + context_store: Entity, + workspace: Entity, + cx: &App, + ) -> Option { + let label = Self::build_code_label_for_full_path( + project_path.worktree_id, + &project_path.path, + workspace.clone(), + cx, + )?; + let full_path = Self::full_path_for_entry( + project_path.worktree_id, + &project_path.path, + workspace.clone(), + cx, + )?; + + let crease_icon_path = if is_directory { + FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(&full_path, cx).unwrap_or_else(|| IconName::File.path().into()) + }; + let completion_icon_path = if is_recent { + IconName::HistoryRerun.path().into() + } else { + crease_icon_path.clone() + }; + + let crease_name = project_path + .path + .file_name() + .map(|file_name| file_name.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + + let new_text = format!("@file {}", full_path.to_string_lossy()); + let new_text_len = new_text.len(); + Some(Completion { + old_range: source_range.clone(), + new_text, + label, + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(completion_icon_path), + confirm: Some(confirm_completion_callback( + crease_icon_path, + crease_name.into(), + excerpt_id, + source_range.start, + new_text_len, + editor, + move |cx| { + context_store.update(cx, |context_store, cx| { + let task = if is_directory { + context_store.add_directory(project_path.clone(), false, cx) + } else { + context_store.add_file_from_path(project_path.clone(), false, cx) + }; + task.detach_and_log_err(cx); + }) + }, + )), + }) + } +} + +impl CompletionProvider for ContextPickerCompletionProvider { + fn completions( + &self, + excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: Anchor, + _trigger: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>>> { + let state = buffer.update(cx, |buffer, _cx| { + let position = buffer_position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + let line = lines.next()?; + MentionCompletion::try_parse(line, offset_to_line) + }); + let Some(state) = state else { + return Task::ready(Ok(None)); + }; + + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Ok(None)); + }; + let Some(context_store) = self.context_store.upgrade() else { + return Task::ready(Ok(None)); + }; + + let snapshot = buffer.read(cx).snapshot(); + let source_range = snapshot.anchor_after(state.source_range.start) + ..snapshot.anchor_before(state.source_range.end); + + let thread_store = self.thread_store.clone(); + let editor = self.editor.clone(); + let http_client = workspace.read(cx).client().http_client().clone(); + + cx.spawn(async move |_, cx| { + let mut completions = Vec::new(); + + let MentionCompletion { + mode: category, + argument, + .. + } = state; + + let query = argument.unwrap_or_else(|| "".to_string()); + match category { + Some(ContextPickerMode::File) => { + let path_matches = cx + .update(|cx| { + super::file_context_picker::search_paths( + query, + Arc::new(AtomicBool::default()), + &workspace, + cx, + ) + })? + .await; + + completions.reserve(path_matches.len()); + cx.update(|cx| { + completions.extend(path_matches.iter().filter_map(|mat| { + let editor = editor.upgrade()?; + Self::completion_for_path( + ProjectPath { + worktree_id: WorktreeId::from_usize(mat.worktree_id), + path: mat.path.clone(), + }, + false, + mat.is_dir, + excerpt_id, + source_range.clone(), + editor.clone(), + context_store.clone(), + workspace.clone(), + cx, + ) + })); + })?; + } + Some(ContextPickerMode::Fetch) => { + if let Some(editor) = editor.upgrade() { + if !query.is_empty() { + completions.push(Self::completion_for_fetch( + source_range.clone(), + query.into(), + excerpt_id, + editor.clone(), + context_store.clone(), + http_client.clone(), + )); + } + + context_store.update(cx, |store, _| { + let urls = store.context().iter().filter_map(|context| { + if let AssistantContext::FetchedUrl(context) = context { + Some(context.url.clone()) + } else { + None + } + }); + for url in urls { + completions.push(Self::completion_for_fetch( + source_range.clone(), + url, + excerpt_id, + editor.clone(), + context_store.clone(), + http_client.clone(), + )); + } + })?; + } + } + Some(ContextPickerMode::Thread) => { + if let Some((thread_store, editor)) = thread_store + .and_then(|thread_store| thread_store.upgrade()) + .zip(editor.upgrade()) + { + let threads = cx + .update(|cx| { + super::thread_context_picker::search_threads( + query, + thread_store.clone(), + cx, + ) + })? + .await; + for thread in threads { + completions.push(Self::completion_for_thread( + thread.clone(), + excerpt_id, + source_range.clone(), + false, + editor.clone(), + context_store.clone(), + thread_store.clone(), + )); + } + } + } + None => { + cx.update(|cx| { + if let Some(editor) = editor.upgrade() { + completions.extend(Self::default_completions( + excerpt_id, + source_range.clone(), + context_store.clone(), + thread_store.clone(), + editor, + workspace.clone(), + cx, + )); + } + })?; + } + } + Ok(Some(completions)) + }) + } + + fn resolve_completions( + &self, + _buffer: Entity, + _completion_indices: Vec, + _completions: Rc>>, + _cx: &mut Context, + ) -> Task> { + Task::ready(Ok(true)) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + _: &str, + _: bool, + cx: &mut Context, + ) -> bool { + let buffer = buffer.read(cx); + let position = position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + if let Some(line) = lines.next() { + MentionCompletion::try_parse(line, offset_to_line) + .map(|completion| { + completion.source_range.start <= offset_to_line + position.column as usize + && completion.source_range.end >= offset_to_line + position.column as usize + }) + .unwrap_or(false) + } else { + false + } + } + + fn sort_completions(&self) -> bool { + false + } +} + +fn confirm_completion_callback( + crease_icon_path: SharedString, + crease_text: SharedString, + excerpt_id: ExcerptId, + start: Anchor, + content_len: usize, + editor: Entity, + add_context_fn: impl Fn(&mut App) -> () + Send + Sync + 'static, +) -> Arc bool + Send + Sync> { + Arc::new(move |_, window, cx| { + add_context_fn(cx); + + let crease_text = crease_text.clone(); + let crease_icon_path = crease_icon_path.clone(); + let editor = editor.clone(); + window.defer(cx, move |window, cx| { + crate::context_picker::insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text, + crease_icon_path, + editor, + window, + cx, + ); + }); + false + }) +} + +#[derive(Debug, Default, PartialEq)] +struct MentionCompletion { + source_range: Range, + mode: Option, + argument: Option, +} + +impl MentionCompletion { + fn try_parse(line: &str, offset_to_line: usize) -> Option { + let last_mention_start = line.rfind('@')?; + if last_mention_start >= line.len() { + return Some(Self::default()); + } + let rest_of_line = &line[last_mention_start + 1..]; + + let mut mode = None; + let mut argument = None; + + let mut parts = rest_of_line.split_whitespace(); + let mut end = last_mention_start + 1; + if let Some(mode_text) = parts.next() { + end += mode_text.len(); + mode = ContextPickerMode::try_from(mode_text).ok(); + match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) { + Some(whitespace_count) => { + if let Some(argument_text) = parts.next() { + argument = Some(argument_text.to_string()); + end += whitespace_count + argument_text.len(); + } + } + None => { + // Rest of line is entirely whitespace + end += rest_of_line.len() - mode_text.len(); + } + } + } + + Some(Self { + source_range: last_mention_start + offset_to_line..end + offset_to_line, + mode, + argument, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Focusable, TestAppContext, VisualTestContext}; + use project::{Project, ProjectPath}; + use serde_json::json; + use settings::SettingsStore; + use std::{ops::Deref, path::PathBuf}; + use util::{path, separator}; + use workspace::AppState; + + #[test] + fn test_mention_completion_parse() { + assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!( + MentionCompletion::try_parse("Lorem @", 0), + Some(MentionCompletion { + source_range: 6..7, + mode: None, + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @file", 0), + Some(MentionCompletion { + source_range: 6..11, + mode: Some(ContextPickerMode::File), + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @file ", 0), + Some(MentionCompletion { + source_range: 6..12, + mode: Some(ContextPickerMode::File), + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @file main.rs", 0), + Some(MentionCompletion { + source_range: 6..19, + mode: Some(ContextPickerMode::File), + argument: Some("main.rs".to_string()), + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @file main.rs ", 0), + Some(MentionCompletion { + source_range: 6..19, + mode: Some(ContextPickerMode::File), + argument: Some("main.rs".to_string()), + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0), + Some(MentionCompletion { + source_range: 6..19, + mode: Some(ContextPickerMode::File), + argument: Some("main.rs".to_string()), + }) + ); + } + + #[gpui::test] + async fn test_context_completion_provider(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "editor": "", + "a": { + "one.txt": "", + "two.txt": "", + "three.txt": "", + "four.txt": "" + }, + "b": { + "five.txt": "", + "six.txt": "", + "seven.txt": "", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let paths = vec![ + separator!("a/one.txt"), + separator!("a/two.txt"), + separator!("a/three.txt"), + separator!("a/four.txt"), + separator!("b/five.txt"), + separator!("b/six.txt"), + separator!("b/seven.txt"), + ]; + for path in paths { + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: Path::new(path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + } + + //TODO: Construct the editor without an actual buffer that points to a file + let item = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: PathBuf::from("editor").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("Could not open test file"); + + let editor = cx.update(|_, cx| { + item.act_as::(cx) + .expect("Opened test file wasn't an editor") + }); + + let context_store = cx.new(|_| ContextStore::new(workspace.downgrade())); + + let editor_entity = editor.downgrade(); + editor.update_in(&mut cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new( + workspace.downgrade(), + context_store.downgrade(), + None, + editor_entity, + )))); + }); + + cx.simulate_input("Lorem "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem "); + assert!(!editor.has_visible_completions_menu()); + }); + + cx.simulate_input("@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[ + format!("seven.txt {}", separator!("dir/b")).as_str(), + format!("six.txt {}", separator!("dir/b")).as_str(), + format!("five.txt {}", separator!("dir/b")).as_str(), + format!("four.txt {}", separator!("dir/a")).as_str(), + "Files & Directories", + "Fetch" + ] + ); + }); + + // Select and confirm "File" + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @file "); + assert!(editor.has_visible_completions_menu()); + }); + + cx.simulate_input("one"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @file one"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + vec![format!("one.txt {}", separator!("dir/a")).as_str(),] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem @file {}", separator!("dir/a/one.txt")) + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![Point::new(0, 6)..Point::new(0, 25)] + ); + }); + + cx.simulate_input(" "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem @file {} ", separator!("dir/a/one.txt")) + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![Point::new(0, 6)..Point::new(0, 25)] + ); + }); + + cx.simulate_input("Ipsum "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem @file {} Ipsum ", separator!("dir/a/one.txt")) + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![Point::new(0, 6)..Point::new(0, 25)] + ); + }); + + cx.simulate_input("@file "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem @file {} Ipsum @file ", separator!("dir/a/one.txt")) + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![Point::new(0, 6)..Point::new(0, 25)] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem @file {} Ipsum @file {}", + separator!("dir/a/one.txt"), + separator!("dir/b/seven.txt") + ) + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![ + Point::new(0, 6)..Point::new(0, 25), + Point::new(0, 32)..Point::new(0, 53) + ] + ); + }); + + cx.simulate_input("\n@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem @file {} Ipsum @file {}\n@", + separator!("dir/a/one.txt"), + separator!("dir/b/seven.txt") + ) + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![ + Point::new(0, 6)..Point::new(0, 25), + Point::new(0, 32)..Point::new(0, 53) + ] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem @file {} Ipsum @file {}\n@file {}", + separator!("dir/a/one.txt"), + separator!("dir/b/seven.txt"), + separator!("dir/b/six.txt"), + ) + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!( + crease_ranges(editor, cx), + vec![ + Point::new(0, 6)..Point::new(0, 25), + Point::new(0, 32)..Point::new(0, 53), + Point::new(1, 0)..Point::new(1, 19) + ] + ); + }); + } + + fn crease_ranges(editor: &Editor, cx: &mut App) -> Vec> { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor.display_map.update(cx, |display_map, cx| { + display_map + .snapshot(cx) + .crease_snapshot + .crease_items_with_offsets(&snapshot) + .into_iter() + .map(|(_, range)| range) + .collect() + }) + } + + fn current_completion_labels(editor: &Editor) -> Vec { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| completion.label.text.to_string()) + .collect::>() + } + + pub(crate) fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + editor::init_settings(cx); + }); + } +} diff --git a/crates/assistant2/src/context_picker/fetch_context_picker.rs b/crates/assistant2/src/context_picker/fetch_context_picker.rs index 35e92b5fe3..1a4b943946 100644 --- a/crates/assistant2/src/context_picker/fetch_context_picker.rs +++ b/crates/assistant2/src/context_picker/fetch_context_picker.rs @@ -81,77 +81,80 @@ impl FetchContextPickerDelegate { url: String::new(), } } +} - async fn build_message(http_client: Arc, url: String) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - format!("https://{url}") - } else { - url - }; +pub(crate) async fn fetch_url_content( + http_client: Arc, + url: String, +) -> Result { + let url = if !url.starts_with("https://") && !url.starts_with("http://") { + format!("https://{url}") + } else { + url + }; - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let mut response = http_client.get(&url, AsyncBody::default(), true).await?; - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading response body")?; + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading response body")?; - if response.status().is_client_error() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let Some(content_type) = response.headers().get("content-type") else { + bail!("missing Content-Type header"); + }; + let content_type = content_type + .to_str() + .context("invalid Content-Type header")?; + let content_type = match content_type { + "text/html" => ContentType::Html, + "text/plain" => ContentType::Plaintext, + "application/json" => ContentType::Json, + _ => ContentType::Html, + }; + + match content_type { + ContentType::Html => { + let mut handlers: Vec = vec![ + Rc::new(RefCell::new(markdown::WebpageChromeRemover)), + Rc::new(RefCell::new(markdown::ParagraphHandler)), + Rc::new(RefCell::new(markdown::HeadingHandler)), + Rc::new(RefCell::new(markdown::ListHandler)), + Rc::new(RefCell::new(markdown::TableHandler::new())), + Rc::new(RefCell::new(markdown::StyledTextHandler)), + ]; + if url.contains("wikipedia.org") { + use html_to_markdown::structure::wikipedia; + + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); + handlers.push(Rc::new( + RefCell::new(wikipedia::WikipediaCodeHandler::new()), + )); + } else { + handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); + } + + convert_html_to_markdown(&body[..], &mut handlers) } + ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Json => { + let json: serde_json::Value = serde_json::from_slice(&body)?; - let Some(content_type) = response.headers().get("content-type") else { - bail!("missing Content-Type header"); - }; - let content_type = content_type - .to_str() - .context("invalid Content-Type header")?; - let content_type = match content_type { - "text/html" => ContentType::Html, - "text/plain" => ContentType::Plaintext, - "application/json" => ContentType::Json, - _ => ContentType::Html, - }; - - match content_type { - ContentType::Html => { - let mut handlers: Vec = vec![ - Rc::new(RefCell::new(markdown::WebpageChromeRemover)), - Rc::new(RefCell::new(markdown::ParagraphHandler)), - Rc::new(RefCell::new(markdown::HeadingHandler)), - Rc::new(RefCell::new(markdown::ListHandler)), - Rc::new(RefCell::new(markdown::TableHandler::new())), - Rc::new(RefCell::new(markdown::StyledTextHandler)), - ]; - if url.contains("wikipedia.org") { - use html_to_markdown::structure::wikipedia; - - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); - handlers.push(Rc::new( - RefCell::new(wikipedia::WikipediaCodeHandler::new()), - )); - } else { - handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); - } - - convert_html_to_markdown(&body[..], &mut handlers) - } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), - ContentType::Json => { - let json: serde_json::Value = serde_json::from_slice(&body)?; - - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) - } + Ok(format!( + "```json\n{}\n```", + serde_json::to_string_pretty(&json)? + )) } } } @@ -208,7 +211,7 @@ impl PickerDelegate for FetchContextPickerDelegate { let confirm_behavior = self.confirm_behavior; cx.spawn_in(window, async move |this, cx| { let text = cx - .background_spawn(Self::build_message(http_client, url.clone())) + .background_spawn(fetch_url_content(http_client, url.clone())) .await?; this.update_in(cx, |this, window, cx| { diff --git a/crates/assistant2/src/context_picker/file_context_picker.rs b/crates/assistant2/src/context_picker/file_context_picker.rs index f856f06e51..572987f8af 100644 --- a/crates/assistant2/src/context_picker/file_context_picker.rs +++ b/crates/assistant2/src/context_picker/file_context_picker.rs @@ -1,25 +1,15 @@ -use std::collections::BTreeSet; -use std::ops::Range; use std::path::Path; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use editor::actions::FoldAt; -use editor::display_map::{Crease, FoldId}; -use editor::scroll::Autoscroll; -use editor::{Anchor, AnchorRangeExt, Editor, FoldPlaceholder, ToPoint}; use file_icons::FileIcons; use fuzzy::PathMatch; use gpui::{ - AnyElement, App, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, Stateful, - Task, WeakEntity, + App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, }; -use multi_buffer::{MultiBufferPoint, MultiBufferRow}; use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; -use rope::Point; -use text::SelectionGoal; -use ui::{prelude::*, ButtonLike, Disclosure, ListItem, TintColor, Tooltip}; +use ui::{prelude::*, ListItem, Tooltip}; use util::ResultExt as _; use workspace::{notifications::NotifyResultExt, Workspace}; @@ -34,7 +24,6 @@ impl FileContextPicker { pub fn new( context_picker: WeakEntity, workspace: WeakEntity, - editor: WeakEntity, context_store: WeakEntity, confirm_behavior: ConfirmBehavior, window: &mut Window, @@ -43,7 +32,6 @@ impl FileContextPicker { let delegate = FileContextPickerDelegate::new( context_picker, workspace, - editor, context_store, confirm_behavior, ); @@ -68,7 +56,6 @@ impl Render for FileContextPicker { pub struct FileContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, - editor: WeakEntity, context_store: WeakEntity, confirm_behavior: ConfirmBehavior, matches: Vec, @@ -79,95 +66,18 @@ impl FileContextPickerDelegate { pub fn new( context_picker: WeakEntity, workspace: WeakEntity, - editor: WeakEntity, context_store: WeakEntity, confirm_behavior: ConfirmBehavior, ) -> Self { Self { context_picker, workspace, - editor, context_store, confirm_behavior, matches: Vec::new(), selected_index: 0, } } - - fn search( - &mut self, - query: String, - cancellation_flag: Arc, - workspace: &Entity, - cx: &mut Context>, - ) -> Task> { - if query.is_empty() { - let workspace = workspace.read(cx); - let project = workspace.project().read(cx); - let recent_matches = workspace - .recent_navigation_history(Some(10), cx) - .into_iter() - .filter_map(|(project_path, _)| { - let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; - Some(PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix: worktree.read(cx).root_name().into(), - distance_to_relative_ancestor: 0, - is_dir: false, - }) - }); - - let file_matches = project.worktrees(cx).flat_map(|worktree| { - let worktree = worktree.read(cx); - let path_prefix: Arc = worktree.root_name().into(); - worktree.entries(false, 0).map(move |entry| PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: worktree.id().to_usize(), - path: entry.path.clone(), - path_prefix: path_prefix.clone(), - distance_to_relative_ancestor: 0, - is_dir: entry.is_dir(), - }) - }); - - Task::ready(recent_matches.chain(file_matches).collect()) - } else { - let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); - let candidate_sets = worktrees - .into_iter() - .map(|worktree| { - let worktree = worktree.read(cx); - - PathMatchCandidateSet { - snapshot: worktree.snapshot(), - include_ignored: worktree - .root_entry() - .map_or(false, |entry| entry.is_ignored), - include_root_name: true, - candidates: project::Candidates::Entries, - } - }) - .collect::>(); - - let executor = cx.background_executor().clone(); - cx.foreground_executor().spawn(async move { - fuzzy::match_path_sets( - candidate_sets.as_slice(), - query.as_str(), - None, - false, - 100, - &cancellation_flag, - executor, - ) - .await - }) - } - } } impl PickerDelegate for FileContextPickerDelegate { @@ -204,7 +114,7 @@ impl PickerDelegate for FileContextPickerDelegate { return Task::ready(()); }; - let search_task = self.search(query, Arc::::default(), &workspace, cx); + let search_task = search_paths(query, Arc::::default(), &workspace, cx); cx.spawn_in(window, async move |this, cx| { // TODO: This should be probably be run in the background. @@ -222,14 +132,6 @@ impl PickerDelegate for FileContextPickerDelegate { return; }; - let file_name = mat - .path - .file_name() - .map(|os_str| os_str.to_string_lossy().into_owned()) - .unwrap_or(mat.path_prefix.to_string()); - - let full_path = mat.path.display().to_string(); - let project_path = ProjectPath { worktree_id: WorktreeId::from_usize(mat.worktree_id), path: mat.path.clone(), @@ -237,106 +139,13 @@ impl PickerDelegate for FileContextPickerDelegate { let is_directory = mat.is_dir; - let Some(editor_entity) = self.editor.upgrade() else { - return; - }; - - editor_entity.update(cx, |editor, cx| { - editor.transact(window, cx, |editor, window, cx| { - // Move empty selections left by 1 column to select the `@`s, so they get overwritten when we insert. - { - let mut selections = editor.selections.all::(cx); - - for selection in selections.iter_mut() { - if selection.is_empty() { - let old_head = selection.head(); - let new_head = MultiBufferPoint::new( - old_head.row, - old_head.column.saturating_sub(1), - ); - selection.set_head(new_head, SelectionGoal::None); - } - } - - editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { - s.select(selections) - }); - } - - let start_anchors = { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor - .selections - .all::(cx) - .into_iter() - .map(|selection| snapshot.anchor_before(selection.start)) - .collect::>() - }; - - editor.insert(&full_path, window, cx); - - let end_anchors = { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor - .selections - .all::(cx) - .into_iter() - .map(|selection| snapshot.anchor_after(selection.end)) - .collect::>() - }; - - editor.insert("\n", window, cx); // Needed to end the fold - - let file_icon = if is_directory { - FileIcons::get_folder_icon(false, cx) - } else { - FileIcons::get_icon(&Path::new(&full_path), cx) - } - .unwrap_or_else(|| SharedString::new("")); - - let placeholder = FoldPlaceholder { - render: render_fold_icon_button( - file_icon, - file_name.into(), - editor_entity.downgrade(), - ), - ..Default::default() - }; - - let render_trailer = - move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); - - let buffer = editor.buffer().read(cx).snapshot(cx); - let mut rows_to_fold = BTreeSet::new(); - let crease_iter = start_anchors - .into_iter() - .zip(end_anchors) - .map(|(start, end)| { - rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row)); - - Crease::inline( - start..end, - placeholder.clone(), - fold_toggle("tool-use"), - render_trailer, - ) - }); - - editor.insert_creases(crease_iter, cx); - - for buffer_row in rows_to_fold { - editor.fold_at(&FoldAt { buffer_row }, window, cx); - } - }); - }); - let Some(task) = self .context_store .update(cx, |context_store, cx| { if is_directory { - context_store.add_directory(project_path, cx) + context_store.add_directory(project_path, true, cx) } else { - context_store.add_file_from_path(project_path, cx) + context_store.add_file_from_path(project_path, true, cx) } }) .ok() @@ -390,6 +199,80 @@ impl PickerDelegate for FileContextPickerDelegate { } } +pub(crate) fn search_paths( + query: String, + cancellation_flag: Arc, + workspace: &Entity, + cx: &App, +) -> Task> { + if query.is_empty() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + let recent_matches = workspace + .recent_navigation_history(Some(10), cx) + .into_iter() + .filter_map(|(project_path, _)| { + let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; + Some(PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: project_path.worktree_id.to_usize(), + path: project_path.path, + path_prefix: worktree.read(cx).root_name().into(), + distance_to_relative_ancestor: 0, + is_dir: false, + }) + }); + + let file_matches = project.worktrees(cx).flat_map(|worktree| { + let worktree = worktree.read(cx); + let path_prefix: Arc = worktree.root_name().into(); + worktree.entries(false, 0).map(move |entry| PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: worktree.id().to_usize(), + path: entry.path.clone(), + path_prefix: path_prefix.clone(), + distance_to_relative_ancestor: 0, + is_dir: entry.is_dir(), + }) + }); + + Task::ready(recent_matches.chain(file_matches).collect()) + } else { + let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name: true, + candidates: project::Candidates::Entries, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + }) + } +} + pub fn render_file_context_entry( id: ElementId, path: &Path, @@ -484,85 +367,3 @@ pub fn render_file_context_entry( } }) } - -fn render_fold_icon_button( - icon: SharedString, - label: SharedString, - editor: WeakEntity, -) -> Arc, &mut App) -> AnyElement> { - Arc::new(move |fold_id, fold_range, cx| { - let is_in_text_selection = editor.upgrade().is_some_and(|editor| { - editor.update(cx, |editor, cx| { - let snapshot = editor - .buffer() - .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx)); - - let is_in_pending_selection = || { - editor - .selections - .pending - .as_ref() - .is_some_and(|pending_selection| { - pending_selection - .selection - .range() - .includes(&fold_range, &snapshot) - }) - }; - - let mut is_in_complete_selection = || { - editor - .selections - .disjoint_in_range::(fold_range.clone(), cx) - .into_iter() - .any(|selection| { - // This is needed to cover a corner case, if we just check for an existing - // selection in the fold range, having a cursor at the start of the fold - // marks it as selected. Non-empty selections don't cause this. - let length = selection.end - selection.start; - length > 0 - }) - }; - - is_in_pending_selection() || is_in_complete_selection() - }) - }); - - ButtonLike::new(fold_id) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .child( - h_flex() - .gap_1() - .child( - Icon::from_path(icon.clone()) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(label.clone()) - .size(LabelSize::Small) - .single_line(), - ), - ) - .into_any_element() - }) -} - -fn fold_toggle( - name: &'static str, -) -> impl Fn( - MultiBufferRow, - bool, - Arc, - &mut Window, - &mut App, -) -> AnyElement { - move |row, is_folded, fold, _window, _cx| { - Disclosure::new((name, row.0 as u64), !is_folded) - .toggle_state(is_folded) - .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) - .into_any_element() - } -} diff --git a/crates/assistant2/src/context_picker/thread_context_picker.rs b/crates/assistant2/src/context_picker/thread_context_picker.rs index 82925492fb..6099029fd6 100644 --- a/crates/assistant2/src/context_picker/thread_context_picker.rs +++ b/crates/assistant2/src/context_picker/thread_context_picker.rs @@ -110,45 +110,11 @@ impl PickerDelegate for ThreadContextPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let Ok(threads) = self.thread_store.update(cx, |this, _cx| { - this.threads() - .into_iter() - .map(|thread| ThreadContextEntry { - id: thread.id, - summary: thread.summary, - }) - .collect::>() - }) else { + let Some(threads) = self.thread_store.upgrade() else { return Task::ready(()); }; - let executor = cx.background_executor().clone(); - let search_task = cx.background_spawn(async move { - if query.is_empty() { - threads - } else { - let candidates = threads - .iter() - .enumerate() - .map(|(id, thread)| StringMatchCandidate::new(id, &thread.summary)) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - 100, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|mat| threads[mat.candidate_id].clone()) - .collect() - } - }); - + let search_task = search_threads(query, threads, cx); cx.spawn_in(window, async move |this, cx| { let matches = search_task.await; this.update(cx, |this, cx| { @@ -176,7 +142,9 @@ impl PickerDelegate for ThreadContextPickerDelegate { this.update_in(cx, |this, window, cx| { this.delegate .context_store - .update(cx, |context_store, cx| context_store.add_thread(thread, cx)) + .update(cx, |context_store, cx| { + context_store.add_thread(thread, true, cx) + }) .ok(); match this.delegate.confirm_behavior { @@ -248,3 +216,46 @@ pub fn render_thread_context_entry( ) }) } + +pub(crate) fn search_threads( + query: String, + thread_store: Entity, + cx: &mut App, +) -> Task> { + let threads = thread_store.update(cx, |this, _cx| { + this.threads() + .into_iter() + .map(|thread| ThreadContextEntry { + id: thread.id, + summary: thread.summary, + }) + .collect::>() + }); + + let executor = cx.background_executor().clone(); + cx.background_spawn(async move { + if query.is_empty() { + threads + } else { + let candidates = threads + .iter() + .enumerate() + .map(|(id, thread)| StringMatchCandidate::new(id, &thread.summary)) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| threads[mat.candidate_id].clone()) + .collect() + } + }) +} diff --git a/crates/assistant2/src/context_store.rs b/crates/assistant2/src/context_store.rs index c74658a678..3dfaaca3c8 100644 --- a/crates/assistant2/src/context_store.rs +++ b/crates/assistant2/src/context_store.rs @@ -64,6 +64,7 @@ impl ContextStore { pub fn add_file_from_path( &mut self, project_path: ProjectPath, + remove_if_exists: bool, cx: &mut Context, ) -> Task> { let workspace = self.workspace.clone(); @@ -86,7 +87,9 @@ impl ContextStore { let already_included = this.update(cx, |this, _cx| { match this.will_include_buffer(buffer_id, &project_path.path) { Some(FileInclusion::Direct(context_id)) => { - this.remove_context(context_id); + if remove_if_exists { + this.remove_context(context_id); + } true } Some(FileInclusion::InDirectory(_)) => true, @@ -157,6 +160,7 @@ impl ContextStore { pub fn add_directory( &mut self, project_path: ProjectPath, + remove_if_exists: bool, cx: &mut Context, ) -> Task> { let workspace = self.workspace.clone(); @@ -169,7 +173,9 @@ impl ContextStore { let already_included = if let Some(context_id) = self.includes_directory(&project_path.path) { - self.remove_context(context_id); + if remove_if_exists { + self.remove_context(context_id); + } true } else { false @@ -256,9 +262,16 @@ impl ContextStore { ))); } - pub fn add_thread(&mut self, thread: Entity, cx: &mut Context) { + pub fn add_thread( + &mut self, + thread: Entity, + remove_if_exists: bool, + cx: &mut Context, + ) { if let Some(context_id) = self.includes_thread(&thread.read(cx).id()) { - self.remove_context(context_id); + if remove_if_exists { + self.remove_context(context_id); + } } else { self.insert_thread(thread, cx); } diff --git a/crates/assistant2/src/context_strip.rs b/crates/assistant2/src/context_strip.rs index e1c5ecfacb..9a56780967 100644 --- a/crates/assistant2/src/context_strip.rs +++ b/crates/assistant2/src/context_strip.rs @@ -39,7 +39,6 @@ impl ContextStrip { pub fn new( context_store: Entity, workspace: WeakEntity, - editor: WeakEntity, thread_store: Option>, context_picker_menu_handle: PopoverMenuHandle, suggest_context_kind: SuggestContextKind, @@ -51,7 +50,6 @@ impl ContextStrip { workspace.clone(), thread_store.clone(), context_store.downgrade(), - editor.clone(), ConfirmBehavior::KeepOpen, window, cx, diff --git a/crates/assistant2/src/inline_prompt_editor.rs b/crates/assistant2/src/inline_prompt_editor.rs index a2256574f2..45385e25ff 100644 --- a/crates/assistant2/src/inline_prompt_editor.rs +++ b/crates/assistant2/src/inline_prompt_editor.rs @@ -861,7 +861,6 @@ impl PromptEditor { ContextStrip::new( context_store.clone(), workspace.clone(), - prompt_editor.downgrade(), thread_store.clone(), context_picker_menu_handle.clone(), SuggestContextKind::Thread, @@ -1014,7 +1013,6 @@ impl PromptEditor { ContextStrip::new( context_store.clone(), workspace.clone(), - prompt_editor.downgrade(), thread_store.clone(), context_picker_menu_handle.clone(), SuggestContextKind::Thread, diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index 4f3e346918..a46024134a 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use collections::HashSet; use editor::actions::MoveUp; -use editor::{Editor, EditorElement, EditorEvent, EditorStyle}; +use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle}; use fs::Fs; use git::ExpandCommitEditor; use git_ui::git_panel; @@ -13,10 +13,8 @@ use gpui::{ use language_model::LanguageModelRegistry; use language_model_selector::ToggleModelSelector; use project::Project; -use rope::Point; use settings::Settings; use std::time::Duration; -use text::Bias; use theme::ThemeSettings; use ui::{ prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip, @@ -25,7 +23,7 @@ use vim_mode_setting::VimModeSetting; use workspace::Workspace; use crate::assistant_model_selector::AssistantModelSelector; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider}; use crate::context_store::{refresh_context_store_text, ContextStore}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::thread::{RequestKind, Thread}; @@ -68,16 +66,30 @@ impl MessageEditor { let mut editor = Editor::auto_height(10, window, cx); editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx); editor.set_show_indent_guides(false, cx); + editor.set_context_menu_options(ContextMenuOptions { + min_entries_visible: 12, + max_entries_visible: 12, + placement: Some(ContextMenuPlacement::Above), + }); editor }); + let editor_entity = editor.downgrade(); + editor.update(cx, |editor, _| { + editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new( + workspace.clone(), + context_store.downgrade(), + Some(thread_store.clone()), + editor_entity, + )))); + }); + let inline_context_picker = cx.new(|cx| { ContextPicker::new( workspace.clone(), Some(thread_store.clone()), context_store.downgrade(), - editor.downgrade(), ConfirmBehavior::Close, window, cx, @@ -88,7 +100,6 @@ impl MessageEditor { ContextStrip::new( context_store.clone(), workspace.clone(), - editor.downgrade(), Some(thread_store.clone()), context_picker_menu_handle.clone(), SuggestContextKind::File, @@ -98,7 +109,6 @@ impl MessageEditor { }); let subscriptions = vec![ - cx.subscribe_in(&editor, window, Self::handle_editor_event), cx.subscribe_in( &inline_context_picker, window, @@ -232,34 +242,6 @@ impl MessageEditor { .detach(); } - fn handle_editor_event( - &mut self, - editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::SelectionsChanged { .. } => { - editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - let newest_cursor = editor.selections.newest::(cx).head(); - if newest_cursor.column > 0 { - let behind_cursor = snapshot.clip_point( - Point::new(newest_cursor.row, newest_cursor.column - 1), - Bias::Left, - ); - let char_behind_cursor = snapshot.chars_at(behind_cursor).next(); - if char_behind_cursor == Some('@') { - self.inline_context_picker_menu_handle.show(window, cx); - } - } - }); - } - _ => {} - } - } - fn handle_inline_context_picker_event( &mut self, _inline_context_picker: &Entity, @@ -616,6 +598,7 @@ impl Render for MessageEditor { background: editor_bg_color, local_player: cx.theme().players().local(), text: text_style, + syntax: cx.theme().syntax().clone(), ..Default::default() }, ) diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index e19c7cecfb..182713cd26 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -23,7 +23,6 @@ use project::{Project, Worktree}; use prompt_store::{ AssistantSystemPromptContext, PromptBuilder, RulesFile, WorktreeInfoForSystemPrompt, }; -use scripting_tool::{ScriptingSession, ScriptingTool}; use serde::{Deserialize, Serialize}; use settings::Settings; use util::{maybe, post_inc, ResultExt as _, TryFutureExt as _}; @@ -34,7 +33,7 @@ use crate::thread_store::{ SerializedMessage, SerializedMessageSegment, SerializedThread, SerializedToolResult, SerializedToolUse, }; -use crate::tool_use::{PendingToolUse, PendingToolUseStatus, ToolType, ToolUse, ToolUseState}; +use crate::tool_use::{PendingToolUse, ToolUse, ToolUseState}; #[derive(Debug, Clone, Copy)] pub enum RequestKind { @@ -194,8 +193,6 @@ pub struct Thread { action_log: Entity, last_restore_checkpoint: Option, pending_checkpoint: Option, - scripting_session: Entity, - scripting_tool_use: ToolUseState, initial_project_snapshot: Shared>>>, cumulative_token_usage: TokenUsage, feedback: Option, @@ -230,8 +227,6 @@ impl Thread { last_restore_checkpoint: None, pending_checkpoint: None, tool_use: ToolUseState::new(tools.clone()), - scripting_session: cx.new(|cx| ScriptingSession::new(project.clone(), cx)), - scripting_tool_use: ToolUseState::new(tools), action_log: cx.new(|_| ActionLog::new()), initial_project_snapshot: { let project_snapshot = Self::project_snapshot(project.clone(), cx); @@ -274,14 +269,7 @@ impl Thread { .unwrap_or(0), ); let tool_use = - ToolUseState::from_serialized_messages(tools.clone(), &serialized.messages, |name| { - name != ScriptingTool::NAME - }); - let scripting_tool_use = - ToolUseState::from_serialized_messages(tools.clone(), &serialized.messages, |name| { - name == ScriptingTool::NAME - }); - let scripting_session = cx.new(|cx| ScriptingSession::new(project.clone(), cx)); + ToolUseState::from_serialized_messages(tools.clone(), &serialized.messages, |_| true); let mut this = Self { id, @@ -320,8 +308,6 @@ impl Thread { tools, tool_use, action_log: cx.new(|_| ActionLog::new()), - scripting_session, - scripting_tool_use, initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(), // TODO: persist token usage? cumulative_token_usage: TokenUsage::default(), @@ -384,37 +370,17 @@ impl Thread { .pending_tool_uses() .into_iter() .find(|tool_use| &tool_use.id == id) - .or_else(|| { - self.scripting_tool_use - .pending_tool_uses() - .into_iter() - .find(|tool_use| &tool_use.id == id) - }) } - pub fn tools_needing_confirmation(&self) -> impl Iterator { + pub fn tools_needing_confirmation(&self) -> impl Iterator { self.tool_use .pending_tool_uses() .into_iter() - .filter_map(|tool_use| { - if let PendingToolUseStatus::NeedsConfirmation(confirmation) = &tool_use.status { - Some((confirmation.tool_type.clone(), tool_use)) - } else { - None - } - }) - .chain( - self.scripting_tool_use - .pending_tool_uses() - .into_iter() - .filter_map(|tool_use| { - if tool_use.status.needs_confirmation() { - Some((ToolType::ScriptingTool, tool_use)) - } else { - None - } - }), - ) + .filter(|tool_use| tool_use.status.needs_confirmation()) + } + + pub fn has_pending_tool_uses(&self) -> bool { + !self.tool_use.pending_tool_uses().is_empty() } pub fn checkpoint_for_message(&self, id: MessageId) -> Option { @@ -547,25 +513,18 @@ impl Thread { /// Returns whether all of the tool uses have finished running. pub fn all_tools_finished(&self) -> bool { - let mut all_pending_tool_uses = self - .tool_use - .pending_tool_uses() - .into_iter() - .chain(self.scripting_tool_use.pending_tool_uses()); - // If the only pending tool uses left are the ones with errors, then // that means that we've finished running all of the pending tools. - all_pending_tool_uses.all(|tool_use| tool_use.status.is_error()) + self.tool_use + .pending_tool_uses() + .iter() + .all(|tool_use| tool_use.status.is_error()) } pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { self.tool_use.tool_uses_for_message(id, cx) } - pub fn scripting_tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { - self.scripting_tool_use.tool_uses_for_message(id, cx) - } - pub fn tool_results_for_message(&self, id: MessageId) -> Vec<&LanguageModelToolResult> { self.tool_use.tool_results_for_message(id) } @@ -574,21 +533,10 @@ impl Thread { self.tool_use.tool_result(id) } - pub fn scripting_tool_results_for_message( - &self, - id: MessageId, - ) -> Vec<&LanguageModelToolResult> { - self.scripting_tool_use.tool_results_for_message(id) - } - pub fn message_has_tool_results(&self, message_id: MessageId) -> bool { self.tool_use.message_has_tool_results(message_id) } - pub fn message_has_scripting_tool_results(&self, message_id: MessageId) -> bool { - self.scripting_tool_use.message_has_tool_results(message_id) - } - pub fn insert_user_message( &mut self, text: impl Into, @@ -709,7 +657,6 @@ impl Thread { tool_uses: this .tool_uses_for_message(message.id, cx) .into_iter() - .chain(this.scripting_tool_uses_for_message(message.id, cx)) .map(|tool_use| SerializedToolUse { id: tool_use.id, name: tool_use.name, @@ -719,7 +666,6 @@ impl Thread { tool_results: this .tool_results_for_message(message.id) .into_iter() - .chain(this.scripting_tool_results_for_message(message.id)) .map(|tool_result| SerializedToolResult { tool_use_id: tool_result.tool_use_id.clone(), is_error: tool_result.is_error, @@ -783,11 +729,12 @@ impl Thread { // Note that Cline supports `.clinerules` being a directory, but that is not currently // supported. This doesn't seem to occur often in GitHub repositories. - const RULES_FILE_NAMES: [&'static str; 5] = [ + const RULES_FILE_NAMES: [&'static str; 6] = [ ".rules", ".cursorrules", ".windsurfrules", ".clinerules", + ".github/copilot-instructions.md", "CLAUDE.md", ]; let selected_rules_file = RULES_FILE_NAMES @@ -852,15 +799,6 @@ impl Thread { let mut request = self.to_completion_request(request_kind, cx); request.tools = { let mut tools = Vec::new(); - - if self.tools.is_scripting_tool_enabled() { - tools.push(LanguageModelRequestTool { - name: ScriptingTool::NAME.into(), - description: ScriptingTool::DESCRIPTION.into(), - input_schema: ScriptingTool::input_schema(), - }); - } - tools.extend(self.tools().enabled_tools(cx).into_iter().map(|tool| { LanguageModelRequestTool { name: tool.name(), @@ -921,8 +859,6 @@ impl Thread { RequestKind::Chat => { self.tool_use .attach_tool_results(message.id, &mut request_message); - self.scripting_tool_use - .attach_tool_results(message.id, &mut request_message); } RequestKind::Summarize => { // We don't care about tool use during summarization. @@ -939,8 +875,6 @@ impl Thread { RequestKind::Chat => { self.tool_use .attach_tool_uses(message.id, &mut request_message); - self.scripting_tool_use - .attach_tool_uses(message.id, &mut request_message); } RequestKind::Summarize => { // We don't care about tool use during summarization. @@ -1087,19 +1021,11 @@ impl Thread { .iter() .rfind(|message| message.role == Role::Assistant) { - if tool_use.name.as_ref() == ScriptingTool::NAME { - thread.scripting_tool_use.request_tool_use( - last_assistant_message.id, - tool_use, - cx, - ); - } else { - thread.tool_use.request_tool_use( - last_assistant_message.id, - tool_use, - cx, - ); - } + thread.tool_use.request_tool_use( + last_assistant_message.id, + tool_use, + cx, + ); } } } @@ -1264,15 +1190,16 @@ impl Thread { tool_use.ui_text.clone(), tool_use.input.clone(), messages.clone(), - ToolType::NonScriptingTool(tool), + tool, ); + cx.emit(ThreadEvent::ToolConfirmationNeeded); } else { self.run_tool( tool_use.id.clone(), tool_use.ui_text.clone(), tool_use.input.clone(), &messages, - ToolType::NonScriptingTool(tool), + tool, cx, ); } @@ -1282,33 +1209,13 @@ impl Thread { tool_use.ui_text.clone(), tool_use.input.clone(), &messages, - ToolType::NonScriptingTool(tool), + tool, cx, ); } } - let pending_scripting_tool_uses = self - .scripting_tool_use - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.is_idle()) - .cloned() - .collect::>(); - - for scripting_tool_use in pending_scripting_tool_uses.iter() { - self.scripting_tool_use.confirm_tool_use( - scripting_tool_use.id.clone(), - scripting_tool_use.ui_text.clone(), - scripting_tool_use.input.clone(), - messages.clone(), - ToolType::ScriptingTool, - ); - } - pending_tool_uses - .into_iter() - .chain(pending_scripting_tool_uses) } pub fn run_tool( @@ -1317,21 +1224,12 @@ impl Thread { ui_text: impl Into, input: serde_json::Value, messages: &[LanguageModelRequestMessage], - tool_type: ToolType, + tool: Arc, cx: &mut Context<'_, Thread>, ) { - match tool_type { - ToolType::ScriptingTool => { - let task = self.spawn_scripting_tool_use(tool_use_id.clone(), input, cx); - self.scripting_tool_use - .run_pending_tool(tool_use_id, ui_text.into(), task); - } - ToolType::NonScriptingTool(tool) => { - let task = self.spawn_tool_use(tool_use_id.clone(), messages, input, tool, cx); - self.tool_use - .run_pending_tool(tool_use_id, ui_text.into(), task); - } - } + let task = self.spawn_tool_use(tool_use_id.clone(), messages, input, tool, cx); + self.tool_use + .run_pending_tool(tool_use_id, ui_text.into(), task); } fn spawn_tool_use( @@ -1375,60 +1273,6 @@ impl Thread { }) } - fn spawn_scripting_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - input: serde_json::Value, - cx: &mut Context, - ) -> Task<()> { - let task = match ScriptingTool::deserialize_input(input) { - Err(err) => Task::ready(Err(err.into())), - Ok(input) => { - let (script_id, script_task) = - self.scripting_session.update(cx, move |session, cx| { - session.run_script(input.lua_script, cx) - }); - - let session = self.scripting_session.clone(); - cx.spawn(async move |_, cx| { - script_task.await; - - let message = session.read_with(cx, |session, _cx| { - // Using a id to get the script output seems impractical. - // Why not just include it in the Task result? - // This is because we'll later report the script state as it runs, - session - .get(script_id) - .output_message_for_llm() - .expect("Script shouldn't still be running") - })?; - - Ok(message) - }) - } - }; - - cx.spawn({ - let tool_use_id = tool_use_id.clone(); - async move |thread, cx| { - let output = task.await; - thread - .update(cx, |thread, cx| { - let pending_tool_use = thread - .scripting_tool_use - .insert_tool_output(tool_use_id.clone(), output); - - cx.emit(ThreadEvent::ToolFinished { - tool_use_id, - pending_tool_use, - canceled: false, - }); - }) - .ok(); - } - }) - } - pub fn attach_tool_results( &mut self, updated_context: Vec, @@ -1730,22 +1574,12 @@ impl Thread { self.cumulative_token_usage.clone() } - pub fn deny_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_type: ToolType, - cx: &mut Context, - ) { + pub fn deny_tool_use(&mut self, tool_use_id: LanguageModelToolUseId, cx: &mut Context) { let err = Err(anyhow::anyhow!( "Permission to run tool action denied by user" )); - if let ToolType::ScriptingTool = tool_type { - self.scripting_tool_use - .insert_tool_output(tool_use_id.clone(), err); - } else { - self.tool_use.insert_tool_output(tool_use_id.clone(), err); - } + self.tool_use.insert_tool_output(tool_use_id.clone(), err); cx.emit(ThreadEvent::ToolFinished { tool_use_id, @@ -1786,6 +1620,7 @@ pub enum ThreadEvent { canceled: bool, }, CheckpointChanged, + ToolConfirmationNeeded, } impl EventEmitter for Thread {} diff --git a/crates/assistant2/src/tool_selector.rs b/crates/assistant2/src/tool_selector.rs index 1c0aae33fa..ffe4f533cf 100644 --- a/crates/assistant2/src/tool_selector.rs +++ b/crates/assistant2/src/tool_selector.rs @@ -4,7 +4,6 @@ use assistant_settings::{AgentProfile, AssistantSettings}; use assistant_tool::{ToolSource, ToolWorkingSet}; use gpui::{Entity, Subscription}; use indexmap::IndexMap; -use scripting_tool::ScriptingTool; use settings::{Settings as _, SettingsStore}; use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip}; @@ -51,8 +50,8 @@ impl ToolSelector { menu = menu.toggleable_entry(profile.name.clone(), false, icon_position, None, { let tools = tool_set.clone(); move |_window, cx| { - tools.disable_source(ToolSource::Native, cx); - tools.disable_scripting_tool(); + tools.disable_all_tools(cx); + tools.enable( ToolSource::Native, &profile @@ -62,8 +61,17 @@ impl ToolSelector { .collect::>(), ); - if profile.tools.contains_key(ScriptingTool::NAME) { - tools.enable_scripting_tool(); + for (context_server_id, preset) in &profile.context_servers { + tools.enable( + ToolSource::ContextServer { + id: context_server_id.clone().into(), + }, + &preset + .tools + .iter() + .filter_map(|(tool, enabled)| enabled.then(|| tool.clone())) + .collect::>(), + ) } } }); @@ -98,11 +106,6 @@ impl ToolSelector { .collect::>(); if ToolSource::Native == source { - tools.push(( - ToolSource::Native, - ScriptingTool::NAME.into(), - tool_set.is_scripting_tool_enabled(), - )); tools.sort_by(|(_, name_a, _), (_, name_b, _)| name_a.cmp(name_b)); } @@ -136,18 +139,10 @@ impl ToolSelector { menu = menu.toggleable_entry(name.clone(), is_enabled, icon_position, None, { let tools = tool_set.clone(); move |_window, _cx| { - if name.as_ref() == ScriptingTool::NAME { - if is_enabled { - tools.disable_scripting_tool(); - } else { - tools.enable_scripting_tool(); - } + if is_enabled { + tools.disable(source.clone(), &[name.clone()]); } else { - if is_enabled { - tools.disable(source.clone(), &[name.clone()]); - } else { - tools.enable(source.clone(), &[name.clone()]); - } + tools.enable(source.clone(), &[name.clone()]); } } }); diff --git a/crates/assistant2/src/tool_use.rs b/crates/assistant2/src/tool_use.rs index 7b7399c950..d13c711d85 100644 --- a/crates/assistant2/src/tool_use.rs +++ b/crates/assistant2/src/tool_use.rs @@ -10,7 +10,7 @@ use language_model::{ LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, }; -use scripting_tool::ScriptingTool; +use ui::IconName; use crate::thread::MessageId; use crate::thread_store::SerializedMessage; @@ -22,6 +22,7 @@ pub struct ToolUse { pub ui_text: SharedString, pub status: ToolUseStatus, pub input: serde_json::Value, + pub icon: ui::IconName, } #[derive(Debug, Clone)] @@ -180,12 +181,19 @@ impl ToolUseState { } })(); + let icon = if let Some(tool) = self.tools.tool(&tool_use.name, cx) { + tool.icon() + } else { + IconName::Cog + }; + tool_uses.push(ToolUse { id: tool_use.id.clone(), name: tool_use.name.clone().into(), ui_text: self.tool_ui_label(&tool_use.name, &tool_use.input, cx), input: tool_use.input.clone(), status, + icon, }) } @@ -200,8 +208,6 @@ impl ToolUseState { ) -> SharedString { if let Some(tool) = self.tools.tool(tool_name, cx) { tool.ui_text(input).into() - } else if tool_name == ScriptingTool::NAME { - "Run Lua Script".into() } else { "Unknown tool".into() } @@ -285,7 +291,7 @@ impl ToolUseState { ui_text: impl Into>, input: serde_json::Value, messages: Arc>, - tool_type: ToolType, + tool: Arc, ) { if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { let ui_text = ui_text.into(); @@ -294,7 +300,7 @@ impl ToolUseState { tool_use_id, input, messages, - tool_type, + tool, ui_text, }; tool_use.status = PendingToolUseStatus::NeedsConfirmation(Arc::new(confirmation)); @@ -398,19 +404,13 @@ pub struct PendingToolUse { pub status: PendingToolUseStatus, } -#[derive(Debug, Clone)] -pub enum ToolType { - ScriptingTool, - NonScriptingTool(Arc), -} - #[derive(Debug, Clone)] pub struct Confirmation { pub tool_use_id: LanguageModelToolUseId, pub input: serde_json::Value, pub ui_text: Arc, pub messages: Arc>, - pub tool_type: ToolType, + pub tool: Arc, } #[derive(Debug, Clone)] diff --git a/crates/assistant2/src/ui.rs b/crates/assistant2/src/ui.rs index b10c09b300..390a6f8edc 100644 --- a/crates/assistant2/src/ui.rs +++ b/crates/assistant2/src/ui.rs @@ -1,3 +1,5 @@ mod context_pill; +mod tool_ready_pop_up; pub use context_pill::*; +pub use tool_ready_pop_up::*; diff --git a/crates/assistant2/src/ui/tool_ready_pop_up.rs b/crates/assistant2/src/ui/tool_ready_pop_up.rs new file mode 100644 index 0000000000..4a43e29113 --- /dev/null +++ b/crates/assistant2/src/ui/tool_ready_pop_up.rs @@ -0,0 +1,115 @@ +use gpui::{ + point, App, Context, EventEmitter, IntoElement, PlatformDisplay, Size, Window, + WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, +}; +use release_channel::ReleaseChannel; +use std::rc::Rc; +use theme; +use ui::{prelude::*, Render}; + +pub struct ToolReadyPopUp { + caption: SharedString, +} + +impl ToolReadyPopUp { + pub fn new(caption: impl Into) -> Self { + Self { + caption: caption.into(), + } + } + + pub fn window_options(screen: Rc, cx: &App) -> WindowOptions { + let size = Size { + width: px(440.), + height: px(72.), + }; + + let notification_margin_width = px(16.); + let notification_margin_height = px(-48.); + + let bounds = gpui::Bounds:: { + origin: screen.bounds().top_right() + - point( + size.width + notification_margin_width, + notification_margin_height, + ), + size, + }; + + let app_id = ReleaseChannel::global(cx).app_id(); + + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: None, + focus: false, + show: true, + kind: WindowKind::PopUp, + is_movable: false, + display_id: Some(screen.id()), + window_background: WindowBackgroundAppearance::Transparent, + app_id: Some(app_id.to_owned()), + window_min_size: None, + window_decorations: Some(WindowDecorations::Client), + } + } +} + +pub enum ToolReadyPopupEvent { + Accepted, + Dismissed, +} + +impl EventEmitter for ToolReadyPopUp {} + +impl Render for ToolReadyPopUp { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme::setup_ui_font(window, cx); + let line_height = window.line_height(); + + h_flex() + .size_full() + .p_3() + .gap_4() + .justify_between() + .elevation_3(cx) + .text_ui(cx) + .font(ui_font) + .border_color(cx.theme().colors().border) + .rounded_xl() + .child( + h_flex() + .items_start() + .gap_2() + .child( + h_flex().h(line_height).justify_center().child( + Icon::new(IconName::Info) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .child( + v_flex() + .child(Headline::new("Agent Panel").size(HeadlineSize::XSmall)) + .child(Label::new(self.caption.clone()).color(Color::Muted)), + ), + ) + .child( + h_flex() + .gap_0p5() + .child( + Button::new("open", "View Panel") + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click({ + cx.listener(move |_this, _event, _, cx| { + cx.emit(ToolReadyPopupEvent::Accepted); + }) + }), + ) + .child(Button::new("dismiss", "Dismiss").on_click({ + cx.listener(move |_, _event, _, cx| { + cx.emit(ToolReadyPopupEvent::Dismissed); + }) + })), + ) + } +} diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index 8fc2433476..214001d13f 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -13,7 +13,7 @@ use editor::{ BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, CustomBlockId, FoldId, RenderBlock, ToDisplayPoint, }, - scroll::{Autoscroll, AutoscrollStrategy}, + scroll::Autoscroll, Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, ProposedChangeLocation, ProposedChangesEditor, RowExt, ToOffset as _, ToPoint, }; @@ -414,12 +414,9 @@ impl ContextEditor { cursor..cursor }; self.editor.update(cx, |editor, cx| { - editor.change_selections( - Some(Autoscroll::Strategy(AutoscrollStrategy::Fit)), - window, - cx, - |selections| selections.select_ranges([new_selection]), - ); + editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| { + selections.select_ranges([new_selection]) + }); }); // Avoid scrolling to the new cursor position so the assistant's output is stable. cx.defer_in(window, |this, _, _| this.scroll_position = None); diff --git a/crates/assistant_context_editor/src/slash_command.rs b/crates/assistant_context_editor/src/slash_command.rs index d5139c8f37..a3897e3235 100644 --- a/crates/assistant_context_editor/src/slash_command.rs +++ b/crates/assistant_context_editor/src/slash_command.rs @@ -2,7 +2,7 @@ use crate::context_editor::ContextEditor; use anyhow::Result; pub use assistant_slash_command::SlashCommand; use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWorkingSet}; -use editor::{CompletionProvider, Editor}; +use editor::{CompletionProvider, Editor, ExcerptId}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window}; use language::{Anchor, Buffer, ToPoint}; @@ -126,6 +126,7 @@ impl SlashCommandCompletionProvider { )), new_text, label: command.label(cx), + icon_path: None, confirm, source: CompletionSource::Custom, }) @@ -223,6 +224,7 @@ impl SlashCommandCompletionProvider { last_argument_range.clone() }, label: new_argument.label, + icon_path: None, new_text, documentation: None, confirm, @@ -241,6 +243,7 @@ impl SlashCommandCompletionProvider { impl CompletionProvider for SlashCommandCompletionProvider { fn completions( &self, + _excerpt_id: ExcerptId, buffer: &Entity, buffer_position: Anchor, _: editor::CompletionContext, diff --git a/crates/assistant_settings/src/agent_profile.rs b/crates/assistant_settings/src/agent_profile.rs index f208568916..2b7c91cd9f 100644 --- a/crates/assistant_settings/src/agent_profile.rs +++ b/crates/assistant_settings/src/agent_profile.rs @@ -9,12 +9,10 @@ pub struct AgentProfile { /// The name of the profile. pub name: SharedString, pub tools: IndexMap, bool>, - #[allow(dead_code)] pub context_servers: IndexMap, ContextServerPreset>, } #[derive(Debug, Clone)] pub struct ContextServerPreset { - #[allow(dead_code)] pub tools: IndexMap, bool>, } diff --git a/crates/assistant_settings/src/assistant_settings.rs b/crates/assistant_settings/src/assistant_settings.rs index 1e37ef91c0..c937e75fe5 100644 --- a/crates/assistant_settings/src/assistant_settings.rs +++ b/crates/assistant_settings/src/assistant_settings.rs @@ -73,6 +73,7 @@ pub struct AssistantSettings { pub enable_experimental_live_diffs: bool, pub profiles: IndexMap, AgentProfile>, pub always_allow_tool_actions: bool, + pub notify_when_agent_waiting: bool, } impl AssistantSettings { @@ -175,6 +176,7 @@ impl AssistantSettingsContent { enable_experimental_live_diffs: None, profiles: None, always_allow_tool_actions: None, + notify_when_agent_waiting: None, }, VersionedAssistantSettingsContent::V2(settings) => settings.clone(), }, @@ -198,6 +200,7 @@ impl AssistantSettingsContent { enable_experimental_live_diffs: None, profiles: None, always_allow_tool_actions: None, + notify_when_agent_waiting: None, }, } } @@ -329,6 +332,7 @@ impl Default for VersionedAssistantSettingsContent { enable_experimental_live_diffs: None, profiles: None, always_allow_tool_actions: None, + notify_when_agent_waiting: None, }) } } @@ -372,6 +376,10 @@ pub struct AssistantSettingsContentV2 { /// /// Default: false always_allow_tool_actions: Option, + /// Whether to show a popup notification when the agent is waiting for user input. + /// + /// Default: true + notify_when_agent_waiting: Option, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -412,6 +420,13 @@ impl Default for LanguageModelSelection { pub struct AgentProfileContent { pub name: Arc, pub tools: IndexMap, bool>, + #[serde(default)] + pub context_servers: IndexMap, ContextServerPresetContent>, +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ContextServerPresetContent { + pub tools: IndexMap, bool>, } #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] @@ -512,6 +527,10 @@ impl Settings for AssistantSettings { &mut settings.always_allow_tool_actions, value.always_allow_tool_actions, ); + merge( + &mut settings.notify_when_agent_waiting, + value.notify_when_agent_waiting, + ); if let Some(profiles) = value.profiles { settings @@ -522,7 +541,18 @@ impl Settings for AssistantSettings { AgentProfile { name: profile.name.into(), tools: profile.tools, - context_servers: IndexMap::default(), + context_servers: profile + .context_servers + .into_iter() + .map(|(context_server_id, preset)| { + ( + context_server_id, + ContextServerPreset { + tools: preset.tools.clone(), + }, + ) + }) + .collect(), }, ) })); @@ -593,6 +623,7 @@ mod tests { enable_experimental_live_diffs: None, profiles: None, always_allow_tool_actions: None, + notify_when_agent_waiting: None, }), ) }, diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index 040a906bf3..73ee3ffccb 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/crates/assistant_tool/Cargo.toml @@ -13,10 +13,11 @@ path = "src/assistant_tool.rs" [dependencies] anyhow.workspace = true -collections.workspace = true clock.workspace = true +collections.workspace = true derive_more.workspace = true gpui.workspace = true +icons.workspace = true language.workspace = true language_model.workspace = true parking_lot.workspace = true diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 20fdb05439..906903acbb 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -1,14 +1,16 @@ mod tool_registry; mod tool_working_set; +use std::fmt::{self, Debug, Formatter}; +use std::sync::Arc; + use anyhow::Result; use collections::{HashMap, HashSet}; use gpui::{App, Context, Entity, SharedString, Task}; +use icons::IconName; use language::Buffer; use language_model::LanguageModelRequestMessage; use project::Project; -use std::fmt::{self, Debug, Formatter}; -use std::sync::Arc; pub use crate::tool_registry::*; pub use crate::tool_working_set::*; @@ -33,6 +35,9 @@ pub trait Tool: 'static + Send + Sync { /// Returns the description of the tool. fn description(&self) -> String; + /// Returns the icon for the tool. + fn icon(&self) -> IconName; + /// Returns the source of the tool. fn source(&self) -> ToolSource { ToolSource::Native diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs index 74d4d4d932..2c1acad053 100644 --- a/crates/assistant_tool/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -15,26 +15,14 @@ pub struct ToolWorkingSet { state: Mutex, } +#[derive(Default)] struct WorkingSetState { context_server_tools_by_id: HashMap>, context_server_tools_by_name: HashMap>, disabled_tools_by_source: HashMap>>, - is_scripting_tool_disabled: bool, next_tool_id: ToolId, } -impl Default for WorkingSetState { - fn default() -> Self { - Self { - context_server_tools_by_id: HashMap::default(), - context_server_tools_by_name: HashMap::default(), - disabled_tools_by_source: HashMap::default(), - is_scripting_tool_disabled: true, - next_tool_id: ToolId::default(), - } - } -} - impl ToolWorkingSet { pub fn tool(&self, name: &str, cx: &App) -> Option> { self.state @@ -55,7 +43,7 @@ impl ToolWorkingSet { pub fn are_all_tools_enabled(&self) -> bool { let state = self.state.lock(); - state.disabled_tools_by_source.is_empty() && !state.is_scripting_tool_disabled + state.disabled_tools_by_source.is_empty() } pub fn are_all_tools_from_source_enabled(&self, source: &ToolSource) -> bool { @@ -70,7 +58,6 @@ impl ToolWorkingSet { pub fn enable_all_tools(&self) { let mut state = self.state.lock(); state.disabled_tools_by_source.clear(); - state.enable_scripting_tool(); } pub fn disable_all_tools(&self, cx: &App) { @@ -124,21 +111,6 @@ impl ToolWorkingSet { .retain(|id, _| !tool_ids_to_remove.contains(id)); state.tools_changed(); } - - pub fn is_scripting_tool_enabled(&self) -> bool { - let state = self.state.lock(); - !state.is_scripting_tool_disabled - } - - pub fn enable_scripting_tool(&self) { - let mut state = self.state.lock(); - state.enable_scripting_tool(); - } - - pub fn disable_scripting_tool(&self) { - let mut state = self.state.lock(); - state.disable_scripting_tool(); - } } impl WorkingSetState { @@ -240,15 +212,5 @@ impl WorkingSetState { self.disable(source, &tool_names); } - - self.disable_scripting_tool(); - } - - fn enable_scripting_tool(&mut self) { - self.is_scripting_tool_disabled = false; - } - - fn disable_scripting_tool(&mut self) { - self.is_scripting_tool_disabled = true; } } diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 53611c8fbf..5d2b2965e6 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -1,28 +1,35 @@ mod bash_tool; +mod copy_path_tool; +mod create_file_tool; mod delete_path_tool; mod diagnostics_tool; mod edit_files_tool; mod fetch_tool; +mod find_replace_file_tool; mod list_directory_tool; mod move_path_tool; mod now_tool; mod path_search_tool; mod read_file_tool; mod regex_search_tool; +mod replace; mod thinking_tool; use std::sync::Arc; use assistant_tool::ToolRegistry; +use copy_path_tool::CopyPathTool; use gpui::App; use http_client::HttpClientWithUrl; use move_path_tool::MovePathTool; use crate::bash_tool::BashTool; +use crate::create_file_tool::CreateFileTool; use crate::delete_path_tool::DeletePathTool; use crate::diagnostics_tool::DiagnosticsTool; use crate::edit_files_tool::EditFilesTool; use crate::fetch_tool::FetchTool; +use crate::find_replace_file_tool::FindReplaceFileTool; use crate::list_directory_tool::ListDirectoryTool; use crate::now_tool::NowTool; use crate::path_search_tool::PathSearchTool; @@ -36,7 +43,10 @@ pub fn init(http_client: Arc, cx: &mut App) { let registry = ToolRegistry::global(cx); registry.register_tool(BashTool); + registry.register_tool(CreateFileTool); + registry.register_tool(CopyPathTool); registry.register_tool(DeletePathTool); + registry.register_tool(FindReplaceFileTool); registry.register_tool(MovePathTool); registry.register_tool(DiagnosticsTool); registry.register_tool(EditFilesTool); diff --git a/crates/assistant_tools/src/bash_tool.rs b/crates/assistant_tools/src/bash_tool.rs index 51c8a024b3..19cf5c198c 100644 --- a/crates/assistant_tools/src/bash_tool.rs +++ b/crates/assistant_tools/src/bash_tool.rs @@ -6,6 +6,7 @@ use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use ui::IconName; use util::command::new_smol_command; #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -31,6 +32,10 @@ impl Tool for BashTool { include_str!("./bash_tool/description.md").to_string() } + fn icon(&self) -> IconName { + IconName::Terminal + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(BashToolInput); serde_json::to_value(&schema).unwrap() @@ -38,7 +43,7 @@ impl Tool for BashTool { fn ui_text(&self, input: &serde_json::Value) -> String { match serde_json::from_value::(input.clone()) { - Ok(input) => format!("`$ {}`", input.command), + Ok(input) => format!("`{}`", input.command), Err(_) => "Run bash command".to_string(), } } diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs new file mode 100644 index 0000000000..d4cb85421b --- /dev/null +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -0,0 +1,119 @@ +use anyhow::{anyhow, Result}; +use assistant_tool::{ActionLog, Tool}; +use gpui::{App, AppContext, Entity, Task}; +use language_model::LanguageModelRequestMessage; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ui::IconName; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CopyPathToolInput { + /// The source path of the file or directory to copy. + /// If a directory is specified, its contents will be copied recursively (like `cp -r`). + /// + /// + /// If the project has the following files: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can copy the first file by providing a source_path of "directory1/a/something.txt" + /// + pub source_path: String, + + /// The destination path where the file or directory should be copied to. + /// + /// + /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", + /// provide a destination_path of "directory2/b/copy.txt" + /// + pub destination_path: String, +} + +pub struct CopyPathTool; + +impl Tool for CopyPathTool { + fn name(&self) -> String { + "copy-path".into() + } + + fn needs_confirmation(&self) -> bool { + true + } + + fn description(&self) -> String { + include_str!("./copy_path_tool/description.md").into() + } + + fn icon(&self) -> IconName { + IconName::Clipboard + } + + fn input_schema(&self) -> serde_json::Value { + let schema = schemars::schema_for!(CopyPathToolInput); + serde_json::to_value(&schema).unwrap() + } + + fn ui_text(&self, input: &serde_json::Value) -> String { + match serde_json::from_value::(input.clone()) { + Ok(input) => { + let src = input.source_path.as_str(); + let dest = input.destination_path.as_str(); + format!("Copy `{src}` to `{dest}`") + } + Err(_) => "Copy path".to_string(), + } + } + + fn run( + self: Arc, + input: serde_json::Value, + _messages: &[LanguageModelRequestMessage], + project: Entity, + _action_log: Entity, + cx: &mut App, + ) -> Task> { + let input = match serde_json::from_value::(input) { + Ok(input) => input, + Err(err) => return Task::ready(Err(anyhow!(err))), + }; + let copy_task = project.update(cx, |project, cx| { + match project + .find_project_path(&input.source_path, cx) + .and_then(|project_path| project.entry_for_path(&project_path, cx)) + { + Some(entity) => match project.find_project_path(&input.destination_path, cx) { + Some(project_path) => { + project.copy_entry(entity.id, None, project_path.path, cx) + } + None => Task::ready(Err(anyhow!( + "Destination path {} was outside the project.", + input.destination_path + ))), + }, + None => Task::ready(Err(anyhow!( + "Source path {} was not found in the project.", + input.source_path + ))), + } + }); + + cx.background_spawn(async move { + match copy_task.await { + Ok(_) => Ok(format!( + "Copied {} to {}", + input.source_path, input.destination_path + )), + Err(err) => Err(anyhow!( + "Failed to copy {} to {}: {}", + input.source_path, + input.destination_path, + err + )), + } + }) + } +} diff --git a/crates/assistant_tools/src/copy_path_tool/description.md b/crates/assistant_tools/src/copy_path_tool/description.md new file mode 100644 index 0000000000..a5105e6f18 --- /dev/null +++ b/crates/assistant_tools/src/copy_path_tool/description.md @@ -0,0 +1,6 @@ +Copies a file or directory in the project, and returns confirmation that the copy succeeded. +Directory contents will be copied recursively (like `cp -r`). + +This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. +It's much more efficient than doing this by separately reading and then writing the file or directory's contents, +so this tool should be preferred over that approach whenever copying is the goal. diff --git a/crates/assistant_tools/src/create_file_tool.rs b/crates/assistant_tools/src/create_file_tool.rs new file mode 100644 index 0000000000..dc1b5c0f03 --- /dev/null +++ b/crates/assistant_tools/src/create_file_tool.rs @@ -0,0 +1,111 @@ +use anyhow::{anyhow, Result}; +use assistant_tool::{ActionLog, Tool}; +use gpui::{App, Entity, Task}; +use language_model::LanguageModelRequestMessage; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ui::IconName; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CreateFileToolInput { + /// The path where the file should be created. + /// + /// + /// If the project has the following structure: + /// + /// - directory1/ + /// - directory2/ + /// + /// You can create a new file by providing a path of "directory1/new_file.txt" + /// + pub path: String, + + /// The text contents of the file to create. + /// + /// + /// To create a file with the text "Hello, World!", provide contents of "Hello, World!" + /// + pub contents: String, +} + +pub struct CreateFileTool; + +impl Tool for CreateFileTool { + fn name(&self) -> String { + "create-file".into() + } + + fn needs_confirmation(&self) -> bool { + true + } + + fn description(&self) -> String { + include_str!("./create_file_tool/description.md").into() + } + + fn icon(&self) -> IconName { + IconName::File + } + + fn input_schema(&self) -> serde_json::Value { + let schema = schemars::schema_for!(CreateFileToolInput); + serde_json::to_value(&schema).unwrap() + } + + fn ui_text(&self, input: &serde_json::Value) -> String { + match serde_json::from_value::(input.clone()) { + Ok(input) => { + let path = input.path.as_str(); + format!("Create file `{path}`") + } + Err(_) => "Create file".to_string(), + } + } + + fn run( + self: Arc, + input: serde_json::Value, + _messages: &[LanguageModelRequestMessage], + project: Entity, + _action_log: Entity, + cx: &mut App, + ) -> Task> { + let input = match serde_json::from_value::(input) { + Ok(input) => input, + Err(err) => return Task::ready(Err(anyhow!(err))), + }; + let project_path = match project.read(cx).find_project_path(&input.path, cx) { + Some(project_path) => project_path, + None => return Task::ready(Err(anyhow!("Path to create was outside the project"))), + }; + let contents: Arc = input.contents.as_str().into(); + let destination_path: Arc = input.path.as_str().into(); + + cx.spawn(async move |cx| { + project + .update(cx, |project, cx| { + project.create_entry(project_path.clone(), false, cx) + })? + .await + .map_err(|err| anyhow!("Unable to create {destination_path}: {err}"))?; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + })? + .await + .map_err(|err| anyhow!("Unable to open buffer for {destination_path}: {err}"))?; + buffer.update(cx, |buffer, cx| { + buffer.set_text(contents, cx); + })?; + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await + .map_err(|err| anyhow!("Unable to save buffer for {destination_path}: {err}"))?; + + Ok(format!("Created file {destination_path}")) + }) + } +} diff --git a/crates/assistant_tools/src/create_file_tool/description.md b/crates/assistant_tools/src/create_file_tool/description.md new file mode 100644 index 0000000000..fc470829ff --- /dev/null +++ b/crates/assistant_tools/src/create_file_tool/description.md @@ -0,0 +1,3 @@ +Creates a new file at the specified path within the project, containing the given text content. Returns confirmation that the file was created. + +This tool is the most efficient way to create new files within the project, so it should always be chosen whenever it's necessary to create a new file in the project with specific text content, or whenever a file in the project needs such a drastic change that you would prefer to replace the entire thing instead of making individual edits. This tool should not be used when making changes to parts of an existing file but not all of it. In those cases, it's better to use another approach to edit the file. diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index cc13d34e80..3bd55e6c3a 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -6,6 +6,7 @@ use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DeletePathToolInput { @@ -38,6 +39,10 @@ impl Tool for DeletePathTool { include_str!("./delete_path_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Trash + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(DeletePathToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 95aec472a5..a3638a431f 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -11,6 +11,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DiagnosticsToolInput { @@ -45,6 +46,10 @@ impl Tool for DiagnosticsTool { include_str!("./diagnostics_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Warning + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(DiagnosticsToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/edit_files_tool.rs b/crates/assistant_tools/src/edit_files_tool.rs index dad870851f..08b275c24a 100644 --- a/crates/assistant_tools/src/edit_files_tool.rs +++ b/crates/assistant_tools/src/edit_files_tool.rs @@ -1,23 +1,23 @@ mod edit_action; pub mod log; -mod replace; +use crate::replace::{replace_exact, replace_with_flexible_indent}; use anyhow::{anyhow, Context, Result}; use assistant_tool::{ActionLog, Tool}; use collections::HashSet; use edit_action::{EditAction, EditActionParser}; -use futures::StreamExt; -use gpui::{App, AsyncApp, Entity, Task}; +use futures::{channel::mpsc, SinkExt, StreamExt}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use language_model::{ LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, }; use log::{EditToolLog, EditToolRequestId}; use project::Project; -use replace::{replace_exact, replace_with_flexible_indent}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::sync::Arc; +use ui::IconName; use util::ResultExt; #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -87,6 +87,10 @@ impl Tool for EditFilesTool { include_str!("./edit_files_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Pencil + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(EditFilesToolInput); serde_json::to_value(&schema).unwrap() @@ -149,22 +153,36 @@ impl Tool for EditFilesTool { struct EditToolRequest { parser: EditActionParser, - output: String, - changed_buffers: HashSet>, - bad_searches: Vec, + editor_response: EditorResponse, project: Entity, action_log: Entity, tool_log: Option<(Entity, EditToolRequestId)>, } -#[derive(Debug)] -enum DiffResult { - BadSearch(BadSearch), - Diff(language::Diff), +enum EditorResponse { + /// The editor model hasn't produced any actions yet. + /// If we don't have any by the end, we'll return its message to the architect model. + Message(String), + /// The editor model produced at least one action. + Actions { + applied: Vec, + search_errors: Vec, + }, +} + +struct AppliedAction { + source: String, + buffer: Entity, } #[derive(Debug)] -enum BadSearch { +enum DiffResult { + Diff(language::Diff), + SearchError(SearchError), +} + +#[derive(Debug)] +enum SearchError { NoMatch { file_path: String, search: String, @@ -225,24 +243,36 @@ impl EditToolRequest { temperature: Some(0.0), }; + let (mut tx, mut rx) = mpsc::channel::(32); let stream = model.stream_completion_text(llm_request, &cx); - let mut chunks = stream.await?; + let reader_task = cx.background_spawn(async move { + let mut chunks = stream.await?; + + while let Some(chunk) = chunks.stream.next().await { + if let Some(chunk) = chunk.log_err() { + // we don't process here because the API fails + // if we take too long between reads + tx.send(chunk).await? + } + } + tx.close().await?; + anyhow::Ok(()) + }); let mut request = Self { parser: EditActionParser::new(), - // we start with the success header so we don't need to shift the output in the common case - output: Self::SUCCESS_OUTPUT_HEADER.to_string(), - changed_buffers: HashSet::default(), - bad_searches: Vec::new(), + editor_response: EditorResponse::Message(String::with_capacity(256)), action_log, project, tool_log, }; - while let Some(chunk) = chunks.stream.next().await { - request.process_response_chunk(&chunk?, cx).await?; + while let Some(chunk) = rx.next().await { + request.process_response_chunk(&chunk, cx).await?; } + reader_task.await?; + request.finalize(cx).await }) } @@ -250,6 +280,12 @@ impl EditToolRequest { async fn process_response_chunk(&mut self, chunk: &str, cx: &mut AsyncApp) -> Result<()> { let new_actions = self.parser.parse_chunk(chunk); + if let EditorResponse::Message(ref mut message) = self.editor_response { + if new_actions.is_empty() { + message.push_str(chunk); + } + } + if let Some((ref log, req_id)) = self.tool_log { log.update(cx, |log, cx| { log.push_editor_response_chunk(req_id, chunk, &new_actions, cx) @@ -300,18 +336,45 @@ impl EditToolRequest { }?; match result { - DiffResult::BadSearch(invalid_replace) => { - self.bad_searches.push(invalid_replace); + DiffResult::SearchError(error) => { + self.push_search_error(error); } DiffResult::Diff(diff) => { let _clock = buffer.update(cx, |buffer, cx| buffer.apply_diff(diff, cx))?; - write!(&mut self.output, "\n\n{}", source)?; - self.changed_buffers.insert(buffer); + self.push_applied_action(AppliedAction { source, buffer }); } } - Ok(()) + anyhow::Ok(()) + } + + fn push_search_error(&mut self, error: SearchError) { + match &mut self.editor_response { + EditorResponse::Message(_) => { + self.editor_response = EditorResponse::Actions { + applied: Vec::new(), + search_errors: vec![error], + }; + } + EditorResponse::Actions { search_errors, .. } => { + search_errors.push(error); + } + } + } + + fn push_applied_action(&mut self, action: AppliedAction) { + match &mut self.editor_response { + EditorResponse::Message(_) => { + self.editor_response = EditorResponse::Actions { + applied: vec![action], + search_errors: Vec::new(), + }; + } + EditorResponse::Actions { applied, .. } => { + applied.push(action); + } + } } async fn replace_diff( @@ -325,152 +388,166 @@ impl EditToolRequest { .file() .map_or(false, |file| file.disk_state().exists()); - return Ok(DiffResult::BadSearch(BadSearch::EmptyBuffer { + let error = SearchError::EmptyBuffer { file_path: file_path.display().to_string(), exists, search: old, - })); + }; + + return Ok(DiffResult::SearchError(error)); } - let result = + let replace_result = // Try to match exactly replace_exact(&old, &new, &snapshot) .await // If that fails, try being flexible about indentation .or_else(|| replace_with_flexible_indent(&old, &new, &snapshot)); - let Some(diff) = result else { - return anyhow::Ok(DiffResult::BadSearch(BadSearch::NoMatch { + let Some(diff) = replace_result else { + let error = SearchError::NoMatch { search: old, file_path: file_path.display().to_string(), - })); + }; + + return Ok(DiffResult::SearchError(error)); }; - anyhow::Ok(DiffResult::Diff(diff)) + Ok(DiffResult::Diff(diff)) } - const SUCCESS_OUTPUT_HEADER: &str = "Successfully applied. Here's a list of changes:"; - const ERROR_OUTPUT_HEADER_NO_EDITS: &str = "I couldn't apply any edits!"; - const ERROR_OUTPUT_HEADER_WITH_EDITS: &str = - "Errors occurred. First, here's a list of the edits we managed to apply:"; - async fn finalize(self, cx: &mut AsyncApp) -> Result { - let changed_buffer_count = self.changed_buffers.len(); + match self.editor_response { + EditorResponse::Message(message) => Err(anyhow!( + "No edits were applied! You might need to provide more context.\n\n{}", + message + )), + EditorResponse::Actions { + applied, + search_errors, + } => { + let mut output = String::with_capacity(1024); - // Save each buffer once at the end - for buffer in &self.changed_buffers { - self.project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? - .await?; - } + let parse_errors = self.parser.errors(); + let has_errors = !search_errors.is_empty() || !parse_errors.is_empty(); - self.action_log - .update(cx, |log, cx| log.buffer_edited(self.changed_buffers, cx)) - .log_err(); + if has_errors { + let error_count = search_errors.len() + parse_errors.len(); - let errors = self.parser.errors(); + if applied.is_empty() { + writeln!( + &mut output, + "{} errors occurred! No edits were applied.", + error_count, + )?; + } else { + writeln!( + &mut output, + "{} errors occurred, but {} edits were correctly applied.", + error_count, + applied.len(), + )?; - if errors.is_empty() && self.bad_searches.is_empty() { - if changed_buffer_count == 0 { - return Err(anyhow!( - "The instructions didn't lead to any changes. You might need to consult the file contents first." - )); - } + writeln!( + &mut output, + "# {} SEARCH/REPLACE block(s) applied:\n\nDo not re-send these since they are already applied!\n", + applied.len() + )?; + } + } else { + write!( + &mut output, + "Successfully applied! Here's a list of applied edits:" + )?; + } - Ok(self.output) - } else { - let mut output = self.output; + let mut changed_buffers = HashSet::default(); - if output.is_empty() { - output.replace_range( - 0..Self::SUCCESS_OUTPUT_HEADER.len(), - Self::ERROR_OUTPUT_HEADER_NO_EDITS, - ); - } else { - output.replace_range( - 0..Self::SUCCESS_OUTPUT_HEADER.len(), - Self::ERROR_OUTPUT_HEADER_WITH_EDITS, - ); - } + for action in applied { + changed_buffers.insert(action.buffer); + write!(&mut output, "\n\n{}", action.source)?; + } - if !self.bad_searches.is_empty() { - writeln!( - &mut output, - "\n\n# {} SEARCH/REPLACE block(s) failed to match:\n", - self.bad_searches.len() - )?; + for buffer in &changed_buffers { + self.project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + } - for bad_search in self.bad_searches { - match bad_search { - BadSearch::NoMatch { file_path, search } => { - writeln!( - &mut output, - "## No exact match in: `{}`\n```\n{}\n```\n", - file_path, search, - )?; - } - BadSearch::EmptyBuffer { - file_path, - exists: true, - search, - } => { - writeln!( - &mut output, - "## No match because `{}` is empty:\n```\n{}\n```\n", - file_path, search, - )?; - } - BadSearch::EmptyBuffer { - file_path, - exists: false, - search, - } => { - writeln!( - &mut output, - "## No match because `{}` does not exist:\n```\n{}\n```\n", - file_path, search, - )?; + self.action_log + .update(cx, |log, cx| log.buffer_edited(changed_buffers.clone(), cx)) + .log_err(); + + if !search_errors.is_empty() { + writeln!( + &mut output, + "\n\n## {} SEARCH/REPLACE block(s) failed to match:\n", + search_errors.len() + )?; + + for error in search_errors { + match error { + SearchError::NoMatch { file_path, search } => { + writeln!( + &mut output, + "### No exact match in: `{}`\n```\n{}\n```\n", + file_path, search, + )?; + } + SearchError::EmptyBuffer { + file_path, + exists: true, + search, + } => { + writeln!( + &mut output, + "### No match because `{}` is empty:\n```\n{}\n```\n", + file_path, search, + )?; + } + SearchError::EmptyBuffer { + file_path, + exists: false, + search, + } => { + writeln!( + &mut output, + "### No match because `{}` does not exist:\n```\n{}\n```\n", + file_path, search, + )?; + } } } + + write!(&mut output, + "The SEARCH section must exactly match an existing block of lines including all white \ + space, comments, indentation, docstrings, etc." + )?; + } + + if !parse_errors.is_empty() { + writeln!( + &mut output, + "\n\n## {} SEARCH/REPLACE blocks failed to parse:", + parse_errors.len() + )?; + + for error in parse_errors { + writeln!(&mut output, "- {}", error)?; + } } - write!(&mut output, - "The SEARCH section must exactly match an existing block of lines including all white \ - space, comments, indentation, docstrings, etc." - )?; - } + if has_errors { + writeln!(&mut output, + "\n\nYou can fix errors by running the tool again. You can include instructions, \ + but errors are part of the conversation so you don't need to repeat them.", + )?; - if !errors.is_empty() { - writeln!( - &mut output, - "\n\n# {} SEARCH/REPLACE blocks failed to parse:", - errors.len() - )?; - - for error in errors { - writeln!(&mut output, "- {}", error)?; - } - } - - if changed_buffer_count > 0 { - writeln!( - &mut output, - "\n\nThe other SEARCH/REPLACE blocks were applied successfully. Do not re-send them!", - )?; - } - - writeln!( - &mut output, - "{}You can fix errors by running the tool again. You can include instructions, \ - but errors are part of the conversation so you don't need to repeat them.", - if changed_buffer_count == 0 { - "\n\n" + Err(anyhow!(output)) } else { - "" + Ok(output) } - )?; - - Err(anyhow!(output)) + } } } } diff --git a/crates/assistant_tools/src/edit_files_tool/description.md b/crates/assistant_tools/src/edit_files_tool/description.md index 2f6c3ebe19..76e242a15d 100644 --- a/crates/assistant_tools/src/edit_files_tool/description.md +++ b/crates/assistant_tools/src/edit_files_tool/description.md @@ -3,3 +3,7 @@ Edit files in the current project by specifying instructions in natural language When using this tool, you should suggest one coherent edit that can be made to the codebase. When the set of edits you want to make is large or complex, feel free to invoke this tool multiple times, each time focusing on a specific change you wanna make. + +You should use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents, and you absolutely must never use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach. + +DO NOT call this tool until the code to be edited appears in the conversation! You must use the `read-files` tool or ask the user to add it to context first. diff --git a/crates/assistant_tools/src/edit_files_tool/edit_prompt.md b/crates/assistant_tools/src/edit_files_tool/edit_prompt.md index fe5a4ca647..f4f3895157 100644 --- a/crates/assistant_tools/src/edit_files_tool/edit_prompt.md +++ b/crates/assistant_tools/src/edit_files_tool/edit_prompt.md @@ -120,7 +120,7 @@ Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each ch Include just the changing lines, and a few surrounding lines if needed for uniqueness. Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks. -Only create *SEARCH/REPLACE* blocks for files that the user has added to the chat! +Only create *SEARCH/REPLACE* blocks for files that have been read! Even though the conversation includes `read-file` tool results, you *CANNOT* issue your own reads. If the conversation doesn't include the code you need to edit, ask for it to be read explicitly. To move code within a file, use 2 *SEARCH/REPLACE* blocks: 1 to delete it from its current location, 1 to insert it in the new location. diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 9d9d9c75ff..a9895f0fbe 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -12,6 +12,7 @@ use language_model::LanguageModelRequestMessage; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use ui::IconName; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum ContentType { @@ -121,6 +122,10 @@ impl Tool for FetchTool { include_str!("./fetch_tool/description.md").to_string() } + fn icon(&self) -> IconName { + IconName::Globe + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(FetchToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/find_replace_file_tool.rs b/crates/assistant_tools/src/find_replace_file_tool.rs new file mode 100644 index 0000000000..d13fbff4db --- /dev/null +++ b/crates/assistant_tools/src/find_replace_file_tool.rs @@ -0,0 +1,229 @@ +use anyhow::{anyhow, Context as _, Result}; +use assistant_tool::{ActionLog, Tool}; +use gpui::{App, AppContext, Entity, Task}; +use language_model::LanguageModelRequestMessage; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, path::PathBuf, sync::Arc}; +use ui::IconName; + +use crate::replace::replace_exact; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FindReplaceFileToolInput { + /// The path of the file to modify. + /// + /// WARNING: When specifying which file path need changing, you MUST + /// start each path with one of the project's root directories. + /// + /// The following examples assume we have two root directories in the project: + /// - backend + /// - frontend + /// + /// + /// `backend/src/main.rs` + /// + /// Notice how the file path starts with root-1. Without that, the path + /// would be ambiguous and the call would fail! + /// + /// + /// + /// `frontend/db.js` + /// + pub path: PathBuf, + + /// A user-friendly description of what's being replaced. This will be shown in the UI. + /// + /// Fix API endpoint URLs + /// Update copyright year + pub display_description: String, + + /// The unique string to find in the file. This string cannot be empty; + /// if the string is empty, the tool call will fail. Remember, do not use this tool + /// to create new files from scratch, or to overwrite existing files! Use a different + /// approach if you want to do that. + /// + /// If this string appears more than once in the file, this tool call will fail, + /// so it is absolutely critical that you verify ahead of time that the string + /// is unique. You can search within the file to verify this. + /// + /// To make the string more likely to be unique, include a minimum of 3 lines of context + /// before the string you actually want to find, as well as a minimum of 3 lines of + /// context after the string you want to find. (These lines of context should appear + /// in the `replace` string as well.) If 3 lines of context is not enough to obtain + /// a string that appears only once in the file, then double the number of context lines + /// until the string becomes unique. (Start with 3 lines before and 3 lines after + /// though, because too much context is needlessly costly.) + /// + /// Do not alter the context lines of code in any way, and make sure to preserve all + /// whitespace and indentation for all lines of code. This string must be exactly as + /// it appears in the file, because this tool will do a literal find/replace, and if + /// even one character in this string is different in any way from how it appears + /// in the file, then the tool call will fail. + /// + /// + /// If a file contains this code: + /// + /// ```rust + /// fn check_user_permissions(user_id: &str) -> Result { + /// // Check if user exists first + /// let user = database.find_user(user_id)?; + /// + /// // This is the part we want to modify + /// if user.role == "admin" { + /// return Ok(true); + /// } + /// + /// // Check other permissions + /// check_custom_permissions(user_id) + /// } + /// ``` + /// + /// Your find string should include at least 3 lines of context before and after the part + /// you want to change: + /// + /// ``` + /// fn check_user_permissions(user_id: &str) -> Result { + /// // Check if user exists first + /// let user = database.find_user(user_id)?; + /// + /// // This is the part we want to modify + /// if user.role == "admin" { + /// return Ok(true); + /// } + /// + /// // Check other permissions + /// check_custom_permissions(user_id) + /// } + /// ``` + /// + /// And your replace string might look like: + /// + /// ``` + /// fn check_user_permissions(user_id: &str) -> Result { + /// // Check if user exists first + /// let user = database.find_user(user_id)?; + /// + /// // This is the part we want to modify + /// if user.role == "admin" || user.role == "superuser" { + /// return Ok(true); + /// } + /// + /// // Check other permissions + /// check_custom_permissions(user_id) + /// } + /// ``` + /// + pub find: String, + + /// The string to replace the one unique occurrence of the find string with. + pub replace: String, +} + +pub struct FindReplaceFileTool; + +impl Tool for FindReplaceFileTool { + fn name(&self) -> String { + "find-replace-file".into() + } + + fn needs_confirmation(&self) -> bool { + true + } + + fn description(&self) -> String { + include_str!("find_replace_tool/description.md").to_string() + } + + fn icon(&self) -> IconName { + IconName::Pencil + } + + fn input_schema(&self) -> serde_json::Value { + let schema = schemars::schema_for!(FindReplaceFileToolInput); + serde_json::to_value(&schema).unwrap() + } + + fn ui_text(&self, input: &serde_json::Value) -> String { + match serde_json::from_value::(input.clone()) { + Ok(input) => input.display_description, + Err(_) => "Edit file".to_string(), + } + } + + fn run( + self: Arc, + input: serde_json::Value, + _messages: &[LanguageModelRequestMessage], + project: Entity, + action_log: Entity, + cx: &mut App, + ) -> Task> { + let input = match serde_json::from_value::(input) { + Ok(input) => input, + Err(err) => return Task::ready(Err(anyhow!(err))), + }; + + cx.spawn(async move |cx| { + let project_path = project.read_with(cx, |project, cx| { + project + .find_project_path(&input.path, cx) + .context("Path not found in project") + })??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx))? + .await?; + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + if input.find.is_empty() { + return Err(anyhow!("`find` string cannot be empty. Use a different tool if you want to create a file.")); + } + + let result = cx + .background_spawn(async move { + replace_exact(&input.find, &input.replace, &snapshot).await + }) + .await; + + if let Some(diff) = result { + buffer.update(cx, |buffer, cx| { + let _ = buffer.apply_diff(diff, cx); + })?; + + project.update(cx, |project, cx| { + project.save_buffer(buffer.clone(), cx) + })?.await?; + + action_log.update(cx, |log, cx| { + let mut buffers = HashSet::default(); + buffers.insert(buffer); + log.buffer_edited(buffers, cx); + })?; + + Ok(format!("Edited {}", input.path.display())) + } else { + let err = buffer.read_with(cx, |buffer, _cx| { + let file_exists = buffer + .file() + .map_or(false, |file| file.disk_state().exists()); + + if !file_exists { + anyhow!("{} does not exist", input.path.display()) + } else if buffer.is_empty() { + anyhow!( + "{} is empty, so the provided `find` string wasn't found.", + input.path.display() + ) + } else { + anyhow!("Failed to match the provided `find` string") + } + })?; + + Err(err) + } + }) + } +} diff --git a/crates/assistant_tools/src/find_replace_tool/description.md b/crates/assistant_tools/src/find_replace_tool/description.md new file mode 100644 index 0000000000..ba2b05708e --- /dev/null +++ b/crates/assistant_tools/src/find_replace_tool/description.md @@ -0,0 +1,7 @@ +Find one unique part of a file in the project and replace that text with new text. + +This tool is the preferred way to make edits to files. If you have multiple edits to make, including edits across multiple files, then make a plan to respond with a single message containing multiple calls to this tool - one call for each find/replace operation. + +You should use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents. You also should not use this tool when you want to move or rename a file. You absolutely must NEVER use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach. + +DO NOT call this tool until the code to be edited appears in the conversation! You must use another tool to read the file's contents into the conversation, or ask the user to add it to context first. diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 813a65d450..9ceba3b129 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -6,6 +6,7 @@ use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{fmt::Write, path::Path, sync::Arc}; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ListDirectoryToolInput { @@ -49,6 +50,10 @@ impl Tool for ListDirectoryTool { include_str!("./list_directory_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Folder + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(ListDirectoryToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index 09b04fcad8..f54bfecf78 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -6,6 +6,7 @@ use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{path::Path, sync::Arc}; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MovePathToolInput { @@ -47,6 +48,10 @@ impl Tool for MovePathTool { include_str!("./move_path_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::ArrowRightLeft + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(MovePathToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index 04219b2bab..3860504869 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -8,6 +8,7 @@ use language_model::LanguageModelRequestMessage; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -39,6 +40,10 @@ impl Tool for NowTool { "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() } + fn icon(&self) -> IconName { + IconName::Info + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(NowToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/path_search_tool.rs b/crates/assistant_tools/src/path_search_tool.rs index e2621e3f96..c3bdfbe4e0 100644 --- a/crates/assistant_tools/src/path_search_tool.rs +++ b/crates/assistant_tools/src/path_search_tool.rs @@ -6,6 +6,7 @@ use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; +use ui::IconName; use util::paths::PathMatcher; use worktree::Snapshot; @@ -47,6 +48,10 @@ impl Tool for PathSearchTool { include_str!("./path_search_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::SearchCode + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(PathSearchToolInput); serde_json::to_value(&schema).unwrap() @@ -71,7 +76,11 @@ impl Tool for PathSearchTool { Ok(input) => (input.offset.unwrap_or(0), input.glob), Err(err) => return Task::ready(Err(anyhow!(err))), }; - let path_matcher = match PathMatcher::new(&[glob.clone()]) { + + let path_matcher = match PathMatcher::new([ + // Sometimes models try to search for "". In this case, return all paths in the project. + if glob.is_empty() { "*" } else { &glob }, + ]) { Ok(matcher) => matcher, Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), }; diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 550d4e64e4..4022f326cd 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -9,6 +9,7 @@ use language_model::LanguageModelRequestMessage; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadFileToolInput { @@ -52,6 +53,10 @@ impl Tool for ReadFileTool { include_str!("./read_file_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Eye + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(ReadFileToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/regex_search_tool.rs b/crates/assistant_tools/src/regex_search_tool.rs index 57f91245c5..3bd6d26fa3 100644 --- a/crates/assistant_tools/src/regex_search_tool.rs +++ b/crates/assistant_tools/src/regex_search_tool.rs @@ -11,6 +11,7 @@ use project::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{cmp, fmt::Write, sync::Arc}; +use ui::IconName; use util::paths::PathMatcher; #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -49,6 +50,10 @@ impl Tool for RegexSearchTool { include_str!("./regex_search_tool/description.md").into() } + fn icon(&self) -> IconName { + IconName::Regex + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(RegexSearchToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/assistant_tools/src/edit_files_tool/replace.rs b/crates/assistant_tools/src/replace.rs similarity index 99% rename from crates/assistant_tools/src/edit_files_tool/replace.rs rename to crates/assistant_tools/src/replace.rs index f98aa7e4fa..46f54bb8a5 100644 --- a/crates/assistant_tools/src/edit_files_tool/replace.rs +++ b/crates/assistant_tools/src/replace.rs @@ -1,5 +1,6 @@ use language::{BufferSnapshot, Diff, Point, ToOffset}; use project::search::SearchQuery; +use std::iter; use util::{paths::PathMatcher, ResultExt as _}; /// Performs an exact string replacement in a buffer, requiring precise character-for-character matching. @@ -11,8 +12,8 @@ pub async fn replace_exact(old: &str, new: &str, snapshot: &BufferSnapshot) -> O false, true, true, - PathMatcher::new(&[]).ok()?, - PathMatcher::new(&[]).ok()?, + PathMatcher::new(iter::empty::<&str>()).ok()?, + PathMatcher::new(iter::empty::<&str>()).ok()?, None, ) .log_err()?; diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 3d020c001a..4e87dddde5 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -7,6 +7,7 @@ use language_model::LanguageModelRequestMessage; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ThinkingToolInput { @@ -30,6 +31,10 @@ impl Tool for ThinkingTool { include_str!("./thinking_tool/description.md").to_string() } + fn icon(&self) -> IconName { + IconName::Brain + } + fn input_schema(&self) -> serde_json::Value { let schema = schemars::schema_for!(ThinkingToolInput); serde_json::to_value(&schema).unwrap() diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 0ae9095021..4a08961b0f 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -7,8 +7,7 @@ use std::cmp::Ordering; use std::mem; use std::{future::Future, iter, ops::Range, sync::Arc}; use sum_tree::SumTree; -use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point}; -use text::{AnchorRangeExt, ToOffset as _}; +use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _}; use util::ResultExt; pub struct BufferDiff { @@ -189,7 +188,7 @@ impl BufferDiffSnapshot { impl BufferDiffInner { /// Returns the new index text and new pending hunks. - fn stage_or_unstage_hunks( + fn stage_or_unstage_hunks_impl( &mut self, unstaged_diff: &Self, stage: bool, @@ -234,9 +233,6 @@ impl BufferDiffInner { } }; - let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); - unstaged_hunk_cursor.next(buffer); - let mut pending_hunks = SumTree::new(buffer); let mut old_pending_hunks = unstaged_diff .pending_hunks @@ -252,18 +248,16 @@ impl BufferDiffInner { { let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left, buffer); - pending_hunks.append(preceding_pending_hunks, buffer); - // skip all overlapping old pending hunks - while old_pending_hunks - .item() - .is_some_and(|preceding_pending_hunk_item| { - preceding_pending_hunk_item - .buffer_range - .overlaps(&buffer_range, buffer) - }) - { + // Skip all overlapping or adjacent old pending hunks + while old_pending_hunks.item().is_some_and(|old_hunk| { + old_hunk + .buffer_range + .start + .cmp(&buffer_range.end, buffer) + .is_le() + }) { old_pending_hunks.next(buffer); } @@ -291,6 +285,9 @@ impl BufferDiffInner { // append the remainder pending_hunks.append(old_pending_hunks.suffix(buffer), buffer); + let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); + unstaged_hunk_cursor.next(buffer); + let mut prev_unstaged_hunk_buffer_offset = 0; let mut prev_unstaged_hunk_base_text_offset = 0; let mut edits = Vec::<(Range, String)>::new(); @@ -357,7 +354,13 @@ impl BufferDiffInner { edits.push((index_range, replacement_text)); } - debug_assert!(edits.iter().is_sorted_by_key(|(range, _)| range.start)); + #[cfg(debug_assertions)] // invariants: non-overlapping and sorted + { + for window in edits.windows(2) { + let (range_a, range_b) = (&window[0].0, &window[1].0); + debug_assert!(range_a.end < range_b.start); + } + } let mut new_index_text = Rope::new(); let mut index_cursor = index_text.cursor(0); @@ -854,7 +857,7 @@ impl BufferDiff { file_exists: bool, cx: &mut Context, ) -> Option { - let (new_index_text, new_pending_hunks) = self.inner.stage_or_unstage_hunks( + let (new_index_text, new_pending_hunks) = self.inner.stage_or_unstage_hunks_impl( &self.secondary_diff.as_ref()?.read(cx).inner, stage, &hunks, @@ -1240,13 +1243,13 @@ impl DiffHunkStatus { } } -/// Range (crossing new lines), old, new #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn assert_hunks( diff_hunks: HunkIter, buffer: &text::BufferSnapshot, diff_base: &str, + // Line range, deleted, added, status expected_hunks: &[(Range, ExpectedText, ExpectedText, DiffHunkStatus)], ) where HunkIter: Iterator, @@ -1267,11 +1270,11 @@ pub fn assert_hunks( let expected_hunks: Vec<_> = expected_hunks .iter() - .map(|(r, old_text, new_text, status)| { + .map(|(line_range, deleted_text, added_text, status)| { ( - Point::new(r.start, 0)..Point::new(r.end, 0), - old_text.as_ref(), - new_text.as_ref().to_string(), + Point::new(line_range.start, 0)..Point::new(line_range.end, 0), + deleted_text.as_ref(), + added_text.as_ref().to_string(), *status, ) }) @@ -1286,6 +1289,7 @@ mod tests { use super::*; use gpui::TestAppContext; + use pretty_assertions::{assert_eq, assert_ne}; use rand::{rngs::StdRng, Rng as _}; use text::{Buffer, BufferId, Rope}; use unindent::Unindent as _; @@ -1705,6 +1709,66 @@ mod tests { } } + #[gpui::test] + async fn test_toggling_stage_and_unstage_same_hunk(cx: &mut TestAppContext) { + let head_text = " + one + two + three + " + .unindent(); + let index_text = head_text.clone(); + let buffer_text = " + one + three + " + .unindent(); + + let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text.clone()); + let unstaged = BufferDiff::build_sync(buffer.clone(), index_text, cx); + let uncommitted = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx); + let unstaged_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer, cx); + diff.set_state(unstaged, &buffer); + diff + }); + let uncommitted_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer, cx); + diff.set_state(uncommitted, &buffer); + diff.set_secondary_diff(unstaged_diff.clone()); + diff + }); + + uncommitted_diff.update(cx, |diff, cx| { + let hunk = diff.hunks(&buffer, cx).next().unwrap(); + + let new_index_text = diff + .stage_or_unstage_hunks(true, &[hunk.clone()], &buffer, true, cx) + .unwrap() + .to_string(); + assert_eq!(new_index_text, buffer_text); + + let hunk = diff.hunks(&buffer, &cx).next().unwrap(); + assert_eq!( + hunk.secondary_status, + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + ); + + let index_text = diff + .stage_or_unstage_hunks(false, &[hunk], &buffer, true, cx) + .unwrap() + .to_string(); + assert_eq!(index_text, head_text); + + let hunk = diff.hunks(&buffer, &cx).next().unwrap(); + // optimistically unstaged (fine, could also be HasSecondaryHunk) + assert_eq!( + hunk.secondary_status, + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + ); + }); + } + #[gpui::test] async fn test_buffer_diff_compare(cx: &mut TestAppContext) { let base_text = " diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 95025c65ca..b74c1f0c7b 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use channel::{ChannelChat, ChannelStore, MessageParams}; use client::{UserId, UserStore}; use collections::HashSet; -use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle}; +use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight, @@ -56,6 +56,7 @@ struct MessageEditorCompletionProvider(WeakEntity); impl CompletionProvider for MessageEditorCompletionProvider { fn completions( &self, + _excerpt_id: ExcerptId, buffer: &Entity, buffer_position: language::Anchor, _: editor::CompletionContext, @@ -311,6 +312,7 @@ impl MessageEditor { old_range: range.clone(), new_text, label, + icon_path: None, confirm: None, documentation: None, source: CompletionSource::Custom, diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index 075eec3a0a..f5fd3531b1 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -11,7 +11,7 @@ pub struct CollaborationPanelSettings { pub default_width: Pixels, } -#[derive(Clone, Copy, Default, Serialize, JsonSchema, Debug)] +#[derive(Clone, Copy, Default, Serialize, Deserialize, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum ChatPanelButton { Never, @@ -20,50 +20,6 @@ pub enum ChatPanelButton { WhenInCall, } -impl<'de> Deserialize<'de> for ChatPanelButton { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor; - - impl serde::de::Visitor<'_> for Visitor { - type Value = ChatPanelButton; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - r#"a boolean or one of "never", "always", "when_in_call""# - ) - } - - fn visit_bool(self, b: bool) -> Result - where - E: serde::de::Error, - { - match b { - false => Ok(ChatPanelButton::Never), - true => Ok(ChatPanelButton::Always), - } - } - - fn visit_str(self, s: &str) -> Result - where - E: serde::de::Error, - { - match s { - "never" => Ok(ChatPanelButton::Never), - "always" => Ok(ChatPanelButton::Always), - "when_in_call" => Ok(ChatPanelButton::WhenInCall), - _ => Err(E::unknown_variant(s, &["never", "always", "when_in_call"])), - } - } - } - - deserializer.deserialize_any(Visitor) - } -} - #[derive(Deserialize, Debug)] pub struct ChatPanelSettings { pub button: ChatPanelButton, diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 6e3feaf2ee..0d8545c240 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -21,6 +21,7 @@ context_server_settings.workspace = true extension.workspace = true futures.workspace = true gpui.workspace = true +icons.workspace = true language_model.workspace = true log.workspace = true parking_lot.workspace = true diff --git a/crates/context_server/src/context_server_tool.rs b/crates/context_server/src/context_server_tool.rs index 9cc3be1f8a..220d0bc4b8 100644 --- a/crates/context_server/src/context_server_tool.rs +++ b/crates/context_server/src/context_server_tool.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use assistant_tool::{ActionLog, Tool, ToolSource}; use gpui::{App, Entity, Task}; +use icons::IconName; use language_model::LanguageModelRequestMessage; use project::Project; @@ -38,6 +39,10 @@ impl Tool for ContextServerTool { self.tool.description.clone().unwrap_or_default() } + fn icon(&self) -> IconName { + IconName::Cog + } + fn source(&self) -> ToolSource { ToolSource::ContextServer { id: self.server_id.clone().into(), diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 867e8fd3bb..bcdfa8b993 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -26,8 +26,6 @@ test-support = [ [dependencies] anyhow.workspace = true -async-compression.workspace = true -async-tar.workspace = true chrono.workspace = true client.workspace = true collections.workspace = true @@ -49,7 +47,6 @@ schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true settings.workspace = true -smol.workspace = true strum.workspace = true task.workspace = true ui.workspace = true diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 5c963399e3..a945fee2b2 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -4,9 +4,7 @@ pub mod request; mod sign_in; use ::fs::Fs; -use anyhow::{anyhow, Context as _, Result}; -use async_compression::futures::bufread::GzipDecoder; -use async_tar::Archive; +use anyhow::{anyhow, Result}; use collections::{HashMap, HashSet}; use command_palette_hooks::CommandPaletteFilter; use futures::{channel::oneshot, future::Shared, Future, FutureExt, TryFutureExt}; @@ -14,7 +12,6 @@ use gpui::{ actions, App, AppContext as _, AsyncApp, Context, Entity, EntityId, EventEmitter, Global, Task, WeakEntity, }; -use http_client::github::get_release_by_tag_name; use http_client::HttpClient; use language::language_settings::CopilotSettings; use language::{ @@ -27,7 +24,6 @@ use node_runtime::NodeRuntime; use parking_lot::Mutex; use request::StatusNotification; use settings::SettingsStore; -use smol::{fs, io::BufReader, stream::StreamExt}; use std::{ any::TypeId, env, @@ -37,7 +33,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::{fs::remove_matching, maybe, ResultExt}; +use util::{fs::remove_matching, ResultExt}; pub use crate::copilot_completion_provider::CopilotCompletionProvider; pub use crate::sign_in::{initiate_sign_in, CopilotCodeVerification}; @@ -65,7 +61,7 @@ pub fn init( let copilot = cx.new({ let node_runtime = node_runtime.clone(); - move |cx| Copilot::start(new_server_id, http, node_runtime, cx) + move |cx| Copilot::start(new_server_id, node_runtime, cx) }); Copilot::set_global(copilot.clone(), cx); cx.observe(&copilot, |handle, cx| { @@ -305,7 +301,6 @@ pub struct Completion { } pub struct Copilot { - http: Arc, node_runtime: NodeRuntime, server: CopilotServer, buffers: HashSet>, @@ -337,13 +332,11 @@ impl Copilot { fn start( new_server_id: LanguageServerId, - http: Arc, node_runtime: NodeRuntime, cx: &mut Context, ) -> Self { let mut this = Self { server_id: new_server_id, - http, node_runtime, server: CopilotServer::Disabled, buffers: Default::default(), @@ -384,14 +377,12 @@ impl Copilot { return; } let server_id = self.server_id; - let http = self.http.clone(); let node_runtime = self.node_runtime.clone(); let env = self.build_env(&language_settings.edit_predictions.copilot); let start_task = cx .spawn(async move |this, cx| { Self::start_language_server( server_id, - http, node_runtime, env, this, @@ -445,11 +436,9 @@ impl Copilot { Default::default(), &mut cx.to_async(), ); - let http = http_client::FakeHttpClient::create(|_| async { unreachable!() }); let node_runtime = NodeRuntime::unavailable(); let this = cx.new(|cx| Self { server_id: LanguageServerId(0), - http: http.clone(), node_runtime, server: CopilotServer::Running(RunningCopilotServer { lsp: Arc::new(server), @@ -464,7 +453,6 @@ impl Copilot { async fn start_language_server( new_server_id: LanguageServerId, - http: Arc, node_runtime: NodeRuntime, env: Option>, this: WeakEntity, @@ -472,7 +460,7 @@ impl Copilot { cx: &mut AsyncApp, ) { let start_language_server = async { - let server_path = get_copilot_lsp(http).await?; + let server_path = get_copilot_lsp(node_runtime.clone()).await?; let node_path = node_runtime.binary_path().await?; let arguments: Vec = vec![server_path.into(), "--stdio".into()]; let binary = LanguageServerBinary { @@ -506,9 +494,23 @@ impl Copilot { let configuration = lsp::DidChangeConfigurationParams { settings: Default::default(), }; + + let editor_info = request::SetEditorInfoParams { + editor_info: request::EditorInfo { + name: "zed".into(), + version: env!("CARGO_PKG_VERSION").into(), + }, + editor_plugin_info: request::EditorPluginInfo { + name: "zed-copilot".into(), + version: "0.0.1".into(), + }, + }; + let editor_info_json = serde_json::to_value(&editor_info)?; + let server = cx .update(|cx| { - let params = server.default_initialize_params(cx); + let mut params = server.default_initialize_params(cx); + params.initialization_options = Some(editor_info_json); server.initialize(params, configuration.into(), cx) })? .await?; @@ -520,16 +522,7 @@ impl Copilot { .await?; server - .request::(request::SetEditorInfoParams { - editor_info: request::EditorInfo { - name: "zed".into(), - version: env!("CARGO_PKG_VERSION").into(), - }, - editor_plugin_info: request::EditorPluginInfo { - name: "zed-copilot".into(), - version: "0.0.1".into(), - }, - }) + .request::(editor_info) .await?; anyhow::Ok((server, status)) @@ -668,13 +661,11 @@ impl Copilot { let env = self.build_env(&language_settings.edit_predictions.copilot); let start_task = cx .spawn({ - let http = self.http.clone(); let node_runtime = self.node_runtime.clone(); let server_id = self.server_id; async move |this, cx| { clear_copilot_dir().await; - Self::start_language_server(server_id, http, node_runtime, env, this, false, cx) - .await + Self::start_language_server(server_id, node_runtime, env, this, false, cx).await } }) .shared(); @@ -1056,73 +1047,31 @@ async fn clear_copilot_config_dir() { remove_matching(copilot_chat::copilot_chat_config_dir(), |_| true).await } -async fn get_copilot_lsp(http: Arc) -> anyhow::Result { - const SERVER_PATH: &str = "dist/language-server.js"; +async fn get_copilot_lsp(node_runtime: NodeRuntime) -> anyhow::Result { + const PACKAGE_NAME: &str = "@github/copilot-language-server"; + const SERVER_PATH: &str = + "node_modules/@github/copilot-language-server/dist/language-server.js"; - ///Check for the latest copilot language server and download it if we haven't already - async fn fetch_latest(http: Arc) -> anyhow::Result { - let release = - get_release_by_tag_name("zed-industries/copilot", "v0.7.0", http.clone()).await?; + let latest_version = node_runtime + .npm_package_latest_version(PACKAGE_NAME) + .await?; + let server_path = paths::copilot_dir().join(SERVER_PATH); - let version_dir = &paths::copilot_dir().join(format!("copilot-{}", release.tag_name)); - - fs::create_dir_all(version_dir).await?; - let server_path = version_dir.join(SERVER_PATH); - - if fs::metadata(&server_path).await.is_err() { - // Copilot LSP looks for this dist dir specifically, so lets add it in. - let dist_dir = version_dir.join("dist"); - fs::create_dir_all(dist_dir.as_path()).await?; - - let url = &release - .assets - .first() - .context("Github release for copilot contained no assets")? - .browser_download_url; - - let mut response = http - .get(url, Default::default(), true) - .await - .context("error downloading copilot release")?; - let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); - let archive = Archive::new(decompressed_bytes); - archive.unpack(dist_dir).await?; - - remove_matching(paths::copilot_dir(), |entry| entry != version_dir).await; - } - - Ok(server_path) + let should_install = node_runtime + .should_install_npm_package( + PACKAGE_NAME, + &server_path, + paths::copilot_dir(), + &latest_version, + ) + .await; + if should_install { + node_runtime + .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)]) + .await?; } - match fetch_latest(http).await { - ok @ Result::Ok(..) => ok, - e @ Err(..) => { - e.log_err(); - // Fetch a cached binary, if it exists - maybe!(async { - let mut last_version_dir = None; - let mut entries = fs::read_dir(paths::copilot_dir()).await?; - while let Some(entry) = entries.next().await { - let entry = entry?; - if entry.file_type().await?.is_dir() { - last_version_dir = Some(entry.path()); - } - } - let last_version_dir = - last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?; - let server_path = last_version_dir.join(SERVER_PATH); - if server_path.exists() { - Ok(server_path) - } else { - Err(anyhow!( - "missing executable in directory {:?}", - last_version_dir - )) - } - }) - .await - } - } + Ok(server_path) } #[cfg(test)] diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 3c3c193e78..15a4cddf20 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -43,6 +43,11 @@ pub enum Model { Claude3_5Sonnet, #[serde(alias = "claude-3-7-sonnet", rename = "claude-3.7-sonnet")] Claude3_7Sonnet, + #[serde( + alias = "claude-3.7-sonnet-thought", + rename = "claude-3.7-sonnet-thought" + )] + Claude3_7SonnetThinking, #[serde(alias = "gemini-2.0-flash", rename = "gemini-2.0-flash-001")] Gemini20Flash, } @@ -54,7 +59,8 @@ impl Model { | Self::Gpt4 | Self::Gpt3_5Turbo | Self::Claude3_5Sonnet - | Self::Claude3_7Sonnet => true, + | Self::Claude3_7Sonnet + | Self::Claude3_7SonnetThinking => true, Self::O3Mini | Self::O1 | Self::Gemini20Flash => false, } } @@ -68,6 +74,7 @@ impl Model { "o3-mini" => Ok(Self::O3Mini), "claude-3-5-sonnet" => Ok(Self::Claude3_5Sonnet), "claude-3-7-sonnet" => Ok(Self::Claude3_7Sonnet), + "claude-3.7-sonnet-thought" => Ok(Self::Claude3_7SonnetThinking), "gemini-2.0-flash-001" => Ok(Self::Gemini20Flash), _ => Err(anyhow!("Invalid model id: {}", id)), } @@ -82,6 +89,7 @@ impl Model { Self::O1 => "o1", Self::Claude3_5Sonnet => "claude-3-5-sonnet", Self::Claude3_7Sonnet => "claude-3-7-sonnet", + Self::Claude3_7SonnetThinking => "claude-3.7-sonnet-thought", Self::Gemini20Flash => "gemini-2.0-flash-001", } } @@ -95,6 +103,7 @@ impl Model { Self::O1 => "o1", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", + Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking", Self::Gemini20Flash => "Gemini 2.0 Flash", } } @@ -108,6 +117,7 @@ impl Model { Self::O1 => 20_000, Self::Claude3_5Sonnet => 200_000, Self::Claude3_7Sonnet => 90_000, + Self::Claude3_7SonnetThinking => 90_000, Model::Gemini20Flash => 128_000, } } diff --git a/crates/dap_adapters/src/lldb.rs b/crates/dap_adapters/src/lldb.rs index be4800cc76..4d4ecd00c1 100644 --- a/crates/dap_adapters/src/lldb.rs +++ b/crates/dap_adapters/src/lldb.rs @@ -37,15 +37,6 @@ impl DebugAdapter for LldbDebugAdapter { ) -> Result { let lldb_dap_path = if let Some(user_installed_path) = user_installed_path { user_installed_path.to_string_lossy().into() - } else if cfg!(target_os = "macos") { - util::command::new_smol_command("xcrun") - .args(&["-f", "lldb-dap"]) - .output() - .await - .ok() - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|path| path.trim().to_string()) - .ok_or(anyhow!("Failed to find lldb-dap in user's path"))? } else { delegate .which(OsStr::new("lldb-dap")) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 9deab383cc..d398534ffb 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -24,7 +24,7 @@ use ui::prelude::*; use util::ResultExt; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - pane, ClearBreakpoints, Continue, Disconnect, Pane, Pause, Restart, StepBack, StepInto, + pane, ClearAllBreakpoints, Continue, Disconnect, Pane, Pause, Restart, StepBack, StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints, Workspace, }; @@ -174,7 +174,7 @@ impl DebugPanel { workspace.update_in(cx, |workspace, window, cx| { let debug_panel = DebugPanel::new(workspace, window, cx); - workspace.register_action(|workspace, _: &ClearBreakpoints, _, cx| { + workspace.register_action(|workspace, _: &ClearAllBreakpoints, _, cx| { workspace.project().read(cx).breakpoint_store().update( cx, |breakpoint_store, cx| { diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs index 310b38507d..0022cd2aa3 100644 --- a/crates/debugger_ui/src/session.rs +++ b/crates/debugger_ui/src/session.rs @@ -19,7 +19,7 @@ use project::Project; use rpc::proto::{self, PeerId}; use running::RunningState; use starting::{StartingEvent, StartingState}; -use ui::prelude::*; +use ui::{prelude::*, Indicator}; use util::ResultExt; use workspace::{ item::{self, Item}, @@ -214,23 +214,48 @@ impl Focusable for DebugSession { impl Item for DebugSession { type Event = DebugPanelItemEvent; fn tab_content(&self, _: item::TabContentParams, _: &Window, cx: &App) -> AnyElement { - let (label, color) = match &self.mode { - DebugSessionState::Inert(_) => ("New Session", Color::Default), - DebugSessionState::Starting(_) => ("Starting", Color::Default), - DebugSessionState::Failed(_) => ("Failed", Color::Error), - DebugSessionState::Running(state) => ( - state - .read_with(cx, |state, cx| state.thread_status(cx)) - .map(|status| status.label()) - .unwrap_or("Running"), - Color::Default, + let (icon, label, color) = match &self.mode { + DebugSessionState::Inert(_) => (None, "New Session", Color::Default), + DebugSessionState::Starting(_) => (None, "Starting", Color::Default), + DebugSessionState::Failed(_) => ( + Some(Indicator::dot().color(Color::Error)), + "Failed", + Color::Error, ), + DebugSessionState::Running(state) => { + if state.read(cx).session().read(cx).is_terminated() { + ( + Some(Indicator::dot().color(Color::Error)), + "Terminated", + Color::Error, + ) + } else { + match state.read(cx).thread_status(cx).unwrap_or_default() { + project::debugger::session::ThreadStatus::Stopped => ( + Some(Indicator::dot().color(Color::Conflict)), + state + .read_with(cx, |state, cx| state.thread_status(cx)) + .map(|status| status.label()) + .unwrap_or("Stopped"), + Color::Conflict, + ), + _ => ( + Some(Indicator::dot().color(Color::Success)), + state + .read_with(cx, |state, cx| state.thread_status(cx)) + .map(|status| status.label()) + .unwrap_or("Running"), + Color::Success, + ), + } + } + } }; let is_starting = matches!(self.mode, DebugSessionState::Starting(_)); h_flex() - .gap_1() + .gap_2() .children(is_starting.then(|| { Icon::new(IconName::ArrowCircle).with_animation( "starting-debug-session", @@ -238,6 +263,8 @@ impl Item for DebugSession { |this, delta| this.transform(Transformation::rotate(percentage(delta))), ) })) + .when_some(icon, |this, indicator| this.child(indicator)) + .justify_between() .child(Label::new(label).color(color)) .into_any_element() } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 8cb3687554..808cb509e0 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -16,8 +16,8 @@ use settings::Settings; use stack_frame_list::StackFrameList; use ui::{ div, h_flex, v_flex, ActiveTheme, AnyElement, App, Button, ButtonCommon, Clickable, Context, - ContextMenu, Disableable, DropdownMenu, FluentBuilder, IconButton, IconName, IconSize, - Indicator, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + ContextMenu, Disableable, Divider, DropdownMenu, FluentBuilder, IconButton, IconName, IconSize, + Indicator, InteractiveElement, IntoElement, Label, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Tooltip, Window, }; use util::ResultExt; @@ -61,14 +61,12 @@ impl Render for RunningState { this.disabled(thread_status != ThreadStatus::Stopped, cx); }); - let is_terminated = self.session.read(cx).is_terminated(); let active_thread_item = &self.active_thread_item; let has_no_threads = threads.is_empty(); let capabilities = self.capabilities(cx); let state = cx.entity(); h_flex() - .when(is_terminated, |this| this.bg(gpui::red())) .key_context("DebugPanelItem") .track_focus(&self.focus_handle(cx)) .size_full() @@ -85,9 +83,10 @@ impl Render for RunningState { .justify_between() .child( h_flex() - .p_1() + .px_1() + .py_0p5() .w_full() - .gap_2() + .gap_1() .map(|this| { if thread_status == ThreadStatus::Running { this.child( @@ -95,7 +94,7 @@ impl Render for RunningState { "debug-pause", IconName::DebugPause, ) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.pause_thread(cx); })) @@ -109,7 +108,7 @@ impl Render for RunningState { "debug-continue", IconName::DebugContinue, ) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.continue_thread(cx) })) @@ -120,61 +119,9 @@ impl Render for RunningState { ) } }) - .when( - capabilities.supports_step_back.unwrap_or(false), - |this| { - this.child( - IconButton::new( - "debug-step-back", - IconName::DebugStepBack, - ) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.step_back(cx); - })) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip(move |window, cx| { - Tooltip::text("Step back")(window, cx) - }), - ) - }, - ) - .child( - IconButton::new("debug-step-over", IconName::DebugStepOver) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.step_over(cx); - })) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip(move |window, cx| { - Tooltip::text("Step over")(window, cx) - }), - ) - .child( - IconButton::new("debug-step-in", IconName::DebugStepInto) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.step_in(cx); - })) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip(move |window, cx| { - Tooltip::text("Step in")(window, cx) - }), - ) - .child( - IconButton::new("debug-step-out", IconName::DebugStepOut) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.step_out(cx); - })) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip(move |window, cx| { - Tooltip::text("Step out")(window, cx) - }), - ) .child( IconButton::new("debug-restart", IconName::DebugRestart) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.restart_session(cx); })) @@ -189,7 +136,7 @@ impl Render for RunningState { ) .child( IconButton::new("debug-stop", IconName::DebugStop) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.stop_thread(cx); })) @@ -214,7 +161,7 @@ impl Render for RunningState { "debug-disconnect", IconName::DebugDisconnect, ) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.disconnect_client(cx); })) @@ -222,12 +169,62 @@ impl Render for RunningState { thread_status == ThreadStatus::Exited || thread_status == ThreadStatus::Ended, ) - .tooltip( - move |window, cx| { - Tooltip::text("Disconnect")(window, cx) - }, - ), + .tooltip(Tooltip::text("Disconnect")), ) + .child(Divider::vertical()) + .when( + capabilities.supports_step_back.unwrap_or(false), + |this| { + this.child( + IconButton::new( + "debug-step-back", + IconName::DebugStepBack, + ) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_back(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step back")(window, cx) + }), + ) + }, + ) + .child( + IconButton::new("debug-step-over", IconName::DebugStepOver) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_over(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step over")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-in", IconName::DebugStepInto) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_in(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step in")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-out", IconName::DebugStepOut) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_out(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step out")(window, cx) + }), + ) + .child(Divider::vertical()) .child( IconButton::new( "debug-ignore-breakpoints", @@ -237,7 +234,7 @@ impl Render for RunningState { IconName::DebugIgnoreBreakpoints }, ) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .on_click(cx.listener(|this, _, _window, cx| { this.toggle_ignore_breakpoints(cx); })) @@ -252,33 +249,47 @@ impl Render for RunningState { ), ), ) - //.child(h_flex()) .child( - h_flex().p_1().mx_2().w_3_4().justify_end().child( - DropdownMenu::new( - ("thread-list", self.session_id.0), - selected_thread_name, - ContextMenu::build(window, cx, move |mut this, _, _| { - for (thread, _) in threads { - let state = state.clone(); - let thread_id = thread.id; - this = - this.entry(thread.name, None, move |_, cx| { - state.update(cx, |state, cx| { - state.select_thread( - ThreadId(thread_id), - cx, - ); - }); - }); - } - this - }), - ) - .disabled( - has_no_threads || thread_status != ThreadStatus::Stopped, + h_flex() + .px_1() + .py_0p5() + .gap_2() + .w_3_4() + .justify_end() + .child(Label::new("Thread:")) + .child( + DropdownMenu::new( + ("thread-list", self.session_id.0), + selected_thread_name, + ContextMenu::build( + window, + cx, + move |mut this, _, _| { + for (thread, _) in threads { + let state = state.clone(); + let thread_id = thread.id; + this = this.entry( + thread.name, + None, + move |_, cx| { + state.update(cx, |state, cx| { + state.select_thread( + ThreadId(thread_id), + cx, + ); + }); + }, + ); + } + this + }, + ), + ) + .disabled( + has_no_threads + || thread_status != ThreadStatus::Stopped, + ), ), - ), ), ) .child( diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 8260c5e6a9..d8b452d471 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -5,7 +5,7 @@ use super::{ use anyhow::Result; use collections::HashMap; use dap::OutputEvent; -use editor::{CompletionProvider, Editor, EditorElement, EditorStyle}; +use editor::{CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId}; use fuzzy::StringMatchCandidate; use gpui::{Context, Entity, Render, Subscription, Task, TextStyle, WeakEntity}; use language::{Buffer, CodeLabel}; @@ -246,6 +246,7 @@ struct ConsoleQueryBarCompletionProvider(WeakEntity); impl CompletionProvider for ConsoleQueryBarCompletionProvider { fn completions( &self, + _excerpt_id: ExcerptId, buffer: &Entity, buffer_position: language::Anchor, _trigger: editor::CompletionContext, @@ -367,6 +368,7 @@ impl ConsoleQueryBarCompletionProvider { text: format!("{} {}", string_match.string.clone(), variable_value), runs: Vec::new(), }, + icon_path: None, documentation: None, confirm: None, source: project::CompletionSource::Custom, @@ -408,6 +410,7 @@ impl ConsoleQueryBarCompletionProvider { text: completion.label.clone(), runs: Vec::new(), }, + icon_path: None, documentation: None, confirm: None, source: project::CompletionSource::Custom, diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index b931073bfd..7d41914103 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -18,6 +18,7 @@ actions!(variable_list, [ExpandSelectedEntry, CollapseSelectedEntry]); pub(crate) struct EntryState { depth: usize, is_expanded: bool, + has_children: bool, parent_reference: VariableReference, } @@ -246,6 +247,7 @@ impl VariableList { .entry(path.clone()) .and_modify(|state| { state.parent_reference = container_reference; + state.has_children = variables_reference != 0; }) .or_insert(EntryState { depth: path.indices.len(), @@ -258,6 +260,7 @@ impl VariableList { .unwrap_or(scope.name.to_lowercase().starts_with("local")) }), parent_reference: container_reference, + has_children: variables_reference != 0, }); entries.push(ListEntry { @@ -358,41 +361,45 @@ impl VariableList { fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { self.cancel_variable_edit(&Default::default(), window, cx); if let Some(selection) = &self.selection { - if let Some(var_ix) = self.entries.iter().enumerate().find_map(|(ix, var)| { - if &var.path == selection { + let index = self.entries.iter().enumerate().find_map(|(ix, var)| { + if &var.path == selection && ix > 0 { Some(ix.saturating_sub(1)) } else { None } - }) { - if let Some(new_selection) = self.entries.get(var_ix).map(|var| var.path.clone()) { - self.selection = Some(new_selection); - cx.notify(); - } else { - self.select_first(&SelectFirst, window, cx); - } + }); + + if let Some(new_selection) = + index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone())) + { + self.selection = Some(new_selection); + cx.notify(); + } else { + self.select_last(&SelectLast, window, cx); } } else { - self.select_first(&SelectFirst, window, cx); + self.select_last(&SelectLast, window, cx); } } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { self.cancel_variable_edit(&Default::default(), window, cx); if let Some(selection) = &self.selection { - if let Some(var_ix) = self.entries.iter().enumerate().find_map(|(ix, var)| { + let index = self.entries.iter().enumerate().find_map(|(ix, var)| { if &var.path == selection { Some(ix.saturating_add(1)) } else { None } - }) { - if let Some(new_selection) = self.entries.get(var_ix).map(|var| var.path.clone()) { - self.selection = Some(new_selection); - cx.notify(); - } else { - self.select_first(&SelectFirst, window, cx); - } + }); + + if let Some(new_selection) = + index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone())) + { + self.selection = Some(new_selection); + cx.notify(); + } else { + self.select_first(&SelectFirst, window, cx); } } else { self.select_first(&SelectFirst, window, cx); @@ -437,7 +444,7 @@ impl VariableList { fn collapse_selected_entry( &mut self, _: &CollapseSelectedEntry, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if let Some(ref selected_entry) = self.selection { @@ -446,25 +453,33 @@ impl VariableList { return; }; - entry_state.is_expanded = false; - cx.notify(); + if !entry_state.is_expanded || !entry_state.has_children { + self.select_prev(&SelectPrevious, window, cx); + } else { + entry_state.is_expanded = false; + cx.notify(); + } } } fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - if let Some(ref selected_entry) = self.selection { + if let Some(selected_entry) = &self.selection { let Some(entry_state) = self.entry_states.get_mut(selected_entry) else { debug_panic!("Trying to toggle variable in variable list that has an no state"); return; }; - entry_state.is_expanded = true; - cx.notify(); + if entry_state.is_expanded || !entry_state.has_children { + self.select_next(&SelectNext, window, cx); + } else { + entry_state.is_expanded = true; + cx.notify(); + } } } @@ -649,6 +664,7 @@ impl VariableList { } else { colors.default }; + let path = entry.path.clone(); div() .id(var_ref as usize) @@ -661,7 +677,8 @@ impl VariableList { .h_full() .hover(|style| style.bg(bg_hover_color)) .on_click(cx.listener({ - move |_this, _, _window, cx| { + move |this, _, _window, cx| { + this.selection = Some(path.clone()); cx.notify(); } })) @@ -832,6 +849,7 @@ impl VariableList { .single_line() .truncate() .size(LabelSize::Small) + .color(Color::Muted) .when_some(variable_color, |this, color| { this.color(Color::from(color)) }), diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index e9a357a083..db5173bf17 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -1385,7 +1385,7 @@ async fn test_unsetting_breakpoints_on_clear_breakpoint_action( }) .await; - cx.dispatch_action(workspace::ClearBreakpoints); + cx.dispatch_action(workspace::ClearAllBreakpoints); cx.run_until_parked(); let shutdown_session = project.update(cx, |project, cx| { diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 6c294447f0..136c48a150 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -1103,6 +1103,219 @@ async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestApp }); }); + // select scope 2 backwards + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec!["> Scope 1", "> Scope 2 <=== selected"]); + }); + }); + + // select scope 1 backwards + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec!["> Scope 1 <=== selected", "> Scope 2"]); + }); + }); + + // test stepping through nested with ExpandSelectedEntry/CollapseSelectedEntry actions + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2 <=== selected", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec!["> Scope 1 <=== selected", "> Scope 2"]); + }); + }); + let shutdown_session = project.update(cx, |project, cx| { project.dap_store().update(cx, |dap_store, cx| { dap_store.shutdown_session(session.read(cx).session_id(), cx) diff --git a/crates/docs_preprocessor/src/templates/keybinding.rs b/crates/docs_preprocessor/src/templates/keybinding.rs index 518bea2358..6523502e54 100644 --- a/crates/docs_preprocessor/src/templates/keybinding.rs +++ b/crates/docs_preprocessor/src/templates/keybinding.rs @@ -31,6 +31,11 @@ impl Template for KeybindingTemplate { let action = args.get("action").map(String::as_str).unwrap_or(""); let macos_binding = context.find_binding("macos", action).unwrap_or_default(); let linux_binding = context.find_binding("linux", action).unwrap_or_default(); + + if macos_binding.is_empty() && linux_binding.is_empty() { + return "
No default binding
".to_string(); + } + format!("{macos_binding}|{linux_binding}") } } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 9486387191..6ac1df7259 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -414,6 +414,8 @@ actions!( Tab, Backtab, ToggleBreakpoint, + DisableBreakpoint, + EnableBreakpoint, EditLogBreakpoint, ToggleAutoSignatureHelp, ToggleGitBlameInline, diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index d8d53e2b10..0908ad48fb 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1,6 +1,6 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, Focusable, FontWeight, + div, px, uniform_list, AnyElement, BackgroundExecutor, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, UniformListScrollHandle, }; @@ -236,6 +236,7 @@ impl CompletionsMenu { runs: Default::default(), filter_range: Default::default(), }, + icon_path: None, documentation: None, confirm: None, source: CompletionSource::Custom, @@ -539,9 +540,25 @@ impl CompletionsMenu { } else { None }; - let color_swatch = completion + + let start_slot = completion .color() - .map(|color| div().size_4().bg(color).rounded_xs()); + .map(|color| { + div() + .flex_shrink_0() + .size_3p5() + .rounded_xs() + .bg(color) + .into_any_element() + }) + .or_else(|| { + completion.icon_path.as_ref().map(|path| { + Icon::from_path(path) + .size(IconSize::XSmall) + .color(Color::Muted) + .into_any_element() + }) + }); div().min_w(px(280.)).max_w(px(540.)).child( ListItem::new(mat.candidate_id) @@ -559,7 +576,7 @@ impl CompletionsMenu { task.detach_and_log_err(cx) } })) - .start_slot::
(color_swatch) + .start_slot::(start_slot) .child(h_flex().overflow_hidden().child(completion_label)) .end_slot::