From d30361537ea6432336e19cb624c5d4ee6f926f27 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 23 Oct 2024 21:26:50 -0400 Subject: [PATCH] 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 --- Cargo.lock | 2 + crates/assistant/src/context.rs | 18 +- crates/assistant/src/context/context_tests.rs | 6 +- .../src/slash_command/auto_command.rs | 3 +- .../slash_command/cargo_workspace_command.rs | 3 +- .../slash_command/context_server_command.rs | 3 +- .../src/slash_command/default_command.rs | 3 +- .../src/slash_command/delta_command.rs | 31 +- .../src/slash_command/diagnostics_command.rs | 6 +- .../src/slash_command/docs_command.rs | 3 +- .../src/slash_command/fetch_command.rs | 3 +- .../src/slash_command/file_command.rs | 114 ++++-- .../src/slash_command/now_command.rs | 3 +- .../src/slash_command/project_command.rs | 3 +- .../src/slash_command/prompt_command.rs | 3 +- .../src/slash_command/search_command.rs | 1 + .../src/slash_command/symbols_command.rs | 3 +- .../src/slash_command/tab_command.rs | 2 +- .../src/slash_command/terminal_command.rs | 3 +- .../src/slash_command/workflow_command.rs | 3 +- crates/assistant_slash_command/Cargo.toml | 6 + .../src/assistant_slash_command.rs | 379 +++++++++++++++++- .../extension/src/extension_slash_command.rs | 3 +- 23 files changed, 516 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 271af64ff5..7c73ec0cff 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index d2b80ca224..78237e51b2 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -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 { diff --git a/crates/assistant/src/context/context_tests.rs b/crates/assistant/src/context/context_tests.rs index 4d866b4d8b..e1b7448738 100644 --- a/crates/assistant/src/context/context_tests.rs +++ b/crates/assistant/src/context/context_tests.rs @@ -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())) } } diff --git a/crates/assistant/src/slash_command/auto_command.rs b/crates/assistant/src/slash_command/auto_command.rs index 352b5a3ac9..cc73f36ebf 100644 --- a/crates/assistant/src/slash_command/auto_command.rs +++ b/crates/assistant/src/slash_command/auto_command.rs @@ -147,7 +147,8 @@ impl SlashCommand for AutoCommand { text: prompt, sections: Vec::new(), run_commands_in_text: true, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant/src/slash_command/cargo_workspace_command.rs b/crates/assistant/src/slash_command/cargo_workspace_command.rs index 04fa408717..968238d36e 100644 --- a/crates/assistant/src/slash_command/cargo_workspace_command.rs +++ b/crates/assistant/src/slash_command/cargo_workspace_command.rs @@ -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))) diff --git a/crates/assistant/src/slash_command/context_server_command.rs b/crates/assistant/src/slash_command/context_server_command.rs index b749f9e4cd..5b22e76bf8 100644 --- a/crates/assistant/src/slash_command/context_server_command.rs +++ b/crates/assistant/src/slash_command/context_server_command.rs @@ -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"))) diff --git a/crates/assistant/src/slash_command/default_command.rs b/crates/assistant/src/slash_command/default_command.rs index 2c956f8ca6..4d9c9e2ae4 100644 --- a/crates/assistant/src/slash_command/default_command.rs +++ b/crates/assistant/src/slash_command/default_command.rs @@ -78,7 +78,8 @@ impl SlashCommand for DefaultSlashCommand { }], text, run_commands_in_text: true, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant/src/slash_command/delta_command.rs b/crates/assistant/src/slash_command/delta_command.rs index a17c5d739c..a37d33e2af 100644 --- a/crates/assistant/src/slash_command/delta_command.rs +++ b/crates/assistant/src/slash_command/delta_command.rs @@ -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()) }) } } diff --git a/crates/assistant/src/slash_command/diagnostics_command.rs b/crates/assistant/src/slash_command/diagnostics_command.rs index 54be2219ff..c7475445ce 100644 --- a/crates/assistant/src/slash_command/diagnostics_command.rs +++ b/crates/assistant/src/slash_command/diagnostics_command.rs @@ -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")) + }) } } diff --git a/crates/assistant/src/slash_command/docs_command.rs b/crates/assistant/src/slash_command/docs_command.rs index 92c3cd1977..b54f708e32 100644 --- a/crates/assistant/src/slash_command/docs_command.rs +++ b/crates/assistant/src/slash_command/docs_command.rs @@ -356,7 +356,8 @@ impl SlashCommand for DocsSlashCommand { }) .collect(), run_commands_in_text: false, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs index 9b61c547db..4d38bb20a7 100644 --- a/crates/assistant/src/slash_command/fetch_command.rs +++ b/crates/assistant/src/slash_command/fetch_command.rs @@ -167,7 +167,8 @@ impl SlashCommand for FetchSlashCommand { metadata: None, }], run_commands_in_text: false, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index 51d0b33ba2..0a1794cae1 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/crates/assistant/src/slash_command/file_command.rs @@ -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::>(); + 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, String, usize)> = Vec::new(); + let mut directory_stack: Vec> = 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); } diff --git a/crates/assistant/src/slash_command/now_command.rs b/crates/assistant/src/slash_command/now_command.rs index 40bc29f27d..cf81bec926 100644 --- a/crates/assistant/src/slash_command/now_command.rs +++ b/crates/assistant/src/slash_command/now_command.rs @@ -63,6 +63,7 @@ impl SlashCommand for NowSlashCommand { metadata: None, }], run_commands_in_text: false, - })) + } + .to_event_stream())) } } diff --git a/crates/assistant/src/slash_command/project_command.rs b/crates/assistant/src/slash_command/project_command.rs index e55699b026..d14cb310ad 100644 --- a/crates/assistant/src/slash_command/project_command.rs +++ b/crates/assistant/src/slash_command/project_command.rs @@ -162,7 +162,8 @@ impl SlashCommand for ProjectSlashCommand { text: output, sections, run_commands_in_text: true, - }) + } + .to_event_stream()) }) .await }) diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index dc80329382..079d1425af 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -102,7 +102,8 @@ impl SlashCommand for PromptSlashCommand { metadata: None, }], run_commands_in_text: true, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant/src/slash_command/search_command.rs b/crates/assistant/src/slash_command/search_command.rs index 999fe252be..9c4938ce93 100644 --- a/crates/assistant/src/slash_command/search_command.rs +++ b/crates/assistant/src/slash_command/search_command.rs @@ -130,6 +130,7 @@ impl SlashCommand for SearchSlashCommand { sections, run_commands_in_text: false, } + .to_event_stream() }) .await; diff --git a/crates/assistant/src/slash_command/symbols_command.rs b/crates/assistant/src/slash_command/symbols_command.rs index d28b53c1a1..468c8d7126 100644 --- a/crates/assistant/src/slash_command/symbols_command.rs +++ b/crates/assistant/src/slash_command/symbols_command.rs @@ -85,7 +85,8 @@ impl SlashCommand for OutlineSlashCommand { }], text: outline_text, run_commands_in_text: false, - }) + } + .to_event_stream()) }) }); diff --git a/crates/assistant/src/slash_command/tab_command.rs b/crates/assistant/src/slash_command/tab_command.rs index 23c3b64b38..771c0765ee 100644 --- a/crates/assistant/src/slash_command/tab_command.rs +++ b/crates/assistant/src/slash_command/tab_command.rs @@ -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()) }) } } diff --git a/crates/assistant/src/slash_command/terminal_command.rs b/crates/assistant/src/slash_command/terminal_command.rs index 7516b275ac..2ca1d4041b 100644 --- a/crates/assistant/src/slash_command/terminal_command.rs +++ b/crates/assistant/src/slash_command/terminal_command.rs @@ -97,7 +97,8 @@ impl SlashCommand for TerminalSlashCommand { metadata: None, }], run_commands_in_text: false, - })) + } + .to_event_stream())) } } diff --git a/crates/assistant/src/slash_command/workflow_command.rs b/crates/assistant/src/slash_command/workflow_command.rs index 1379eb5e80..ca6ccde92e 100644 --- a/crates/assistant/src/slash_command/workflow_command.rs +++ b/crates/assistant/src/slash_command/workflow_command.rs @@ -75,7 +75,8 @@ impl SlashCommand for WorkflowSlashCommand { metadata: None, }], run_commands_in_text: false, - }) + } + .to_event_stream()) }) } } diff --git a/crates/assistant_slash_command/Cargo.toml b/crates/assistant_slash_command/Cargo.toml index a58a84312f..8ec5b729c9 100644 --- a/crates/assistant_slash_command/Cargo.toml +++ b/crates/assistant_slash_command/Cargo.toml @@ -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"] } diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index 90e47690a8..de247602d8 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -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; +pub type SlashCommandResult = Result>>; pub trait SlashCommand: 'static + Send + Sync { fn name(&self) -> String; @@ -98,13 +100,146 @@ pub type RenderFoldPlaceholder = Arc< + Fn(ElementId, Arc, &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, + }, + Content(SlashCommandContent), + EndSection { + metadata: Option, + }, +} + +#[derive(Debug, Default, PartialEq, Clone)] pub struct SlashCommandOutput { pub text: String, pub sections: Vec>, 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> { + 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>, + ) -> Result { + 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 { pub range: Range, @@ -118,3 +253,243 @@ impl SlashCommandOutputSection { 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::>().await; + let events = events + .into_iter() + .filter_map(|event| event.ok()) + .collect::>(); + + 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::>().await; + let events = events + .into_iter() + .filter_map(|event| event.ok()) + .collect::>(); + + 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::>().await; + let events = events + .into_iter() + .filter_map(|event| event.ok()) + .collect::>(); + + 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); + } + } +} diff --git a/crates/extension/src/extension_slash_command.rs b/crates/extension/src/extension_slash_command.rs index e9725f1ae4..0a10e9e1a2 100644 --- a/crates/extension/src/extension_slash_command.rs +++ b/crates/extension/src/extension_slash_command.rs @@ -128,7 +128,8 @@ impl SlashCommand for ExtensionSlashCommand { }) .collect(), run_commands_in_text: false, - }) + } + .to_event_stream()) }) } }