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 <bennetbo@gmx.de>
This commit is contained in:
Agus Zubiaga
2025-09-02 16:39:55 +00:00
committed by GitHub
co-authored by Bennet Bo Fenner
parent ac8c653ae6
commit dfa066dfe8
3 changed files with 116 additions and 18 deletions
@@ -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;
+111 -12
View File
@@ -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());
});
}
-1
View File
@@ -666,7 +666,6 @@ pub enum ResolveState {
CanResolve(LanguageServerId, Option<lsp::LSPAny>),
Resolving,
}
impl InlayHint {
pub fn text(&self) -> Rope {
match &self.label {