acp: Display slash command hints (#37376)

Agus Zubiaga and Bennet Bo Fenner created

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 <bennetbo@gmx.de>

Change summary

crates/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(-)

Detailed changes

crates/agent_ui/src/acp/completion_provider.rs 🔗

@@ -1005,14 +1005,14 @@ impl ContextCompletion {
 }
 
 #[derive(Debug, Default, PartialEq)]
-struct SlashCommandCompletion {
-    source_range: Range<usize>,
-    command: Option<String>,
-    argument: Option<String>,
+pub struct SlashCommandCompletion {
+    pub source_range: Range<usize>,
+    pub command: Option<String>,
+    pub argument: Option<String>,
 }
 
 impl SlashCommandCompletion {
-    fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
+    pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
         // 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;

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<HistoryStore>,
     prompt_store: Option<Entity<PromptStore>>,
     prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
     _subscriptions: Vec<Subscription>,
     _parse_slash_command_task: Task<()>,
 }
@@ -76,6 +79,8 @@ pub enum MessageEditorEvent {
 
 impl EventEmitter<MessageEditorEvent> for MessageEditor {}
 
+const COMMAND_HINT_INLAY_ID: usize = 0;
+
 impl MessageEditor {
     pub fn new(
         workspace: WeakEntity<Workspace>,
@@ -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::<Vec<_>>();
+                        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<MultiBuffer>, cx: &App) -> Option<Inlay> {
+        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: "<name>".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 <name>");
             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 <name>");
+
+            // 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());
         });
     }

crates/project/src/project.rs 🔗

@@ -666,7 +666,6 @@ pub enum ResolveState {
     CanResolve(LanguageServerId, Option<lsp::LSPAny>),
     Resolving,
 }
-
 impl InlayHint {
     pub fn text(&self) -> Rope {
         match &self.label {