From dfa066dfe8fc63fe9070756a1b3c02442835fe0c Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Sep 2025 13:39:55 -0300 Subject: [PATCH] acp: Display slash command hints (#37376) Displays the slash command's argument hint while it hasn't been provided: https://github.com/user-attachments/assets/f3bb148c-247d-43bc-810d-92055a313514 Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- .../agent_ui/src/acp/completion_provider.rs | 10 +- crates/agent_ui/src/acp/message_editor.rs | 123 ++++++++++++++++-- crates/project/src/project.rs | 1 - 3 files changed, 116 insertions(+), 18 deletions(-) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 59106c3795aa14794e1fca9ee32049e0cff1314f..dc38c65868385e1e5ee913dd76160c7bdebbd0ad 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -1005,14 +1005,14 @@ impl ContextCompletion { } #[derive(Debug, Default, PartialEq)] -struct SlashCommandCompletion { - source_range: Range, - command: Option, - argument: Option, +pub struct SlashCommandCompletion { + pub source_range: Range, + pub command: Option, + pub argument: Option, } impl SlashCommandCompletion { - fn try_parse(line: &str, offset_to_line: usize) -> Option { + pub fn try_parse(line: &str, offset_to_line: usize) -> Option { // If we decide to support commands that are not at the beginning of the prompt, we can remove this check if !line.starts_with('/') || offset_to_line != 0 { return None; diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index b51bc2e0a3d10a3647fafd816684c7382c5dde36..3350374aa529a3feb2473679860a9614bb413854 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,5 +1,5 @@ use crate::{ - acp::completion_provider::ContextPickerCompletionProvider, + acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion}, context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content}, }; use acp_thread::{MentionUri, selection_name}; @@ -11,10 +11,10 @@ use assistant_slash_commands::codeblock_fence_for_path; use collections::{HashMap, HashSet}; use editor::{ Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, - ToOffset, + EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId, + MultiBuffer, ToOffset, actions::Paste, - display_map::{Crease, CreaseId, FoldId}, + display_map::{Crease, CreaseId, FoldId, Inlay}, }; use futures::{ FutureExt as _, @@ -25,10 +25,12 @@ use gpui::{ EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, Subscription, Task, TextStyle, WeakEntity, pulsating_between, }; -use language::{Buffer, Language}; +use language::{Buffer, Language, language_settings::InlayHintKind}; use language_model::LanguageModelImage; use postage::stream::Stream as _; -use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree}; +use project::{ + CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree, +}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::Settings; @@ -62,6 +64,7 @@ pub struct MessageEditor { history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, + available_commands: Rc>>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, } @@ -76,6 +79,8 @@ pub enum MessageEditorEvent { impl EventEmitter for MessageEditor {} +const COMMAND_HINT_INLAY_ID: usize = 0; + impl MessageEditor { pub fn new( workspace: WeakEntity, @@ -102,7 +107,7 @@ impl MessageEditor { history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), - available_commands, + available_commands.clone(), )); let mention_set = MentionSet::default(); let editor = cx.new(|cx| { @@ -133,12 +138,33 @@ impl MessageEditor { }) .detach(); + let mut has_hint = false; let mut subscriptions = Vec::new(); + subscriptions.push(cx.subscribe_in(&editor, window, { move |this, editor, event, window, cx| { if let EditorEvent::Edited { .. } = event { - let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + let snapshot = editor.update(cx, |editor, cx| { + let new_hints = this + .command_hint(editor.buffer(), cx) + .into_iter() + .collect::>(); + let has_new_hint = !new_hints.is_empty(); + editor.splice_inlays( + if has_hint { + &[InlayId::Hint(COMMAND_HINT_INLAY_ID)] + } else { + &[] + }, + new_hints, + cx, + ); + has_hint = has_new_hint; + + editor.snapshot(window, cx) + }); this.mention_set.remove_invalid(snapshot); + cx.notify(); } } @@ -152,11 +178,55 @@ impl MessageEditor { history_store, prompt_store, prompt_capabilities, + available_commands, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), } } + fn command_hint(&self, buffer: &Entity, cx: &App) -> Option { + let available_commands = self.available_commands.borrow(); + if available_commands.is_empty() { + return None; + } + + let snapshot = buffer.read(cx).snapshot(cx); + let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?; + if parsed_command.argument.is_some() { + return None; + } + + let command_name = parsed_command.command?; + let available_command = available_commands + .iter() + .find(|command| command.name == command_name)?; + + let acp::AvailableCommandInput::Unstructured { mut hint } = + available_command.input.clone()?; + + let mut hint_pos = parsed_command.source_range.end + 1; + if hint_pos > snapshot.len() { + hint_pos = snapshot.len(); + hint.insert(0, ' '); + } + + let hint_pos = snapshot.anchor_after(hint_pos); + + Some(Inlay::hint( + COMMAND_HINT_INLAY_ID, + hint_pos, + &InlayHint { + position: hint_pos.text_anchor, + label: InlayHintLabel::String(hint), + kind: Some(InlayHintKind::Parameter), + padding_left: false, + padding_right: false, + tooltip: None, + resolve_state: project::ResolveState::Resolved, + }, + )) + } + pub fn insert_thread_summary( &mut self, thread: agent2::DbThreadMetadata, @@ -1184,6 +1254,7 @@ impl Render for MessageEditor { local_player: cx.theme().players().local(), text: text_style, syntax: cx.theme().syntax().clone(), + inlay_hints_style: editor::make_inlay_hints_style(cx), ..Default::default() }, ) @@ -1639,7 +1710,7 @@ mod tests { name: "say-hello".to_string(), description: "Say hello to whoever you want".to_string(), input: Some(acp::AvailableCommandInput::Unstructured { - hint: "Who do you want to say hello to?".to_string(), + hint: "".to_string(), }), }, ])); @@ -1714,7 +1785,7 @@ mod tests { cx.run_until_parked(); editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/quick-math "); + assert_eq!(editor.display_text(cx), "/quick-math "); assert!(!editor.has_visible_completions_menu()); editor.set_text("", window, cx); }); @@ -1722,7 +1793,7 @@ mod tests { cx.simulate_input("/say"); editor.update_in(&mut cx, |editor, _window, cx| { - assert_eq!(editor.text(cx), "/say"); + assert_eq!(editor.display_text(cx), "/say"); assert!(editor.has_visible_completions_menu()); assert_eq!( @@ -1740,6 +1811,7 @@ mod tests { editor.update_in(&mut cx, |editor, _window, cx| { assert_eq!(editor.text(cx), "/say-hello "); + assert_eq!(editor.display_text(cx), "/say-hello "); assert!(editor.has_visible_completions_menu()); assert_eq!( @@ -1757,8 +1829,35 @@ mod tests { cx.run_until_parked(); - editor.update_in(&mut cx, |editor, _window, cx| { + editor.update_in(&mut cx, |editor, window, cx| { assert_eq!(editor.text(cx), "/say-hello GPT5"); + assert_eq!(editor.display_text(cx), "/say-hello GPT5"); + assert!(!editor.has_visible_completions_menu()); + + // Delete argument + for _ in 0..4 { + editor.backspace(&editor::actions::Backspace, window, cx); + } + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/say-hello "); + // Hint is visible because argument was deleted + assert_eq!(editor.display_text(cx), "/say-hello "); + + // Delete last command letter + editor.backspace(&editor::actions::Backspace, window, cx); + editor.backspace(&editor::actions::Backspace, window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, _window, cx| { + // Hint goes away once command no longer matches an available one + assert_eq!(editor.text(cx), "/say-hell"); + assert_eq!(editor.display_text(cx), "/say-hell"); assert!(!editor.has_visible_completions_menu()); }); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 557367edf522a103ee1a8b55f5264be561d1698e..229249d48c5ab370c4b354cda7cbf9312790759d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -666,7 +666,6 @@ pub enum ResolveState { CanResolve(LanguageServerId, Option), Resolving, } - impl InlayHint { pub fn text(&self) -> Rope { match &self.label {