From aae39071efcf65fd51feb07ab8149b85bfde230b Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 19 Nov 2024 09:41:44 +0100 Subject: [PATCH] editor: Show hints for using AI features on empty lines (#20824) Co-Authored-by: Thorsten Co-Authored-by: Antonio Screenshot: ![screenshot-2024-11-18-17 11 08@2x](https://github.com/user-attachments/assets/610fd7db-7476-4b9b-9465-a3d55df12340) TODO: - [x] docs Release Notes: - Added inline hints that guide users on how to invoke the inline assistant and open the assistant panel. (These hints can be disabled by setting `{"assistant": {"show_hints": false}}`.) --------- Co-authored-by: Thorsten Co-authored-by: Antonio Co-authored-by: Thorsten Ball --- assets/settings/default.json | 3 + crates/assistant/src/assistant_settings.rs | 11 ++ crates/editor/src/editor.rs | 36 ++++++ crates/editor/src/element.rs | 114 +++++++++-------- crates/gpui/src/window.rs | 22 +++- crates/outline_panel/src/outline_panel.rs | 6 +- crates/recent_projects/src/recent_projects.rs | 8 +- crates/zed/src/main.rs | 3 +- crates/zed/src/zed.rs | 1 + crates/zed/src/zed/assistant_hints.rs | 115 ++++++++++++++++++ docs/src/assistant/configuration.md | 26 ++-- docs/src/configuring-zed.md | 21 ++-- 12 files changed, 283 insertions(+), 83 deletions(-) create mode 100644 crates/zed/src/zed/assistant_hints.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 7c4a9a8111..3757dfe119 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -490,6 +490,9 @@ "version": "2", // Whether the assistant is enabled. "enabled": true, + // Whether to show inline hints showing the keybindings to use the inline assistant and the + // assistant panel. + "show_hints": true, // Whether to show the assistant panel button in the status bar. "button": true, // Where to dock the assistant panel. Can be 'left', 'right' or 'bottom'. diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index 5bfd406658..98188305fb 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -60,6 +60,7 @@ pub struct AssistantSettings { pub inline_alternatives: Vec, pub using_outdated_settings_version: bool, pub enable_experimental_live_diffs: bool, + pub show_hints: bool, } impl AssistantSettings { @@ -202,6 +203,7 @@ impl AssistantSettingsContent { AssistantSettingsContent::Versioned(settings) => match settings { VersionedAssistantSettingsContent::V1(settings) => AssistantSettingsContentV2 { enabled: settings.enabled, + show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -242,6 +244,7 @@ impl AssistantSettingsContent { }, AssistantSettingsContent::Legacy(settings) => AssistantSettingsContentV2 { enabled: None, + show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -354,6 +357,7 @@ impl Default for VersionedAssistantSettingsContent { fn default() -> Self { Self::V2(AssistantSettingsContentV2 { enabled: None, + show_hints: None, button: None, dock: None, default_width: None, @@ -371,6 +375,11 @@ pub struct AssistantSettingsContentV2 { /// /// Default: true enabled: Option, + /// Whether to show inline hints that show keybindings for inline assistant + /// and assistant panel. + /// + /// Default: true + show_hints: Option, /// Whether to show the assistant panel button in the status bar. /// /// Default: true @@ -505,6 +514,7 @@ impl Settings for AssistantSettings { let value = value.upgrade(); merge(&mut settings.enabled, value.enabled); + merge(&mut settings.show_hints, value.show_hints); merge(&mut settings.button, value.button); merge(&mut settings.dock, value.dock); merge( @@ -575,6 +585,7 @@ mod tests { }), inline_alternatives: None, enabled: None, + show_hints: None, button: None, dock: None, default_width: None, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 11d47daa6b..6167c24bff 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -540,6 +540,15 @@ pub enum IsVimMode { No, } +pub trait ActiveLineTrailerProvider { + fn render_active_line_trailer( + &mut self, + style: &EditorStyle, + focus_handle: &FocusHandle, + cx: &mut WindowContext, + ) -> Option; +} + /// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`] /// /// See the [module level documentation](self) for more information. @@ -667,6 +676,7 @@ pub struct Editor { next_scroll_position: NextScrollCursorCenterTopBottom, addons: HashMap>, _scroll_cursor_center_top_bottom_task: Task<()>, + active_line_trailer_provider: Option>, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] @@ -2200,6 +2210,7 @@ impl Editor { addons: HashMap::default(), _scroll_cursor_center_top_bottom_task: Task::ready(()), text_style_refinement: None, + active_line_trailer_provider: None, }; this.tasks_update_task = Some(this.refresh_runnables(cx)); this._subscriptions.extend(project_subscriptions); @@ -2488,6 +2499,16 @@ impl Editor { self.refresh_inline_completion(false, false, cx); } + pub fn set_active_line_trailer_provider( + &mut self, + provider: Option, + _cx: &mut ViewContext, + ) where + T: ActiveLineTrailerProvider + 'static, + { + self.active_line_trailer_provider = provider.map(|provider| Box::new(provider) as Box<_>); + } + pub fn placeholder_text(&self, _cx: &WindowContext) -> Option<&str> { self.placeholder_text.as_deref() } @@ -11844,6 +11865,21 @@ impl Editor { && self.has_blame_entries(cx) } + pub fn render_active_line_trailer( + &mut self, + style: &EditorStyle, + cx: &mut WindowContext, + ) -> Option { + if !self.newest_selection_head_on_empty_line(cx) || self.has_active_inline_completion(cx) { + return None; + } + + let focus_handle = self.focus_handle.clone(); + self.active_line_trailer_provider + .as_mut()? + .render_active_line_trailer(style, &focus_handle, cx) + } + fn has_blame_entries(&self, cx: &mut WindowContext) -> bool { self.blame() .map_or(false, |blame| blame.read(cx).has_generated_entries()) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7702134409..6e4538ae6d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1412,7 +1412,7 @@ impl EditorElement { } #[allow(clippy::too_many_arguments)] - fn layout_inline_blame( + fn layout_active_line_trailer( &self, display_row: DisplayRow, display_snapshot: &DisplaySnapshot, @@ -1424,61 +1424,71 @@ impl EditorElement { line_height: Pixels, cx: &mut WindowContext, ) -> Option { - if !self + let render_inline_blame = self .editor - .update(cx, |editor, cx| editor.render_git_blame_inline(cx)) - { - return None; - } + .update(cx, |editor, cx| editor.render_git_blame_inline(cx)); + if render_inline_blame { + let workspace = self + .editor + .read(cx) + .workspace + .as_ref() + .map(|(w, _)| w.clone()); - let workspace = self - .editor - .read(cx) - .workspace - .as_ref() - .map(|(w, _)| w.clone()); + let display_point = DisplayPoint::new(display_row, 0); + let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); - let display_point = DisplayPoint::new(display_row, 0); - let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); + let blame = self.editor.read(cx).blame.clone()?; + let blame_entry = blame + .update(cx, |blame, cx| { + blame.blame_for_rows([Some(buffer_row)], cx).next() + }) + .flatten()?; - let blame = self.editor.read(cx).blame.clone()?; - let blame_entry = blame - .update(cx, |blame, cx| { - blame.blame_for_rows([Some(buffer_row)], cx).next() - }) - .flatten()?; + let mut element = + render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); - let mut element = - render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); + let start_y = content_origin.y + + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + let start_x = { + const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; - let start_x = { - const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; + let line_end = if let Some(crease_trailer) = crease_trailer { + crease_trailer.bounds.right() + } else { + content_origin.x - scroll_pixel_position.x + line_layout.width + }; + let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; - let line_end = if let Some(crease_trailer) = crease_trailer { - crease_trailer.bounds.right() - } else { - content_origin.x - scroll_pixel_position.x + line_layout.width + let min_column_in_pixels = ProjectSettings::get_global(cx) + .git + .inline_blame + .and_then(|settings| settings.min_column) + .map(|col| self.column_pixels(col as usize, cx)) + .unwrap_or(px(0.)); + let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; + + cmp::max(padded_line_end, min_start) }; - let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; - let min_column_in_pixels = ProjectSettings::get_global(cx) - .git - .inline_blame - .and_then(|settings| settings.min_column) - .map(|col| self.column_pixels(col as usize, cx)) - .unwrap_or(px(0.)); - let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; + let absolute_offset = point(start_x, start_y); + element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - cmp::max(padded_line_end, min_start) - }; + Some(element) + } else if let Some(mut element) = self.editor.update(cx, |editor, cx| { + editor.render_active_line_trailer(&self.style, cx) + }) { + let start_y = content_origin.y + + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + let start_x = content_origin.x - scroll_pixel_position.x + em_width; + let absolute_offset = point(start_x, start_y); + element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - let absolute_offset = point(start_x, start_y); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - - Some(element) + Some(element) + } else { + None + } } #[allow(clippy::too_many_arguments)] @@ -3454,7 +3464,7 @@ impl EditorElement { self.paint_lines(&invisible_display_ranges, layout, cx); self.paint_redactions(layout, cx); self.paint_cursors(layout, cx); - self.paint_inline_blame(layout, cx); + self.paint_active_line_trailer(layout, cx); cx.with_element_namespace("crease_trailers", |cx| { for trailer in layout.crease_trailers.iter_mut().flatten() { trailer.element.paint(cx); @@ -3936,10 +3946,10 @@ impl EditorElement { } } - fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - if let Some(mut inline_blame) = layout.inline_blame.take() { + fn paint_active_line_trailer(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { + if let Some(mut element) = layout.active_line_trailer.take() { cx.paint_layer(layout.text_hitbox.bounds, |cx| { - inline_blame.paint(cx); + element.paint(cx); }) } } @@ -5331,14 +5341,14 @@ impl Element for EditorElement { ) }); - let mut inline_blame = None; + let mut active_line_trailer = None; if let Some(newest_selection_head) = newest_selection_head { let display_row = newest_selection_head.row(); if (start_row..end_row).contains(&display_row) { let line_ix = display_row.minus(start_row) as usize; let line_layout = &line_layouts[line_ix]; let crease_trailer_layout = crease_trailers[line_ix].as_ref(); - inline_blame = self.layout_inline_blame( + active_line_trailer = self.layout_active_line_trailer( display_row, &snapshot.display_snapshot, line_layout, @@ -5657,7 +5667,7 @@ impl Element for EditorElement { line_elements, line_numbers, blamed_display_rows, - inline_blame, + active_line_trailer, blocks, cursors, visible_cursors, @@ -5794,7 +5804,7 @@ pub struct EditorLayout { line_numbers: Vec>, display_hunks: Vec<(DisplayDiffHunk, Option)>, blamed_display_rows: Option>, - inline_blame: Option, + active_line_trailer: Option, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, highlighted_gutter_ranges: Vec<(Range, Hsla)>, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 0f2be2497a..9a028c1f01 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3050,7 +3050,7 @@ impl<'a> WindowContext<'a> { } /// Represent this action as a key binding string, to display in the UI. - pub fn keystroke_text_for(&self, action: &dyn Action) -> String { + pub fn keystroke_text_for_action(&self, action: &dyn Action) -> String { self.bindings_for_action(action) .into_iter() .next() @@ -3065,6 +3065,26 @@ impl<'a> WindowContext<'a> { .unwrap_or_else(|| action.name().to_string()) } + /// Represent this action as a key binding string, to display in the UI. + pub fn keystroke_text_for_action_in( + &self, + action: &dyn Action, + focus_handle: &FocusHandle, + ) -> String { + self.bindings_for_action_in(action, focus_handle) + .into_iter() + .next() + .map(|binding| { + binding + .keystrokes() + .iter() + .map(ToString::to_string) + .collect::>() + .join(" ") + }) + .unwrap_or_else(|| action.name().to_string()) + } + /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput) -> DispatchEventResult { diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index f878b582d9..f378348782 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -3875,13 +3875,13 @@ impl OutlinePanel { .child({ let keystroke = match self.position(cx) { DockPosition::Left => { - cx.keystroke_text_for(&workspace::ToggleLeftDock) + cx.keystroke_text_for_action(&workspace::ToggleLeftDock) } DockPosition::Bottom => { - cx.keystroke_text_for(&workspace::ToggleBottomDock) + cx.keystroke_text_for_action(&workspace::ToggleBottomDock) } DockPosition::Right => { - cx.keystroke_text_for(&workspace::ToggleRightDock) + cx.keystroke_text_for_action(&workspace::ToggleRightDock) } }; Label::new(format!("Toggle this panel with {keystroke}")) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 072e8ba695..e01309cacd 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -185,13 +185,13 @@ impl PickerDelegate for RecentProjectsDelegate { fn placeholder_text(&self, cx: &mut WindowContext) -> Arc { let (create_window, reuse_window) = if self.create_new_window { ( - cx.keystroke_text_for(&menu::Confirm), - cx.keystroke_text_for(&menu::SecondaryConfirm), + cx.keystroke_text_for_action(&menu::Confirm), + cx.keystroke_text_for_action(&menu::SecondaryConfirm), ) } else { ( - cx.keystroke_text_for(&menu::SecondaryConfirm), - cx.keystroke_text_for(&menu::Confirm), + cx.keystroke_text_for_action(&menu::SecondaryConfirm), + cx.keystroke_text_for_action(&menu::Confirm), ) }; Arc::from(format!( diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index a5fc52e933..c632843baa 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -66,7 +66,7 @@ use zed::{ OpenRequest, }; -use crate::zed::inline_completion_registry; +use crate::zed::{assistant_hints, inline_completion_registry}; #[cfg(feature = "mimalloc")] #[global_allocator] @@ -401,6 +401,7 @@ fn main() { stdout_is_a_pty(), cx, ); + assistant_hints::init(cx); repl::init( app_state.fs.clone(), app_state.client.telemetry().clone(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e2dc36a21f..0f10f1914b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1,4 +1,5 @@ mod app_menus; +pub mod assistant_hints; pub mod inline_completion_registry; #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub(crate) mod linux_prompts; diff --git a/crates/zed/src/zed/assistant_hints.rs b/crates/zed/src/zed/assistant_hints.rs new file mode 100644 index 0000000000..244b7fab26 --- /dev/null +++ b/crates/zed/src/zed/assistant_hints.rs @@ -0,0 +1,115 @@ +use assistant::assistant_settings::AssistantSettings; +use collections::HashMap; +use editor::{ActiveLineTrailerProvider, Editor, EditorMode}; +use gpui::{AnyWindowHandle, AppContext, ViewContext, WeakView, WindowContext}; +use settings::{Settings, SettingsStore}; +use std::{cell::RefCell, rc::Rc}; +use theme::ActiveTheme; +use ui::prelude::*; +use workspace::Workspace; + +pub fn init(cx: &mut AppContext) { + let editors: Rc, AnyWindowHandle>>> = Rc::default(); + + cx.observe_new_views({ + let editors = editors.clone(); + move |_: &mut Workspace, cx: &mut ViewContext| { + let workspace_handle = cx.view().clone(); + cx.subscribe(&workspace_handle, { + let editors = editors.clone(); + move |_, _, event, cx| match event { + workspace::Event::ItemAdded { item } => { + if let Some(editor) = item.act_as::(cx) { + if editor.read(cx).mode() != EditorMode::Full { + return; + } + + cx.on_release({ + let editor_handle = editor.downgrade(); + let editors = editors.clone(); + move |_, _, _| { + editors.borrow_mut().remove(&editor_handle); + } + }) + .detach(); + editors + .borrow_mut() + .insert(editor.downgrade(), cx.window_handle()); + + let show_hints = should_show_hints(cx); + editor.update(cx, |editor, cx| { + assign_active_line_trailer_provider(editor, show_hints, cx) + }) + } + } + _ => {} + } + }) + .detach(); + } + }) + .detach(); + + let mut show_hints = AssistantSettings::get_global(cx).show_hints; + cx.observe_global::(move |cx| { + let new_show_hints = should_show_hints(cx); + if new_show_hints != show_hints { + show_hints = new_show_hints; + for (editor, window) in editors.borrow().iter() { + _ = window.update(cx, |_window, cx| { + _ = editor.update(cx, |editor, cx| { + assign_active_line_trailer_provider(editor, show_hints, cx); + }) + }); + } + } + }) + .detach(); +} + +struct AssistantHintsProvider; + +impl ActiveLineTrailerProvider for AssistantHintsProvider { + fn render_active_line_trailer( + &mut self, + style: &editor::EditorStyle, + focus_handle: &gpui::FocusHandle, + cx: &mut WindowContext, + ) -> Option { + if !focus_handle.is_focused(cx) { + return None; + } + + let chat_keybinding = + cx.keystroke_text_for_action_in(&assistant::ToggleFocus, focus_handle); + let generate_keybinding = + cx.keystroke_text_for_action_in(&zed_actions::InlineAssist::default(), focus_handle); + + Some( + h_flex() + .id("inline-assistant-instructions") + .w_full() + .font_family(style.text.font().family) + .text_color(cx.theme().status().hint) + .line_height(style.text.line_height) + .child(format!( + "{chat_keybinding} to chat, {generate_keybinding} to generate" + )) + .into_any(), + ) + } +} + +fn assign_active_line_trailer_provider( + editor: &mut Editor, + show_hints: bool, + cx: &mut ViewContext, +) { + let provider = show_hints.then_some(AssistantHintsProvider); + editor.set_active_line_trailer_provider(provider, cx); +} + +fn should_show_hints(cx: &AppContext) -> bool { + let assistant_settings = AssistantSettings::get_global(cx); + assistant_settings.enabled && assistant_settings.show_hints +} diff --git a/docs/src/assistant/configuration.md b/docs/src/assistant/configuration.md index 2145bd9504..1be96491f4 100644 --- a/docs/src/assistant/configuration.md +++ b/docs/src/assistant/configuration.md @@ -200,18 +200,28 @@ You must provide the model's Context Window in the `max_tokens` parameter, this { "assistant": { "enabled": true, + "show_hints": true, + "button": true, + "dock": "right" + "default_width": 480, "default_model": { "provider": "zed.dev", "model": "claude-3-5-sonnet" }, "version": "2", - "button": true, - "default_width": 480, - "dock": "right" } } ``` +| key | type | default | description | +| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | +| enabled | boolean | true | Setting this to `false` will completely disable the assistant | +| show_hints | boolean | true | Whether to to show hints in the editor explaining how to use assistant | +| button | boolean | true | Show the assistant icon in the status bar | +| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | +| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | +| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | + #### Custom endpoints {#custom-endpoint} You can use a custom API endpoint for different providers, as long as it's compatible with the providers API structure. @@ -271,13 +281,3 @@ will generate two outputs for every assist. One with Claude 3.5 Sonnet, and one } } ``` - -#### Common Panel Settings - -| key | type | default | description | -| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | -| enabled | boolean | true | Setting this to `false` will completely disable the assistant | -| button | boolean | true | Show the assistant icon in the status bar | -| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | -| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | -| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index ce8068fa3b..b4da7901a1 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -2327,15 +2327,18 @@ Run the `theme selector: toggle` action in the command palette to see a current - Default: ```json -"assistant": { - "enabled": true, - "button": true, - "dock": "right", - "default_width": 640, - "default_height": 320, - "provider": "openai", - "version": "1", -}, +{ + "assistant": { + "enabled": true, + "button": true, + "dock": "right", + "default_width": 640, + "default_height": 320, + "provider": "openai", + "version": "1", + "show_hints": true + } +} ``` ## Outline Panel