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:
Marshall Bowers 2024-10-23 21:26:50 -04:00 committed by GitHub
parent 510c71d41b
commit d30361537e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 516 additions and 88 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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 {

View file

@ -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()))
} }
} }

View file

@ -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())
}) })
} }
} }

View file

@ -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)))

View file

@ -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")))

View file

@ -78,7 +78,8 @@ impl SlashCommand for DefaultSlashCommand {
}], }],
text, text,
run_commands_in_text: true, run_commands_in_text: true,
}) }
.to_event_stream())
}) })
} }
} }

View file

@ -86,25 +86,28 @@ 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 Some(file_command_range) = new_output.sections.first() { if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
let new_text = &new_output.text[file_command_range.range.clone()]; {
if old_text.chars().ne(new_text.chars()) { if let Some(file_command_range) = new_output.sections.first() {
output.sections.extend(new_output.sections.into_iter().map( let new_text = &new_output.text[file_command_range.range.clone()];
|section| SlashCommandOutputSection { if old_text.chars().ne(new_text.chars()) {
range: output.text.len() + section.range.start output.sections.extend(new_output.sections.into_iter().map(
..output.text.len() + section.range.end, |section| SlashCommandOutputSection {
icon: section.icon, range: output.text.len() + section.range.start
label: section.label, ..output.text.len() + section.range.end,
metadata: section.metadata, icon: section.icon,
}, label: section.label,
)); metadata: section.metadata,
output.text.push_str(&new_output.text); },
));
output.text.push_str(&new_output.text);
}
} }
} }
} }
} }
Ok(output) Ok(output.to_event_stream())
}) })
} }
} }

View file

@ -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"))
})
} }
} }

View file

@ -356,7 +356,8 @@ impl SlashCommand for DocsSlashCommand {
}) })
.collect(), .collect(),
run_commands_in_text: false, run_commands_in_text: false,
}) }
.to_event_stream())
}) })
} }
} }

View file

@ -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())
}) })
} }
} }

View file

@ -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(output) Ok(events_rx.boxed())
}) })
} }
@ -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);
} }

View file

@ -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()))
} }
} }

View file

@ -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
}) })

View file

@ -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())
}) })
} }
} }

View file

@ -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;

View file

@ -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())
}) })
}); });

View file

@ -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())
}) })
} }
} }

View file

@ -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()))
} }
} }

View file

@ -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())
}) })
} }
} }

View file

@ -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"] }

View file

@ -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);
}
}
}

View file

@ -128,7 +128,8 @@ impl SlashCommand for ExtensionSlashCommand {
}) })
.collect(), .collect(),
run_commands_in_text: false, run_commands_in_text: false,
}) }
.to_event_stream())
}) })
} }
} }