Cargo.lock 🔗
@@ -453,9 +453,11 @@ dependencies = [
"anyhow",
"collections",
"derive_more",
+ "futures 0.3.30",
"gpui",
"language",
"parking_lot",
+ "pretty_assertions",
"serde",
"serde_json",
"workspace",
Marshall Bowers and Max created
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>
Cargo.lock | 2
crates/assistant/src/context.rs | 18
crates/assistant/src/context/context_tests.rs | 6
crates/assistant/src/slash_command/auto_command.rs | 3
crates/assistant/src/slash_command/cargo_workspace_command.rs | 3
crates/assistant/src/slash_command/context_server_command.rs | 3
crates/assistant/src/slash_command/default_command.rs | 3
crates/assistant/src/slash_command/delta_command.rs | 31
crates/assistant/src/slash_command/diagnostics_command.rs | 6
crates/assistant/src/slash_command/docs_command.rs | 3
crates/assistant/src/slash_command/fetch_command.rs | 3
crates/assistant/src/slash_command/file_command.rs | 114
crates/assistant/src/slash_command/now_command.rs | 3
crates/assistant/src/slash_command/project_command.rs | 3
crates/assistant/src/slash_command/prompt_command.rs | 3
crates/assistant/src/slash_command/search_command.rs | 1
crates/assistant/src/slash_command/symbols_command.rs | 3
crates/assistant/src/slash_command/tab_command.rs | 2
crates/assistant/src/slash_command/terminal_command.rs | 3
crates/assistant/src/slash_command/workflow_command.rs | 3
crates/assistant_slash_command/Cargo.toml | 6
crates/assistant_slash_command/src/assistant_slash_command.rs | 379 ++++
crates/extension/src/extension_slash_command.rs | 3
23 files changed, 516 insertions(+), 88 deletions(-)
@@ -453,9 +453,11 @@ dependencies = [
"anyhow",
"collections",
"derive_more",
+ "futures 0.3.30",
"gpui",
"language",
"parking_lot",
+ "pretty_assertions",
"serde",
"serde_json",
"workspace",
@@ -7,7 +7,7 @@ use crate::{
};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
- SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
+ SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
};
use assistant_tool::ToolRegistry;
use client::{self, proto, telemetry::Telemetry};
@@ -1688,19 +1688,13 @@ impl Context {
let command_range = command_range.clone();
async move {
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 {
Ok(mut output) => {
- // Ensure section ranges are valid.
- 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;
- }
- }
+ output.ensure_valid_section_ranges();
// Ensure there is a newline after the last section.
if ensure_trailing_newline {
@@ -1097,7 +1097,8 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
text: output_text,
sections,
run_commands_in_text: false,
- })),
+ }
+ .to_event_stream())),
true,
false,
cx,
@@ -1421,6 +1422,7 @@ impl SlashCommand for FakeSlashCommand {
text: format!("Executed fake command: {}", self.0),
sections: vec![],
run_commands_in_text: false,
- }))
+ }
+ .to_event_stream()))
}
}
@@ -147,7 +147,8 @@ impl SlashCommand for AutoCommand {
text: prompt,
sections: Vec::new(),
run_commands_in_text: true,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -147,7 +147,8 @@ impl SlashCommand for CargoWorkspaceSlashCommand {
metadata: None,
}],
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
@@ -185,7 +185,8 @@ impl SlashCommand for ContextServerSlashCommand {
}],
text: prompt,
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
} else {
Task::ready(Err(anyhow!("Context server not found")))
@@ -78,7 +78,8 @@ impl SlashCommand for DefaultSlashCommand {
}],
text,
run_commands_in_text: true,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -86,25 +86,28 @@ impl SlashCommand for DeltaSlashCommand {
.zip(file_command_new_outputs)
{
if let Ok(new_output) = new_output {
- if let Some(file_command_range) = new_output.sections.first() {
- let new_text = &new_output.text[file_command_range.range.clone()];
- if old_text.chars().ne(new_text.chars()) {
- output.sections.extend(new_output.sections.into_iter().map(
- |section| SlashCommandOutputSection {
- range: output.text.len() + section.range.start
- ..output.text.len() + section.range.end,
- icon: section.icon,
- label: section.label,
- metadata: section.metadata,
- },
- ));
- output.text.push_str(&new_output.text);
+ if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
+ {
+ if let Some(file_command_range) = new_output.sections.first() {
+ let new_text = &new_output.text[file_command_range.range.clone()];
+ if old_text.chars().ne(new_text.chars()) {
+ output.sections.extend(new_output.sections.into_iter().map(
+ |section| SlashCommandOutputSection {
+ range: output.text.len() + section.range.start
+ ..output.text.len() + section.range.end,
+ icon: section.icon,
+ label: section.label,
+ metadata: section.metadata,
+ },
+ ));
+ output.text.push_str(&new_output.text);
+ }
}
}
}
}
- 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);
- 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(),
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -167,7 +167,8 @@ impl SlashCommand for FetchSlashCommand {
metadata: None,
}],
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -1,13 +1,15 @@
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
- AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
- SlashCommandOutputSection, SlashCommandResult,
+ AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
+ SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
};
+use futures::channel::mpsc;
use fuzzy::PathMatch;
use gpui::{AppContext, Model, Task, View, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
use project::{PathMatchCandidateSet, Project};
use serde::{Deserialize, Serialize};
+use smol::stream::StreamExt;
use std::{
fmt::Write,
ops::{Range, RangeInclusive},
@@ -221,11 +223,11 @@ fn collect_files(
.map(|worktree| worktree.read(cx).snapshot())
.collect::<Vec<_>>();
+ let (events_tx, events_rx) = mpsc::unbounded();
cx.spawn(|mut cx| async move {
- let mut output = SlashCommandOutput::default();
for snapshot in snapshots {
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 is_top_level_directory = true;
@@ -241,17 +243,19 @@ fn collect_files(
continue;
}
- while let Some((dir, _, _)) = directory_stack.last() {
+ while let Some(dir) = directory_stack.last() {
if entry.path.starts_with(dir) {
break;
}
- let (_, entry_name, start) = directory_stack.pop().unwrap();
- output.sections.push(build_entry_output_section(
- start..output.text.len().saturating_sub(1),
- Some(&PathBuf::from(entry_name)),
- true,
- None,
- ));
+ directory_stack.pop().unwrap();
+ events_tx
+ .unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
+ events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
+ SlashCommandContent::Text {
+ text: "\n".into(),
+ run_commands_in_text: false,
+ },
+ )))?;
}
let filename = entry
@@ -283,23 +287,46 @@ fn collect_files(
continue;
}
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
- let entry_start = output.text.len();
if prefix_paths.is_empty() {
- if is_top_level_directory {
- output
- .text
- .push_str(&path_including_worktree_name.to_string_lossy());
+ let label = if is_top_level_directory {
is_top_level_directory = false;
+ path_including_worktree_name.to_string_lossy().to_string()
} else {
- output.text.push_str(&filename);
- }
- directory_stack.push((entry.path.clone(), filename, entry_start));
+ filename
+ };
+ 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 {
let entry_name = format!("{}/{}", prefix_paths, &filename);
- output.text.push_str(&entry_name);
- directory_stack.push((entry.path.clone(), entry_name, entry_start));
+ events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
+ 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() {
let Some(open_buffer_task) = project_handle
.update(&mut cx, |project, cx| {
@@ -310,6 +337,7 @@ fn collect_files(
continue;
};
if let Some(buffer) = open_buffer_task.await.log_err() {
+ let mut output = SlashCommandOutput::default();
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
append_buffer_to_output(
&snapshot,
@@ -317,32 +345,19 @@ fn collect_files(
&mut output,
)
.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() {
- if directory_stack.is_empty() {
- 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,
- ));
- }
+ while let Some(_) = directory_stack.pop() {
+ events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
}
}
- Ok(output)
+ Ok(events_rx.boxed())
})
}
@@ -528,8 +543,10 @@ pub fn append_buffer_to_output(
#[cfg(test)]
mod test {
+ use assistant_slash_command::SlashCommandOutput;
use fs::FakeFs;
use gpui::TestAppContext;
+ use pretty_assertions::assert_eq;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
@@ -577,6 +594,9 @@ mod test {
.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
.await
.unwrap();
+ let result_1 = SlashCommandOutput::from_event_stream(result_1)
+ .await
+ .unwrap();
assert!(result_1.text.starts_with("root/dir"));
// 4 files + 2 directories
@@ -586,6 +606,9 @@ mod test {
.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
.await
.unwrap();
+ let result_2 = SlashCommandOutput::from_event_stream(result_2)
+ .await
+ .unwrap();
assert_eq!(result_1, result_2);
@@ -593,6 +616,7 @@ mod test {
.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
.await
.unwrap();
+ let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
assert!(result.text.starts_with("root/dir"));
// 5 files + 2 directories
@@ -639,6 +663,7 @@ mod test {
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
.await
.unwrap();
+ let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
// Sanity check
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))
.await
.unwrap();
+ let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
assert!(result.text.starts_with("zed/assets/themes\n"));
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[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
drop(project);
}
@@ -63,6 +63,7 @@ impl SlashCommand for NowSlashCommand {
metadata: None,
}],
run_commands_in_text: false,
- }))
+ }
+ .to_event_stream()))
}
}
@@ -162,7 +162,8 @@ impl SlashCommand for ProjectSlashCommand {
text: output,
sections,
run_commands_in_text: true,
- })
+ }
+ .to_event_stream())
})
.await
})
@@ -102,7 +102,8 @@ impl SlashCommand for PromptSlashCommand {
metadata: None,
}],
run_commands_in_text: true,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -130,6 +130,7 @@ impl SlashCommand for SearchSlashCommand {
sections,
run_commands_in_text: false,
}
+ .to_event_stream()
})
.await;
@@ -85,7 +85,8 @@ impl SlashCommand for OutlineSlashCommand {
}],
text: outline_text,
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? {
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,
}],
run_commands_in_text: false,
- }))
+ }
+ .to_event_stream()))
}
}
@@ -75,7 +75,8 @@ impl SlashCommand for WorkflowSlashCommand {
metadata: None,
}],
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
}
}
@@ -15,9 +15,15 @@ path = "src/assistant_slash_command.rs"
anyhow.workspace = true
collections.workspace = true
derive_more.workspace = true
+futures.workspace = true
gpui.workspace = true
language.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.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;
use anyhow::Result;
+use futures::stream::{self, BoxStream};
+use futures::StreamExt;
use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
use serde::{Deserialize, Serialize};
@@ -56,7 +58,7 @@ pub struct ArgumentCompletion {
pub replace_previous_arguments: bool,
}
-pub type SlashCommandResult = Result<SlashCommandOutput>;
+pub type SlashCommandResult = Result<BoxStream<'static, Result<SlashCommandEvent>>>;
pub trait SlashCommand: 'static + Send + Sync {
fn name(&self) -> String;
@@ -98,13 +100,146 @@ pub type RenderFoldPlaceholder = Arc<
+ 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 text: String,
pub sections: Vec<SlashCommandOutputSection<usize>>,
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)]
pub struct SlashCommandOutputSection<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()
}
}
+
+#[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(),
run_commands_in_text: false,
- })
+ }
+ .to_event_stream())
})
}
}