assistant: Update SlashCommand
trait with streaming return type (#19652)
This PR updates the `SlashCommand` trait to use a streaming return type. This change is just at the trait layer. The goal here is to decouple changing the trait's API while preserving behavior on either side. The `SlashCommandOutput` type now has two methods for converting two and from a stream to use in cases where we're not yet doing streaming. On the `SlashCommand` implementer side, the implements can call `to_event_stream` to produce a stream of events based off the `SlashCommandOutput`. On the slash command consumer side we use `SlashCommandOutput::from_event_stream` to convert a stream of events back into a `SlashCommandOutput`. The `/file` slash command has been updated to emit `SlashCommandEvent`s directly in order for it to work properly. Release Notes: - N/A --------- Co-authored-by: Max <max@zed.dev>
This commit is contained in:
parent
510c71d41b
commit
d30361537e
23 changed files with 516 additions and 88 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -453,9 +453,11 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"collections",
|
"collections",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
|
"futures 0.3.30",
|
||||||
"gpui",
|
"gpui",
|
||||||
"language",
|
"language",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"pretty_assertions",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"workspace",
|
"workspace",
|
||||||
|
|
|
@ -7,7 +7,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Context as _, Result};
|
use anyhow::{anyhow, Context as _, Result};
|
||||||
use assistant_slash_command::{
|
use assistant_slash_command::{
|
||||||
SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
|
SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
|
||||||
};
|
};
|
||||||
use assistant_tool::ToolRegistry;
|
use assistant_tool::ToolRegistry;
|
||||||
use client::{self, proto, telemetry::Telemetry};
|
use client::{self, proto, telemetry::Telemetry};
|
||||||
|
@ -1688,19 +1688,13 @@ impl Context {
|
||||||
let command_range = command_range.clone();
|
let command_range = command_range.clone();
|
||||||
async move {
|
async move {
|
||||||
let output = output.await;
|
let output = output.await;
|
||||||
|
let output = match output {
|
||||||
|
Ok(output) => SlashCommandOutput::from_event_stream(output).await,
|
||||||
|
Err(err) => Err(err),
|
||||||
|
};
|
||||||
this.update(&mut cx, |this, cx| match output {
|
this.update(&mut cx, |this, cx| match output {
|
||||||
Ok(mut output) => {
|
Ok(mut output) => {
|
||||||
// Ensure section ranges are valid.
|
output.ensure_valid_section_ranges();
|
||||||
for section in &mut output.sections {
|
|
||||||
section.range.start = section.range.start.min(output.text.len());
|
|
||||||
section.range.end = section.range.end.min(output.text.len());
|
|
||||||
while !output.text.is_char_boundary(section.range.start) {
|
|
||||||
section.range.start -= 1;
|
|
||||||
}
|
|
||||||
while !output.text.is_char_boundary(section.range.end) {
|
|
||||||
section.range.end += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure there is a newline after the last section.
|
// Ensure there is a newline after the last section.
|
||||||
if ensure_trailing_newline {
|
if ensure_trailing_newline {
|
||||||
|
|
|
@ -1097,7 +1097,8 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
|
||||||
text: output_text,
|
text: output_text,
|
||||||
sections,
|
sections,
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})),
|
}
|
||||||
|
.to_event_stream())),
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
cx,
|
cx,
|
||||||
|
@ -1421,6 +1422,7 @@ impl SlashCommand for FakeSlashCommand {
|
||||||
text: format!("Executed fake command: {}", self.0),
|
text: format!("Executed fake command: {}", self.0),
|
||||||
sections: vec![],
|
sections: vec![],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
}))
|
}
|
||||||
|
.to_event_stream()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -147,7 +147,8 @@ impl SlashCommand for AutoCommand {
|
||||||
text: prompt,
|
text: prompt,
|
||||||
sections: Vec::new(),
|
sections: Vec::new(),
|
||||||
run_commands_in_text: true,
|
run_commands_in_text: true,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -147,7 +147,8 @@ impl SlashCommand for CargoWorkspaceSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
output.unwrap_or_else(|error| Task::ready(Err(error)))
|
output.unwrap_or_else(|error| Task::ready(Err(error)))
|
||||||
|
|
|
@ -185,7 +185,8 @@ impl SlashCommand for ContextServerSlashCommand {
|
||||||
}],
|
}],
|
||||||
text: prompt,
|
text: prompt,
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Task::ready(Err(anyhow!("Context server not found")))
|
Task::ready(Err(anyhow!("Context server not found")))
|
||||||
|
|
|
@ -78,7 +78,8 @@ impl SlashCommand for DefaultSlashCommand {
|
||||||
}],
|
}],
|
||||||
text,
|
text,
|
||||||
run_commands_in_text: true,
|
run_commands_in_text: true,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,6 +86,8 @@ impl SlashCommand for DeltaSlashCommand {
|
||||||
.zip(file_command_new_outputs)
|
.zip(file_command_new_outputs)
|
||||||
{
|
{
|
||||||
if let Ok(new_output) = new_output {
|
if let Ok(new_output) = new_output {
|
||||||
|
if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
|
||||||
|
{
|
||||||
if let Some(file_command_range) = new_output.sections.first() {
|
if let Some(file_command_range) = new_output.sections.first() {
|
||||||
let new_text = &new_output.text[file_command_range.range.clone()];
|
let new_text = &new_output.text[file_command_range.range.clone()];
|
||||||
if old_text.chars().ne(new_text.chars()) {
|
if old_text.chars().ne(new_text.chars()) {
|
||||||
|
@ -103,8 +105,9 @@ impl SlashCommand for DeltaSlashCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(output)
|
Ok(output.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -180,7 +180,11 @@ impl SlashCommand for DiagnosticsSlashCommand {
|
||||||
|
|
||||||
let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
|
let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
|
||||||
|
|
||||||
cx.spawn(move |_| async move { task.await?.ok_or_else(|| anyhow!("No diagnostics found")) })
|
cx.spawn(move |_| async move {
|
||||||
|
task.await?
|
||||||
|
.map(|output| output.to_event_stream())
|
||||||
|
.ok_or_else(|| anyhow!("No diagnostics found"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -356,7 +356,8 @@ impl SlashCommand for DocsSlashCommand {
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,7 +167,8 @@ impl SlashCommand for FetchSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
use anyhow::{anyhow, Context as _, Result};
|
use anyhow::{anyhow, Context as _, Result};
|
||||||
use assistant_slash_command::{
|
use assistant_slash_command::{
|
||||||
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
|
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
|
||||||
SlashCommandOutputSection, SlashCommandResult,
|
SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
|
||||||
};
|
};
|
||||||
|
use futures::channel::mpsc;
|
||||||
use fuzzy::PathMatch;
|
use fuzzy::PathMatch;
|
||||||
use gpui::{AppContext, Model, Task, View, WeakView};
|
use gpui::{AppContext, Model, Task, View, WeakView};
|
||||||
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
|
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
|
||||||
use project::{PathMatchCandidateSet, Project};
|
use project::{PathMatchCandidateSet, Project};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use smol::stream::StreamExt;
|
||||||
use std::{
|
use std::{
|
||||||
fmt::Write,
|
fmt::Write,
|
||||||
ops::{Range, RangeInclusive},
|
ops::{Range, RangeInclusive},
|
||||||
|
@ -221,11 +223,11 @@ fn collect_files(
|
||||||
.map(|worktree| worktree.read(cx).snapshot())
|
.map(|worktree| worktree.read(cx).snapshot())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let (events_tx, events_rx) = mpsc::unbounded();
|
||||||
cx.spawn(|mut cx| async move {
|
cx.spawn(|mut cx| async move {
|
||||||
let mut output = SlashCommandOutput::default();
|
|
||||||
for snapshot in snapshots {
|
for snapshot in snapshots {
|
||||||
let worktree_id = snapshot.id();
|
let worktree_id = snapshot.id();
|
||||||
let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
|
let mut directory_stack: Vec<Arc<Path>> = Vec::new();
|
||||||
let mut folded_directory_names_stack = Vec::new();
|
let mut folded_directory_names_stack = Vec::new();
|
||||||
let mut is_top_level_directory = true;
|
let mut is_top_level_directory = true;
|
||||||
|
|
||||||
|
@ -241,17 +243,19 @@ fn collect_files(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some((dir, _, _)) = directory_stack.last() {
|
while let Some(dir) = directory_stack.last() {
|
||||||
if entry.path.starts_with(dir) {
|
if entry.path.starts_with(dir) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let (_, entry_name, start) = directory_stack.pop().unwrap();
|
directory_stack.pop().unwrap();
|
||||||
output.sections.push(build_entry_output_section(
|
events_tx
|
||||||
start..output.text.len().saturating_sub(1),
|
.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
|
||||||
Some(&PathBuf::from(entry_name)),
|
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
|
||||||
true,
|
SlashCommandContent::Text {
|
||||||
None,
|
text: "\n".into(),
|
||||||
));
|
run_commands_in_text: false,
|
||||||
|
},
|
||||||
|
)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let filename = entry
|
let filename = entry
|
||||||
|
@ -283,23 +287,46 @@ fn collect_files(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
|
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
|
||||||
let entry_start = output.text.len();
|
|
||||||
if prefix_paths.is_empty() {
|
if prefix_paths.is_empty() {
|
||||||
if is_top_level_directory {
|
let label = if is_top_level_directory {
|
||||||
output
|
|
||||||
.text
|
|
||||||
.push_str(&path_including_worktree_name.to_string_lossy());
|
|
||||||
is_top_level_directory = false;
|
is_top_level_directory = false;
|
||||||
|
path_including_worktree_name.to_string_lossy().to_string()
|
||||||
} else {
|
} else {
|
||||||
output.text.push_str(&filename);
|
filename
|
||||||
}
|
};
|
||||||
directory_stack.push((entry.path.clone(), filename, entry_start));
|
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::Folder,
|
||||||
|
label: label.clone().into(),
|
||||||
|
metadata: None,
|
||||||
|
}))?;
|
||||||
|
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
|
||||||
|
SlashCommandContent::Text {
|
||||||
|
text: label,
|
||||||
|
run_commands_in_text: false,
|
||||||
|
},
|
||||||
|
)))?;
|
||||||
|
directory_stack.push(entry.path.clone());
|
||||||
} else {
|
} else {
|
||||||
let entry_name = format!("{}/{}", prefix_paths, &filename);
|
let entry_name = format!("{}/{}", prefix_paths, &filename);
|
||||||
output.text.push_str(&entry_name);
|
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
|
||||||
directory_stack.push((entry.path.clone(), entry_name, entry_start));
|
icon: IconName::Folder,
|
||||||
|
label: entry_name.clone().into(),
|
||||||
|
metadata: None,
|
||||||
|
}))?;
|
||||||
|
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
|
||||||
|
SlashCommandContent::Text {
|
||||||
|
text: entry_name,
|
||||||
|
run_commands_in_text: false,
|
||||||
|
},
|
||||||
|
)))?;
|
||||||
|
directory_stack.push(entry.path.clone());
|
||||||
}
|
}
|
||||||
output.text.push('\n');
|
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
|
||||||
|
SlashCommandContent::Text {
|
||||||
|
text: "\n".into(),
|
||||||
|
run_commands_in_text: false,
|
||||||
|
},
|
||||||
|
)))?;
|
||||||
} else if entry.is_file() {
|
} else if entry.is_file() {
|
||||||
let Some(open_buffer_task) = project_handle
|
let Some(open_buffer_task) = project_handle
|
||||||
.update(&mut cx, |project, cx| {
|
.update(&mut cx, |project, cx| {
|
||||||
|
@ -310,6 +337,7 @@ fn collect_files(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(buffer) = open_buffer_task.await.log_err() {
|
if let Some(buffer) = open_buffer_task.await.log_err() {
|
||||||
|
let mut output = SlashCommandOutput::default();
|
||||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
|
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
|
||||||
append_buffer_to_output(
|
append_buffer_to_output(
|
||||||
&snapshot,
|
&snapshot,
|
||||||
|
@ -317,32 +345,19 @@ fn collect_files(
|
||||||
&mut output,
|
&mut output,
|
||||||
)
|
)
|
||||||
.log_err();
|
.log_err();
|
||||||
|
let mut buffer_events = output.to_event_stream();
|
||||||
|
while let Some(event) = buffer_events.next().await {
|
||||||
|
events_tx.unbounded_send(event)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some((dir, entry, start)) = directory_stack.pop() {
|
while let Some(_) = directory_stack.pop() {
|
||||||
if directory_stack.is_empty() {
|
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
|
||||||
let mut root_path = PathBuf::new();
|
|
||||||
root_path.push(snapshot.root_name());
|
|
||||||
root_path.push(&dir);
|
|
||||||
output.sections.push(build_entry_output_section(
|
|
||||||
start..output.text.len(),
|
|
||||||
Some(&root_path),
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
output.sections.push(build_entry_output_section(
|
|
||||||
start..output.text.len(),
|
|
||||||
Some(&PathBuf::from(entry.as_str())),
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(events_rx.boxed())
|
||||||
Ok(output)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -528,8 +543,10 @@ pub fn append_buffer_to_output(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
use assistant_slash_command::SlashCommandOutput;
|
||||||
use fs::FakeFs;
|
use fs::FakeFs;
|
||||||
use gpui::TestAppContext;
|
use gpui::TestAppContext;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
|
@ -577,6 +594,9 @@ mod test {
|
||||||
.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
|
.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result_1 = SlashCommandOutput::from_event_stream(result_1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(result_1.text.starts_with("root/dir"));
|
assert!(result_1.text.starts_with("root/dir"));
|
||||||
// 4 files + 2 directories
|
// 4 files + 2 directories
|
||||||
|
@ -586,6 +606,9 @@ mod test {
|
||||||
.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
|
.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result_2 = SlashCommandOutput::from_event_stream(result_2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(result_1, result_2);
|
assert_eq!(result_1, result_2);
|
||||||
|
|
||||||
|
@ -593,6 +616,7 @@ mod test {
|
||||||
.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
|
.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
|
||||||
|
|
||||||
assert!(result.text.starts_with("root/dir"));
|
assert!(result.text.starts_with("root/dir"));
|
||||||
// 5 files + 2 directories
|
// 5 files + 2 directories
|
||||||
|
@ -639,6 +663,7 @@ mod test {
|
||||||
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
|
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
|
||||||
|
|
||||||
// Sanity check
|
// Sanity check
|
||||||
assert!(result.text.starts_with("zed/assets/themes\n"));
|
assert!(result.text.starts_with("zed/assets/themes\n"));
|
||||||
|
@ -700,6 +725,7 @@ mod test {
|
||||||
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
|
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
|
||||||
|
|
||||||
assert!(result.text.starts_with("zed/assets/themes\n"));
|
assert!(result.text.starts_with("zed/assets/themes\n"));
|
||||||
assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
|
assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
|
||||||
|
@ -720,6 +746,8 @@ mod test {
|
||||||
assert_eq!(result.sections[6].label, "summercamp");
|
assert_eq!(result.sections[6].label, "summercamp");
|
||||||
assert_eq!(result.sections[7].label, "zed/assets/themes");
|
assert_eq!(result.sections[7].label, "zed/assets/themes");
|
||||||
|
|
||||||
|
assert_eq!(result.text, "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n");
|
||||||
|
|
||||||
// Ensure that the project lasts until after the last await
|
// Ensure that the project lasts until after the last await
|
||||||
drop(project);
|
drop(project);
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,6 +63,7 @@ impl SlashCommand for NowSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
}))
|
}
|
||||||
|
.to_event_stream()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -162,7 +162,8 @@ impl SlashCommand for ProjectSlashCommand {
|
||||||
text: output,
|
text: output,
|
||||||
sections,
|
sections,
|
||||||
run_commands_in_text: true,
|
run_commands_in_text: true,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
|
|
|
@ -102,7 +102,8 @@ impl SlashCommand for PromptSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: true,
|
run_commands_in_text: true,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,6 +130,7 @@ impl SlashCommand for SearchSlashCommand {
|
||||||
sections,
|
sections,
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
}
|
}
|
||||||
|
.to_event_stream()
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,8 @@ impl SlashCommand for OutlineSlashCommand {
|
||||||
}],
|
}],
|
||||||
text: outline_text,
|
text: outline_text,
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -150,7 +150,7 @@ impl SlashCommand for TabSlashCommand {
|
||||||
for (full_path, buffer, _) in tab_items_search.await? {
|
for (full_path, buffer, _) in tab_items_search.await? {
|
||||||
append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
|
append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
|
||||||
}
|
}
|
||||||
Ok(output)
|
Ok(output.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,7 +97,8 @@ impl SlashCommand for TerminalSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
}))
|
}
|
||||||
|
.to_event_stream()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,8 @@ impl SlashCommand for WorkflowSlashCommand {
|
||||||
metadata: None,
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,9 +15,15 @@ path = "src/assistant_slash_command.rs"
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
collections.workspace = true
|
collections.workspace = true
|
||||||
derive_more.workspace = true
|
derive_more.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
language.workspace = true
|
language.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
workspace.workspace = true
|
workspace.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
pretty_assertions.workspace = true
|
||||||
|
workspace = { workspace = true, features = ["test-support"] }
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
mod slash_command_registry;
|
mod slash_command_registry;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use futures::stream::{self, BoxStream};
|
||||||
|
use futures::StreamExt;
|
||||||
use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
|
use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
|
||||||
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
|
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
@ -56,7 +58,7 @@ pub struct ArgumentCompletion {
|
||||||
pub replace_previous_arguments: bool,
|
pub replace_previous_arguments: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SlashCommandResult = Result<SlashCommandOutput>;
|
pub type SlashCommandResult = Result<BoxStream<'static, Result<SlashCommandEvent>>>;
|
||||||
|
|
||||||
pub trait SlashCommand: 'static + Send + Sync {
|
pub trait SlashCommand: 'static + Send + Sync {
|
||||||
fn name(&self) -> String;
|
fn name(&self) -> String;
|
||||||
|
@ -98,13 +100,146 @@ pub type RenderFoldPlaceholder = Arc<
|
||||||
+ Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
|
+ Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
#[derive(Debug, Default, PartialEq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum SlashCommandContent {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
run_commands_in_text: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum SlashCommandEvent {
|
||||||
|
StartSection {
|
||||||
|
icon: IconName,
|
||||||
|
label: SharedString,
|
||||||
|
metadata: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
Content(SlashCommandContent),
|
||||||
|
EndSection {
|
||||||
|
metadata: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Clone)]
|
||||||
pub struct SlashCommandOutput {
|
pub struct SlashCommandOutput {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub sections: Vec<SlashCommandOutputSection<usize>>,
|
pub sections: Vec<SlashCommandOutputSection<usize>>,
|
||||||
pub run_commands_in_text: bool,
|
pub run_commands_in_text: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SlashCommandOutput {
|
||||||
|
pub fn ensure_valid_section_ranges(&mut self) {
|
||||||
|
for section in &mut self.sections {
|
||||||
|
section.range.start = section.range.start.min(self.text.len());
|
||||||
|
section.range.end = section.range.end.min(self.text.len());
|
||||||
|
while !self.text.is_char_boundary(section.range.start) {
|
||||||
|
section.range.start -= 1;
|
||||||
|
}
|
||||||
|
while !self.text.is_char_boundary(section.range.end) {
|
||||||
|
section.range.end += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns this [`SlashCommandOutput`] as a stream of [`SlashCommandEvent`]s.
|
||||||
|
pub fn to_event_stream(mut self) -> BoxStream<'static, Result<SlashCommandEvent>> {
|
||||||
|
self.ensure_valid_section_ranges();
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut last_section_end = 0;
|
||||||
|
|
||||||
|
for section in self.sections {
|
||||||
|
if last_section_end < section.range.start {
|
||||||
|
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: self
|
||||||
|
.text
|
||||||
|
.get(last_section_end..section.range.start)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
run_commands_in_text: self.run_commands_in_text,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(Ok(SlashCommandEvent::StartSection {
|
||||||
|
icon: section.icon,
|
||||||
|
label: section.label,
|
||||||
|
metadata: section.metadata.clone(),
|
||||||
|
}));
|
||||||
|
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: self
|
||||||
|
.text
|
||||||
|
.get(section.range.start..section.range.end)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
run_commands_in_text: self.run_commands_in_text,
|
||||||
|
})));
|
||||||
|
events.push(Ok(SlashCommandEvent::EndSection {
|
||||||
|
metadata: section.metadata,
|
||||||
|
}));
|
||||||
|
|
||||||
|
last_section_end = section.range.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_section_end < self.text.len() {
|
||||||
|
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: self.text[last_section_end..].to_string(),
|
||||||
|
run_commands_in_text: self.run_commands_in_text,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
stream::iter(events).boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn from_event_stream(
|
||||||
|
mut events: BoxStream<'static, Result<SlashCommandEvent>>,
|
||||||
|
) -> Result<SlashCommandOutput> {
|
||||||
|
let mut output = SlashCommandOutput::default();
|
||||||
|
let mut section_stack = Vec::new();
|
||||||
|
|
||||||
|
while let Some(event) = events.next().await {
|
||||||
|
match event? {
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
metadata,
|
||||||
|
} => {
|
||||||
|
let start = output.text.len();
|
||||||
|
section_stack.push(SlashCommandOutputSection {
|
||||||
|
range: start..start,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text,
|
||||||
|
run_commands_in_text,
|
||||||
|
}) => {
|
||||||
|
output.text.push_str(&text);
|
||||||
|
output.run_commands_in_text = run_commands_in_text;
|
||||||
|
|
||||||
|
if let Some(section) = section_stack.last_mut() {
|
||||||
|
section.range.end = output.text.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SlashCommandEvent::EndSection { metadata } => {
|
||||||
|
if let Some(mut section) = section_stack.pop() {
|
||||||
|
section.metadata = metadata;
|
||||||
|
output.sections.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(section) = section_stack.pop() {
|
||||||
|
output.sections.push(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SlashCommandOutputSection<T> {
|
pub struct SlashCommandOutputSection<T> {
|
||||||
pub range: Range<T>,
|
pub range: Range<T>,
|
||||||
|
@ -118,3 +253,243 @@ impl SlashCommandOutputSection<language::Anchor> {
|
||||||
self.range.start.is_valid(buffer) && !self.range.to_offset(buffer).is_empty()
|
self.range.start.is_valid(buffer) && !self.range.to_offset(buffer).is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_slash_command_output_to_events_round_trip() {
|
||||||
|
// Test basic output consisting of a single section.
|
||||||
|
{
|
||||||
|
let text = "Hello, world!".to_string();
|
||||||
|
let range = 0..text.len();
|
||||||
|
let output = SlashCommandOutput {
|
||||||
|
text,
|
||||||
|
sections: vec![SlashCommandOutputSection {
|
||||||
|
range,
|
||||||
|
icon: IconName::Code,
|
||||||
|
label: "Section 1".into(),
|
||||||
|
metadata: None,
|
||||||
|
}],
|
||||||
|
run_commands_in_text: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
|
||||||
|
let events = events
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|event| event.ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::Code,
|
||||||
|
label: "Section 1".into(),
|
||||||
|
metadata: None
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Hello, world!".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection { metadata: None }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_output =
|
||||||
|
SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(new_output, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test output where the sections do not comprise all of the text.
|
||||||
|
{
|
||||||
|
let text = "Apple\nCucumber\nBanana\n".to_string();
|
||||||
|
let output = SlashCommandOutput {
|
||||||
|
text,
|
||||||
|
sections: vec![
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 0..6,
|
||||||
|
icon: IconName::Check,
|
||||||
|
label: "Fruit".into(),
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 15..22,
|
||||||
|
icon: IconName::Check,
|
||||||
|
label: "Fruit".into(),
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run_commands_in_text: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
|
||||||
|
let events = events
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|event| event.ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::Check,
|
||||||
|
label: "Fruit".into(),
|
||||||
|
metadata: None
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Apple\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection { metadata: None },
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Cucumber\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::Check,
|
||||||
|
label: "Fruit".into(),
|
||||||
|
metadata: None
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Banana\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection { metadata: None }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_output =
|
||||||
|
SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(new_output, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test output consisting of multiple sections.
|
||||||
|
{
|
||||||
|
let text = "Line 1\nLine 2\nLine 3\nLine 4\n".to_string();
|
||||||
|
let output = SlashCommandOutput {
|
||||||
|
text,
|
||||||
|
sections: vec![
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 0..6,
|
||||||
|
icon: IconName::FileCode,
|
||||||
|
label: "Section 1".into(),
|
||||||
|
metadata: Some(json!({ "a": true })),
|
||||||
|
},
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 7..13,
|
||||||
|
icon: IconName::FileDoc,
|
||||||
|
label: "Section 2".into(),
|
||||||
|
metadata: Some(json!({ "b": true })),
|
||||||
|
},
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 14..20,
|
||||||
|
icon: IconName::FileGit,
|
||||||
|
label: "Section 3".into(),
|
||||||
|
metadata: Some(json!({ "c": true })),
|
||||||
|
},
|
||||||
|
SlashCommandOutputSection {
|
||||||
|
range: 21..27,
|
||||||
|
icon: IconName::FileToml,
|
||||||
|
label: "Section 4".into(),
|
||||||
|
metadata: Some(json!({ "d": true })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run_commands_in_text: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
|
||||||
|
let events = events
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|event| event.ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::FileCode,
|
||||||
|
label: "Section 1".into(),
|
||||||
|
metadata: Some(json!({ "a": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Line 1".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection {
|
||||||
|
metadata: Some(json!({ "a": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::FileDoc,
|
||||||
|
label: "Section 2".into(),
|
||||||
|
metadata: Some(json!({ "b": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Line 2".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection {
|
||||||
|
metadata: Some(json!({ "b": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::FileGit,
|
||||||
|
label: "Section 3".into(),
|
||||||
|
metadata: Some(json!({ "c": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Line 3".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection {
|
||||||
|
metadata: Some(json!({ "c": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::StartSection {
|
||||||
|
icon: IconName::FileToml,
|
||||||
|
label: "Section 4".into(),
|
||||||
|
metadata: Some(json!({ "d": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "Line 4".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
SlashCommandEvent::EndSection {
|
||||||
|
metadata: Some(json!({ "d": true }))
|
||||||
|
},
|
||||||
|
SlashCommandEvent::Content(SlashCommandContent::Text {
|
||||||
|
text: "\n".into(),
|
||||||
|
run_commands_in_text: false
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_output =
|
||||||
|
SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(new_output, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -128,7 +128,8 @@ impl SlashCommand for ExtensionSlashCommand {
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
}
|
||||||
|
.to_event_stream())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue