Make slash command output streamable (#19632)

Marshall Bowers , David Soria Parra , Antonio Scandurra , David , Antonio , Max , Max Brunsfeld , and Will created

This PR adds support for streaming output from slash commands

In this PR we are focused primarily on the interface of the
`SlashCommand` trait to support streaming the output. We will follow up
later with support for extensions and context servers to take advantage
of the streaming nature.

Release Notes:

- N/A

---------

Co-authored-by: David Soria Parra <davidsp@anthropic.com>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: David <david@anthropic.com>
Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Max <max@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Will <will@zed.dev>

Change summary

Cargo.lock                                                      |   1 
crates/assistant/src/assistant_panel.rs                         | 290 
crates/assistant/src/context.rs                                 | 745 ++
crates/assistant/src/context/context_tests.rs                   | 305 
crates/assistant/src/slash_command.rs                           |   2 
crates/assistant/src/slash_command/streaming_example_command.rs |  18 
crates/assistant_slash_command/Cargo.toml                       |   1 
crates/assistant_slash_command/src/assistant_slash_command.rs   |  19 
crates/editor/src/display_map.rs                                |  49 
crates/editor/src/display_map/fold_map.rs                       |  84 
crates/editor/src/editor.rs                                     |  90 
crates/editor/src/items.rs                                      |   4 
crates/proto/proto/zed.proto                                    |  23 
crates/search/src/project_search.rs                             |   2 
14 files changed, 1,131 insertions(+), 502 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -463,6 +463,7 @@ dependencies = [
  "futures 0.3.30",
  "gpui",
  "language",
+ "language_model",
  "parking_lot",
  "pretty_assertions",
  "serde",

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,3 +1,4 @@
+use crate::slash_command::file_command::codeblock_fence_for_path;
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings},
     humanize_token_count,
@@ -6,24 +7,23 @@ use crate::{
     slash_command::{
         default_command::DefaultSlashCommand,
         docs_command::{DocsSlashCommand, DocsSlashCommandArgs},
-        file_command::{self, codeblock_fence_for_path},
-        SlashCommandCompletionProvider, SlashCommandRegistry,
+        file_command, SlashCommandCompletionProvider, SlashCommandRegistry,
     },
     slash_command_picker,
     terminal_inline_assistant::TerminalInlineAssistant,
     Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context,
     ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole,
     DeployHistory, DeployPromptLibrary, Edit, InlineAssistant, InsertDraggedFiles,
-    InsertIntoEditor, Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate,
-    ModelSelector, NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
-    RemoteContextMetadata, RequestType, SavedContextMetadata, Split, ToggleFocus,
-    ToggleModelSelector,
+    InsertIntoEditor, InvokedSlashCommandStatus, Message, MessageId, MessageMetadata,
+    MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, ParsedSlashCommand,
+    PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, RequestType,
+    SavedContextMetadata, SlashCommandId, Split, ToggleFocus, ToggleModelSelector,
 };
 use anyhow::Result;
 use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
 use assistant_tool::ToolRegistry;
 use client::{proto, zed_urls, Client, Status};
-use collections::{BTreeSet, HashMap, HashSet};
+use collections::{hash_map, BTreeSet, HashMap, HashSet};
 use editor::{
     actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
     display_map::{
@@ -38,12 +38,12 @@ use editor::{display_map::CreaseId, FoldPlaceholder};
 use fs::Fs;
 use futures::FutureExt;
 use gpui::{
-    canvas, div, img, percentage, point, pulsating_between, size, Action, Animation, AnimationExt,
-    AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry, ClipboardItem,
-    CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
-    FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, Render, RenderImage,
-    SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task, Transformation,
-    UpdateGlobal, View, VisualContext, WeakView, WindowContext,
+    canvas, div, img, percentage, point, prelude::*, pulsating_between, size, Action, Animation,
+    AnimationExt, AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry,
+    ClipboardItem, CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle,
+    FocusableView, FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels,
+    Render, RenderImage, SharedString, Size, StatefulInteractiveElement, Styled, Subscription,
+    Task, Transformation, UpdateGlobal, View, WeakModel, WeakView,
 };
 use indexed_docs::IndexedDocsStore;
 use language::{
@@ -77,8 +77,8 @@ use text::SelectionGoal;
 use ui::{
     prelude::*,
     utils::{format_distance_from_now, DateTimeType},
-    Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem,
-    ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
+    Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, IconButtonShape, KeyBinding,
+    ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
 };
 use util::{maybe, ResultExt};
 use workspace::{
@@ -1477,7 +1477,7 @@ pub struct ContextEditor {
     scroll_position: Option<ScrollPosition>,
     remote_id: Option<workspace::ViewId>,
     pending_slash_command_creases: HashMap<Range<language::Anchor>, CreaseId>,
-    pending_slash_command_blocks: HashMap<Range<language::Anchor>, CustomBlockId>,
+    invoked_slash_command_creases: HashMap<SlashCommandId, CreaseId>,
     pending_tool_use_creases: HashMap<Range<language::Anchor>, CreaseId>,
     _subscriptions: Vec<Subscription>,
     patches: HashMap<Range<language::Anchor>, PatchViewState>,
@@ -1548,7 +1548,7 @@ impl ContextEditor {
             workspace,
             project,
             pending_slash_command_creases: HashMap::default(),
-            pending_slash_command_blocks: HashMap::default(),
+            invoked_slash_command_creases: HashMap::default(),
             pending_tool_use_creases: HashMap::default(),
             _subscriptions,
             patches: HashMap::default(),
@@ -1573,14 +1573,13 @@ impl ContextEditor {
         });
         let command = self.context.update(cx, |context, cx| {
             context.reparse(cx);
-            context.pending_slash_commands()[0].clone()
+            context.parsed_slash_commands()[0].clone()
         });
         self.run_command(
             command.source_range,
             &command.name,
             &command.arguments,
             false,
-            false,
             self.workspace.clone(),
             cx,
         );
@@ -1753,7 +1752,6 @@ impl ContextEditor {
                     &command.name,
                     &command.arguments,
                     true,
-                    false,
                     workspace.clone(),
                     cx,
                 );
@@ -1769,7 +1767,6 @@ impl ContextEditor {
         name: &str,
         arguments: &[String],
         ensure_trailing_newline: bool,
-        expand_result: bool,
         workspace: WeakView<Workspace>,
         cx: &mut ViewContext<Self>,
     ) {
@@ -1793,9 +1790,9 @@ impl ContextEditor {
             self.context.update(cx, |context, cx| {
                 context.insert_command_output(
                     command_range,
+                    name,
                     output,
                     ensure_trailing_newline,
-                    expand_result,
                     cx,
                 )
             });
@@ -1865,8 +1862,7 @@ impl ContextEditor {
                                     IconName::PocketKnife,
                                     tool_use.name.clone().into(),
                                 ),
-                                constrain_width: false,
-                                merge_adjacent: false,
+                                ..Default::default()
                             };
                             let render_trailer =
                                 move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
@@ -1921,11 +1917,10 @@ impl ContextEditor {
             ContextEvent::PatchesUpdated { removed, updated } => {
                 self.patches_updated(removed, updated, cx);
             }
-            ContextEvent::PendingSlashCommandsUpdated { removed, updated } => {
+            ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => {
                 self.editor.update(cx, |editor, cx| {
                     let buffer = editor.buffer().read(cx).snapshot(cx);
-                    let (excerpt_id, buffer_id, _) = buffer.as_singleton().unwrap();
-                    let excerpt_id = *excerpt_id;
+                    let (&excerpt_id, _, _) = buffer.as_singleton().unwrap();
 
                     editor.remove_creases(
                         removed
@@ -1934,16 +1929,6 @@ impl ContextEditor {
                         cx,
                     );
 
-                    editor.remove_blocks(
-                        HashSet::from_iter(
-                            removed.iter().filter_map(|range| {
-                                self.pending_slash_command_blocks.remove(range)
-                            }),
-                        ),
-                        None,
-                        cx,
-                    );
-
                     let crease_ids = editor.insert_creases(
                         updated.iter().map(|command| {
                             let workspace = self.workspace.clone();
@@ -1958,7 +1943,6 @@ impl ContextEditor {
                                                 &command.name,
                                                 &command.arguments,
                                                 false,
-                                                false,
                                                 workspace.clone(),
                                                 cx,
                                             );
@@ -1968,8 +1952,7 @@ impl ContextEditor {
                             });
                             let placeholder = FoldPlaceholder {
                                 render: Arc::new(move |_, _, _| Empty.into_any()),
-                                constrain_width: false,
-                                merge_adjacent: false,
+                                ..Default::default()
                             };
                             let render_toggle = {
                                 let confirm_command = confirm_command.clone();
@@ -2011,62 +1994,29 @@ impl ContextEditor {
                         cx,
                     );
 
-                    let block_ids = editor.insert_blocks(
-                        updated
-                            .iter()
-                            .filter_map(|command| match &command.status {
-                                PendingSlashCommandStatus::Error(error) => {
-                                    Some((command, error.clone()))
-                                }
-                                _ => None,
-                            })
-                            .map(|(command, error_message)| BlockProperties {
-                                style: BlockStyle::Fixed,
-                                height: 1,
-                                placement: BlockPlacement::Below(Anchor {
-                                    buffer_id: Some(buffer_id),
-                                    excerpt_id,
-                                    text_anchor: command.source_range.start,
-                                }),
-                                render: slash_command_error_block_renderer(error_message),
-                                priority: 0,
-                            }),
-                        None,
-                        cx,
-                    );
-
                     self.pending_slash_command_creases.extend(
                         updated
                             .iter()
                             .map(|command| command.source_range.clone())
                             .zip(crease_ids),
                     );
-
-                    self.pending_slash_command_blocks.extend(
-                        updated
-                            .iter()
-                            .map(|command| command.source_range.clone())
-                            .zip(block_ids),
-                    );
                 })
             }
+            ContextEvent::InvokedSlashCommandChanged { command_id } => {
+                self.update_invoked_slash_command(*command_id, cx);
+            }
+            ContextEvent::SlashCommandOutputSectionAdded { section } => {
+                self.insert_slash_command_output_sections([section.clone()], false, cx);
+            }
             ContextEvent::SlashCommandFinished {
-                output_range,
-                sections,
-                run_commands_in_output,
-                expand_result,
+                output_range: _output_range,
+                run_commands_in_ranges,
             } => {
-                self.insert_slash_command_output_sections(
-                    sections.iter().cloned(),
-                    *expand_result,
-                    cx,
-                );
-
-                if *run_commands_in_output {
+                for range in run_commands_in_ranges {
                     let commands = self.context.update(cx, |context, cx| {
                         context.reparse(cx);
                         context
-                            .pending_commands_for_range(output_range.clone(), cx)
+                            .pending_commands_for_range(range.clone(), cx)
                             .to_vec()
                     });
 
@@ -2076,7 +2026,6 @@ impl ContextEditor {
                             &command.name,
                             &command.arguments,
                             false,
-                            false,
                             self.workspace.clone(),
                             cx,
                         );
@@ -2119,8 +2068,7 @@ impl ContextEditor {
                             IconName::PocketKnife,
                             format!("Tool Result: {tool_use_id}").into(),
                         ),
-                        constrain_width: false,
-                        merge_adjacent: false,
+                        ..Default::default()
                     };
                     let render_trailer =
                         move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
@@ -2158,6 +2106,77 @@ impl ContextEditor {
         }
     }
 
+    fn update_invoked_slash_command(
+        &mut self,
+        command_id: SlashCommandId,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let context_editor = cx.view().downgrade();
+        self.editor.update(cx, |editor, cx| {
+            if let Some(invoked_slash_command) =
+                self.context.read(cx).invoked_slash_command(&command_id)
+            {
+                if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+                    let (&excerpt_id, _buffer_id, _buffer_snapshot) =
+                        buffer.as_singleton().unwrap();
+
+                    let start = buffer
+                        .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start)
+                        .unwrap();
+                    let end = buffer
+                        .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end)
+                        .unwrap();
+                    editor.remove_folds_with_type(
+                        &[start..end],
+                        TypeId::of::<PendingSlashCommand>(),
+                        false,
+                        cx,
+                    );
+
+                    editor.remove_creases(
+                        HashSet::from_iter(self.invoked_slash_command_creases.remove(&command_id)),
+                        cx,
+                    );
+                } else if let hash_map::Entry::Vacant(entry) =
+                    self.invoked_slash_command_creases.entry(command_id)
+                {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+                    let (&excerpt_id, _buffer_id, _buffer_snapshot) =
+                        buffer.as_singleton().unwrap();
+                    let context = self.context.downgrade();
+                    let crease_start = buffer
+                        .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start)
+                        .unwrap();
+                    let crease_end = buffer
+                        .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end)
+                        .unwrap();
+                    let fold_placeholder =
+                        invoked_slash_command_fold_placeholder(command_id, context, context_editor);
+                    let crease_ids = editor.insert_creases(
+                        [Crease::new(
+                            crease_start..crease_end,
+                            fold_placeholder.clone(),
+                            fold_toggle("invoked-slash-command"),
+                            |_row, _folded, _cx| Empty.into_any(),
+                        )],
+                        cx,
+                    );
+                    editor.fold_ranges([(crease_start..crease_end, fold_placeholder)], false, cx);
+                    entry.insert(crease_ids[0]);
+                } else {
+                    cx.notify()
+                }
+            } else {
+                editor.remove_creases(
+                    HashSet::from_iter(self.invoked_slash_command_creases.remove(&command_id)),
+                    cx,
+                );
+                cx.notify();
+            };
+        });
+    }
+
     fn patches_updated(
         &mut self,
         removed: &Vec<Range<text::Anchor>>,
@@ -2229,8 +2248,7 @@ impl ContextEditor {
                             .unwrap_or_else(|| Empty.into_any())
                         })
                     },
-                    constrain_width: false,
-                    merge_adjacent: false,
+                    ..Default::default()
                 };
 
                 let should_refold;
@@ -2288,7 +2306,7 @@ impl ContextEditor {
                 }
 
                 if should_refold {
-                    editor.unfold_ranges([patch_start..patch_end], true, false, cx);
+                    editor.unfold_ranges(&[patch_start..patch_end], true, false, cx);
                     editor.fold_ranges([(patch_start..patch_end, header_placeholder)], false, cx);
                 }
             }
@@ -2334,8 +2352,7 @@ impl ContextEditor {
                                 section.icon,
                                 section.label.clone(),
                             ),
-                            constrain_width: false,
-                            merge_adjacent: false,
+                            ..Default::default()
                         },
                         render_slash_command_output_toggle,
                         |_, _, _| Empty.into_any_element(),
@@ -3275,13 +3292,12 @@ impl ContextEditor {
                             Crease::new(
                                 start..end,
                                 FoldPlaceholder {
-                                    constrain_width: false,
                                     render: render_fold_icon_button(
                                         weak_editor.clone(),
                                         metadata.crease.icon,
                                         metadata.crease.label.clone(),
                                     ),
-                                    merge_adjacent: false,
+                                    ..Default::default()
                                 },
                                 render_slash_command_output_toggle,
                                 |_, _, _| Empty.into_any(),
@@ -4947,8 +4963,7 @@ fn quote_selection_fold_placeholder(title: String, editor: WeakView<Editor>) ->
                     .into_any_element()
             }
         }),
-        constrain_width: false,
-        merge_adjacent: false,
+        ..Default::default()
     }
 }
 
@@ -4992,7 +5007,7 @@ fn render_pending_slash_command_gutter_decoration(
 
 fn render_docs_slash_command_trailer(
     row: MultiBufferRow,
-    command: PendingSlashCommand,
+    command: ParsedSlashCommand,
     cx: &mut WindowContext,
 ) -> AnyElement {
     if command.arguments.is_empty() {
@@ -5076,17 +5091,78 @@ fn make_lsp_adapter_delegate(
     })
 }
 
-fn slash_command_error_block_renderer(message: String) -> RenderBlock {
-    Box::new(move |_| {
-        div()
-            .pl_6()
-            .child(
-                Label::new(format!("error: {}", message))
-                    .single_line()
-                    .color(Color::Error),
-            )
-            .into_any()
-    })
+enum PendingSlashCommand {}
+
+fn invoked_slash_command_fold_placeholder(
+    command_id: SlashCommandId,
+    context: WeakModel<Context>,
+    context_editor: WeakView<ContextEditor>,
+) -> FoldPlaceholder {
+    FoldPlaceholder {
+        constrain_width: false,
+        merge_adjacent: false,
+        render: Arc::new(move |fold_id, _, cx| {
+            let Some(context) = context.upgrade() else {
+                return Empty.into_any();
+            };
+
+            let Some(command) = context.read(cx).invoked_slash_command(&command_id) else {
+                return Empty.into_any();
+            };
+
+            h_flex()
+                .id(fold_id)
+                .px_1()
+                .ml_6()
+                .gap_2()
+                .bg(cx.theme().colors().surface_background)
+                .rounded_md()
+                .child(Label::new(format!("/{}", command.name.clone())))
+                .map(|parent| match &command.status {
+                    InvokedSlashCommandStatus::Running(_) => {
+                        parent.child(Icon::new(IconName::ArrowCircle).with_animation(
+                            "arrow-circle",
+                            Animation::new(Duration::from_secs(4)).repeat(),
+                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                        ))
+                    }
+                    InvokedSlashCommandStatus::Error(message) => parent
+                        .child(
+                            Label::new(format!("error: {message}"))
+                                .single_line()
+                                .color(Color::Error),
+                        )
+                        .child(
+                            IconButton::new("dismiss-error", IconName::Close)
+                                .shape(IconButtonShape::Square)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Muted)
+                                .on_click({
+                                    let context_editor = context_editor.clone();
+                                    move |_event, cx| {
+                                        context_editor
+                                            .update(cx, |context_editor, cx| {
+                                                context_editor.editor.update(cx, |editor, cx| {
+                                                    editor.remove_creases(
+                                                        HashSet::from_iter(
+                                                            context_editor
+                                                                .invoked_slash_command_creases
+                                                                .remove(&command_id),
+                                                        ),
+                                                        cx,
+                                                    );
+                                                })
+                                            })
+                                            .log_err();
+                                    }
+                                }),
+                        ),
+                    InvokedSlashCommandStatus::Finished => parent,
+                })
+                .into_any_element()
+        }),
+        type_tag: Some(TypeId::of::<PendingSlashCommand>()),
+    }
 }
 
 enum TokenState {

crates/assistant/src/context.rs 🔗

@@ -8,7 +8,8 @@ use crate::{
 };
 use anyhow::{anyhow, Context as _, Result};
 use assistant_slash_command::{
-    SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
+    SlashCommandContent, SlashCommandEvent, SlashCommandOutputSection, SlashCommandRegistry,
+    SlashCommandResult,
 };
 use assistant_tool::ToolRegistry;
 use client::{self, proto, telemetry::Telemetry};
@@ -47,9 +48,10 @@ use std::{
     time::{Duration, Instant},
 };
 use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
-use text::BufferSnapshot;
+use text::{BufferSnapshot, ToPoint};
 use util::{post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
+use workspace::ui::IconName;
 
 #[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ContextId(String);
@@ -92,10 +94,21 @@ pub enum ContextOperation {
         summary: ContextSummary,
         version: clock::Global,
     },
-    SlashCommandFinished {
+    SlashCommandStarted {
         id: SlashCommandId,
         output_range: Range<language::Anchor>,
-        sections: Vec<SlashCommandOutputSection<language::Anchor>>,
+        name: String,
+        version: clock::Global,
+    },
+    SlashCommandFinished {
+        id: SlashCommandId,
+        timestamp: clock::Lamport,
+        error_message: Option<String>,
+        version: clock::Global,
+    },
+    SlashCommandOutputSectionAdded {
+        timestamp: clock::Lamport,
+        section: SlashCommandOutputSection<language::Anchor>,
         version: clock::Global,
     },
     BufferOperation(language::Operation),
@@ -152,31 +165,47 @@ impl ContextOperation {
                 },
                 version: language::proto::deserialize_version(&update.version),
             }),
-            proto::context_operation::Variant::SlashCommandFinished(finished) => {
-                Ok(Self::SlashCommandFinished {
+            proto::context_operation::Variant::SlashCommandStarted(message) => {
+                Ok(Self::SlashCommandStarted {
                     id: SlashCommandId(language::proto::deserialize_timestamp(
-                        finished.id.context("invalid id")?,
+                        message.id.context("invalid id")?,
                     )),
                     output_range: language::proto::deserialize_anchor_range(
-                        finished.output_range.context("invalid range")?,
+                        message.output_range.context("invalid range")?,
                     )?,
-                    sections: finished
-                        .sections
-                        .into_iter()
-                        .map(|section| {
-                            Ok(SlashCommandOutputSection {
-                                range: language::proto::deserialize_anchor_range(
-                                    section.range.context("invalid range")?,
-                                )?,
-                                icon: section.icon_name.parse()?,
-                                label: section.label.into(),
-                                metadata: section
-                                    .metadata
-                                    .and_then(|metadata| serde_json::from_str(&metadata).log_err()),
-                            })
-                        })
-                        .collect::<Result<Vec<_>>>()?,
-                    version: language::proto::deserialize_version(&finished.version),
+                    name: message.name,
+                    version: language::proto::deserialize_version(&message.version),
+                })
+            }
+            proto::context_operation::Variant::SlashCommandOutputSectionAdded(message) => {
+                let section = message.section.context("missing section")?;
+                Ok(Self::SlashCommandOutputSectionAdded {
+                    timestamp: language::proto::deserialize_timestamp(
+                        message.timestamp.context("missing timestamp")?,
+                    ),
+                    section: SlashCommandOutputSection {
+                        range: language::proto::deserialize_anchor_range(
+                            section.range.context("invalid range")?,
+                        )?,
+                        icon: section.icon_name.parse()?,
+                        label: section.label.into(),
+                        metadata: section
+                            .metadata
+                            .and_then(|metadata| serde_json::from_str(&metadata).log_err()),
+                    },
+                    version: language::proto::deserialize_version(&message.version),
+                })
+            }
+            proto::context_operation::Variant::SlashCommandCompleted(message) => {
+                Ok(Self::SlashCommandFinished {
+                    id: SlashCommandId(language::proto::deserialize_timestamp(
+                        message.id.context("invalid id")?,
+                    )),
+                    timestamp: language::proto::deserialize_timestamp(
+                        message.timestamp.context("missing timestamp")?,
+                    ),
+                    error_message: message.error_message,
+                    version: language::proto::deserialize_version(&message.version),
                 })
             }
             proto::context_operation::Variant::BufferOperation(op) => Ok(Self::BufferOperation(
@@ -231,21 +260,33 @@ impl ContextOperation {
                     },
                 )),
             },
-            Self::SlashCommandFinished {
+            Self::SlashCommandStarted {
                 id,
                 output_range,
-                sections,
+                name,
                 version,
             } => proto::ContextOperation {
-                variant: Some(proto::context_operation::Variant::SlashCommandFinished(
-                    proto::context_operation::SlashCommandFinished {
+                variant: Some(proto::context_operation::Variant::SlashCommandStarted(
+                    proto::context_operation::SlashCommandStarted {
                         id: Some(language::proto::serialize_timestamp(id.0)),
                         output_range: Some(language::proto::serialize_anchor_range(
                             output_range.clone(),
                         )),
-                        sections: sections
-                            .iter()
-                            .map(|section| {
+                        name: name.clone(),
+                        version: language::proto::serialize_version(version),
+                    },
+                )),
+            },
+            Self::SlashCommandOutputSectionAdded {
+                timestamp,
+                section,
+                version,
+            } => proto::ContextOperation {
+                variant: Some(
+                    proto::context_operation::Variant::SlashCommandOutputSectionAdded(
+                        proto::context_operation::SlashCommandOutputSectionAdded {
+                            timestamp: Some(language::proto::serialize_timestamp(*timestamp)),
+                            section: Some({
                                 let icon_name: &'static str = section.icon.into();
                                 proto::SlashCommandOutputSection {
                                     range: Some(language::proto::serialize_anchor_range(
@@ -257,8 +298,23 @@ impl ContextOperation {
                                         serde_json::to_string(metadata).log_err()
                                     }),
                                 }
-                            })
-                            .collect(),
+                            }),
+                            version: language::proto::serialize_version(version),
+                        },
+                    ),
+                ),
+            },
+            Self::SlashCommandFinished {
+                id,
+                timestamp,
+                error_message,
+                version,
+            } => proto::ContextOperation {
+                variant: Some(proto::context_operation::Variant::SlashCommandCompleted(
+                    proto::context_operation::SlashCommandCompleted {
+                        id: Some(language::proto::serialize_timestamp(id.0)),
+                        timestamp: Some(language::proto::serialize_timestamp(*timestamp)),
+                        error_message: error_message.clone(),
                         version: language::proto::serialize_version(version),
                     },
                 )),
@@ -278,7 +334,9 @@ impl ContextOperation {
             Self::InsertMessage { anchor, .. } => anchor.id.0,
             Self::UpdateMessage { metadata, .. } => metadata.timestamp,
             Self::UpdateSummary { summary, .. } => summary.timestamp,
-            Self::SlashCommandFinished { id, .. } => id.0,
+            Self::SlashCommandStarted { id, .. } => id.0,
+            Self::SlashCommandOutputSectionAdded { timestamp, .. }
+            | Self::SlashCommandFinished { timestamp, .. } => *timestamp,
             Self::BufferOperation(_) => {
                 panic!("reading the timestamp of a buffer operation is not supported")
             }
@@ -291,6 +349,8 @@ impl ContextOperation {
             Self::InsertMessage { version, .. }
             | Self::UpdateMessage { version, .. }
             | Self::UpdateSummary { version, .. }
+            | Self::SlashCommandStarted { version, .. }
+            | Self::SlashCommandOutputSectionAdded { version, .. }
             | Self::SlashCommandFinished { version, .. } => version,
             Self::BufferOperation(_) => {
                 panic!("reading the version of a buffer operation is not supported")
@@ -311,15 +371,19 @@ pub enum ContextEvent {
         removed: Vec<Range<language::Anchor>>,
         updated: Vec<Range<language::Anchor>>,
     },
-    PendingSlashCommandsUpdated {
+    InvokedSlashCommandChanged {
+        command_id: SlashCommandId,
+    },
+    ParsedSlashCommandsUpdated {
         removed: Vec<Range<language::Anchor>>,
-        updated: Vec<PendingSlashCommand>,
+        updated: Vec<ParsedSlashCommand>,
+    },
+    SlashCommandOutputSectionAdded {
+        section: SlashCommandOutputSection<language::Anchor>,
     },
     SlashCommandFinished {
         output_range: Range<language::Anchor>,
-        sections: Vec<SlashCommandOutputSection<language::Anchor>>,
-        run_commands_in_output: bool,
-        expand_result: bool,
+        run_commands_in_ranges: Vec<Range<language::Anchor>>,
     },
     UsePendingTools,
     ToolFinished {
@@ -478,7 +542,8 @@ pub struct Context {
     pending_ops: Vec<ContextOperation>,
     operations: Vec<ContextOperation>,
     buffer: Model<Buffer>,
-    pending_slash_commands: Vec<PendingSlashCommand>,
+    parsed_slash_commands: Vec<ParsedSlashCommand>,
+    invoked_slash_commands: HashMap<SlashCommandId, InvokedSlashCommand>,
     edits_since_last_parse: language::Subscription,
     finished_slash_commands: HashSet<SlashCommandId>,
     slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>,
@@ -508,7 +573,7 @@ trait ContextAnnotation {
     fn range(&self) -> &Range<language::Anchor>;
 }
 
-impl ContextAnnotation for PendingSlashCommand {
+impl ContextAnnotation for ParsedSlashCommand {
     fn range(&self) -> &Range<language::Anchor> {
         &self.source_range
     }
@@ -580,7 +645,8 @@ impl Context {
             message_anchors: Default::default(),
             contents: Default::default(),
             messages_metadata: Default::default(),
-            pending_slash_commands: Vec::new(),
+            parsed_slash_commands: Vec::new(),
+            invoked_slash_commands: HashMap::default(),
             finished_slash_commands: HashSet::default(),
             pending_tool_uses_by_id: HashMap::default(),
             slash_command_output_sections: Vec::new(),
@@ -827,24 +893,50 @@ impl Context {
                         summary_changed = true;
                     }
                 }
-                ContextOperation::SlashCommandFinished {
+                ContextOperation::SlashCommandStarted {
                     id,
                     output_range,
-                    sections,
+                    name,
                     ..
                 } => {
-                    if self.finished_slash_commands.insert(id) {
-                        let buffer = self.buffer.read(cx);
-                        self.slash_command_output_sections
-                            .extend(sections.iter().cloned());
+                    self.invoked_slash_commands.insert(
+                        id,
+                        InvokedSlashCommand {
+                            name: name.into(),
+                            range: output_range,
+                            status: InvokedSlashCommandStatus::Running(Task::ready(())),
+                        },
+                    );
+                    cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
+                }
+                ContextOperation::SlashCommandOutputSectionAdded { section, .. } => {
+                    let buffer = self.buffer.read(cx);
+                    if let Err(ix) = self
+                        .slash_command_output_sections
+                        .binary_search_by(|probe| probe.range.cmp(&section.range, buffer))
+                    {
                         self.slash_command_output_sections
-                            .sort_by(|a, b| a.range.cmp(&b.range, buffer));
-                        cx.emit(ContextEvent::SlashCommandFinished {
-                            output_range,
-                            sections,
-                            expand_result: false,
-                            run_commands_in_output: false,
-                        });
+                            .insert(ix, section.clone());
+                        cx.emit(ContextEvent::SlashCommandOutputSectionAdded { section });
+                    }
+                }
+                ContextOperation::SlashCommandFinished {
+                    id, error_message, ..
+                } => {
+                    if self.finished_slash_commands.insert(id) {
+                        if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) {
+                            match error_message {
+                                Some(message) => {
+                                    slash_command.status =
+                                        InvokedSlashCommandStatus::Error(message.into());
+                                }
+                                None => {
+                                    slash_command.status = InvokedSlashCommandStatus::Finished;
+                                }
+                            }
+                        }
+
+                        cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
                     }
                 }
                 ContextOperation::BufferOperation(_) => unreachable!(),
@@ -882,32 +974,34 @@ impl Context {
                 self.messages_metadata.contains_key(message_id)
             }
             ContextOperation::UpdateSummary { .. } => true,
-            ContextOperation::SlashCommandFinished {
-                output_range,
-                sections,
-                ..
-            } => {
-                let version = &self.buffer.read(cx).version;
-                sections
-                    .iter()
-                    .map(|section| &section.range)
-                    .chain([output_range])
-                    .all(|range| {
-                        let observed_start = range.start == language::Anchor::MIN
-                            || range.start == language::Anchor::MAX
-                            || version.observed(range.start.timestamp);
-                        let observed_end = range.end == language::Anchor::MIN
-                            || range.end == language::Anchor::MAX
-                            || version.observed(range.end.timestamp);
-                        observed_start && observed_end
-                    })
+            ContextOperation::SlashCommandStarted { output_range, .. } => {
+                self.has_received_operations_for_anchor_range(output_range.clone(), cx)
             }
+            ContextOperation::SlashCommandOutputSectionAdded { section, .. } => {
+                self.has_received_operations_for_anchor_range(section.range.clone(), cx)
+            }
+            ContextOperation::SlashCommandFinished { .. } => true,
             ContextOperation::BufferOperation(_) => {
                 panic!("buffer operations should always be applied")
             }
         }
     }
 
+    fn has_received_operations_for_anchor_range(
+        &self,
+        range: Range<text::Anchor>,
+        cx: &AppContext,
+    ) -> bool {
+        let version = &self.buffer.read(cx).version;
+        let observed_start = range.start == language::Anchor::MIN
+            || range.start == language::Anchor::MAX
+            || version.observed(range.start.timestamp);
+        let observed_end = range.end == language::Anchor::MIN
+            || range.end == language::Anchor::MAX
+            || version.observed(range.end.timestamp);
+        observed_start && observed_end
+    }
+
     fn push_op(&mut self, op: ContextOperation, cx: &mut ModelContext<Self>) {
         self.operations.push(op.clone());
         cx.emit(ContextEvent::Operation(op));
@@ -983,8 +1077,15 @@ impl Context {
             .binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer))
     }
 
-    pub fn pending_slash_commands(&self) -> &[PendingSlashCommand] {
-        &self.pending_slash_commands
+    pub fn parsed_slash_commands(&self) -> &[ParsedSlashCommand] {
+        &self.parsed_slash_commands
+    }
+
+    pub fn invoked_slash_command(
+        &self,
+        command_id: &SlashCommandId,
+    ) -> Option<&InvokedSlashCommand> {
+        self.invoked_slash_commands.get(command_id)
     }
 
     pub fn slash_command_output_sections(&self) -> &[SlashCommandOutputSection<language::Anchor>] {
@@ -1306,7 +1407,7 @@ impl Context {
         }
 
         if !updated_slash_commands.is_empty() || !removed_slash_command_ranges.is_empty() {
-            cx.emit(ContextEvent::PendingSlashCommandsUpdated {
+            cx.emit(ContextEvent::ParsedSlashCommandsUpdated {
                 removed: removed_slash_command_ranges,
                 updated: updated_slash_commands,
             });
@@ -1324,7 +1425,7 @@ impl Context {
         &mut self,
         range: Range<text::Anchor>,
         buffer: &BufferSnapshot,
-        updated: &mut Vec<PendingSlashCommand>,
+        updated: &mut Vec<ParsedSlashCommand>,
         removed: &mut Vec<Range<text::Anchor>>,
         cx: &AppContext,
     ) {
@@ -1358,7 +1459,7 @@ impl Context {
                                 .map_or(command_line.name.end, |argument| argument.end);
                         let source_range =
                             buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix);
-                        let pending_command = PendingSlashCommand {
+                        let pending_command = ParsedSlashCommand {
                             name: name.to_string(),
                             arguments,
                             source_range,
@@ -1373,7 +1474,7 @@ impl Context {
             offset = lines.offset();
         }
 
-        let removed_commands = self.pending_slash_commands.splice(old_range, new_commands);
+        let removed_commands = self.parsed_slash_commands.splice(old_range, new_commands);
         removed.extend(removed_commands.map(|command| command.source_range));
     }
 
@@ -1642,15 +1743,15 @@ impl Context {
         &mut self,
         position: language::Anchor,
         cx: &mut ModelContext<Self>,
-    ) -> Option<&mut PendingSlashCommand> {
+    ) -> Option<&mut ParsedSlashCommand> {
         let buffer = self.buffer.read(cx);
         match self
-            .pending_slash_commands
+            .parsed_slash_commands
             .binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer))
         {
-            Ok(ix) => Some(&mut self.pending_slash_commands[ix]),
+            Ok(ix) => Some(&mut self.parsed_slash_commands[ix]),
             Err(ix) => {
-                let cmd = self.pending_slash_commands.get_mut(ix)?;
+                let cmd = self.parsed_slash_commands.get_mut(ix)?;
                 if position.cmp(&cmd.source_range.start, buffer).is_ge()
                     && position.cmp(&cmd.source_range.end, buffer).is_le()
                 {
@@ -1666,9 +1767,9 @@ impl Context {
         &self,
         range: Range<language::Anchor>,
         cx: &AppContext,
-    ) -> &[PendingSlashCommand] {
+    ) -> &[ParsedSlashCommand] {
         let range = self.pending_command_indices_for_range(range, cx);
-        &self.pending_slash_commands[range]
+        &self.parsed_slash_commands[range]
     }
 
     fn pending_command_indices_for_range(
@@ -1676,7 +1777,7 @@ impl Context {
         range: Range<language::Anchor>,
         cx: &AppContext,
     ) -> Range<usize> {
-        self.indices_intersecting_buffer_range(&self.pending_slash_commands, range, cx)
+        self.indices_intersecting_buffer_range(&self.parsed_slash_commands, range, cx)
     }
 
     fn indices_intersecting_buffer_range<T: ContextAnnotation>(
@@ -1702,112 +1803,275 @@ impl Context {
 
     pub fn insert_command_output(
         &mut self,
-        command_range: Range<language::Anchor>,
+        command_source_range: Range<language::Anchor>,
+        name: &str,
         output: Task<SlashCommandResult>,
         ensure_trailing_newline: bool,
-        expand_result: bool,
         cx: &mut ModelContext<Self>,
     ) {
+        let version = self.version.clone();
+        let command_id = SlashCommandId(self.next_timestamp());
+
+        const PENDING_OUTPUT_END_MARKER: &str = "…";
+
+        let (command_range, command_source_range, insert_position) =
+            self.buffer.update(cx, |buffer, cx| {
+                let command_source_range = command_source_range.to_offset(buffer);
+                let mut insertion = format!("\n{PENDING_OUTPUT_END_MARKER}");
+                if ensure_trailing_newline {
+                    insertion.push('\n');
+                }
+                buffer.edit(
+                    [(
+                        command_source_range.end..command_source_range.end,
+                        insertion,
+                    )],
+                    None,
+                    cx,
+                );
+                let insert_position = buffer.anchor_after(command_source_range.end + 1);
+                let command_range = buffer.anchor_before(command_source_range.start)
+                    ..buffer.anchor_after(
+                        command_source_range.end + 1 + PENDING_OUTPUT_END_MARKER.len(),
+                    );
+                let command_source_range = buffer.anchor_before(command_source_range.start)
+                    ..buffer.anchor_before(command_source_range.end + 1);
+                (command_range, command_source_range, insert_position)
+            });
         self.reparse(cx);
 
-        let insert_output_task = cx.spawn(|this, mut cx| {
-            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) => {
-                        output.ensure_valid_section_ranges();
+        let insert_output_task = cx.spawn(|this, mut cx| async move {
+            let run_command = async {
+                let mut stream = output.await?;
 
-                        // Ensure there is a newline after the last section.
-                        if ensure_trailing_newline {
-                            let has_newline_after_last_section =
-                                output.sections.last().map_or(false, |last_section| {
-                                    output.text[last_section.range.end..].ends_with('\n')
+                struct PendingSection {
+                    start: language::Anchor,
+                    icon: IconName,
+                    label: SharedString,
+                    metadata: Option<serde_json::Value>,
+                }
+
+                let mut pending_section_stack: Vec<PendingSection> = Vec::new();
+                let mut run_commands_in_ranges: Vec<Range<language::Anchor>> = Vec::new();
+                let mut last_role: Option<Role> = None;
+                let mut last_section_range = None;
+
+                while let Some(event) = stream.next().await {
+                    let event = event?;
+                    match event {
+                        SlashCommandEvent::StartMessage {
+                            role,
+                            merge_same_roles,
+                        } => {
+                            if !merge_same_roles && Some(role) != last_role {
+                                this.update(&mut cx, |this, cx| {
+                                    let offset = this.buffer.read_with(cx, |buffer, _cx| {
+                                        insert_position.to_offset(buffer)
+                                    });
+                                    this.insert_message_at_offset(
+                                        offset,
+                                        role,
+                                        MessageStatus::Pending,
+                                        cx,
+                                    );
+                                })?;
+                            }
+
+                            last_role = Some(role);
+                        }
+                        SlashCommandEvent::StartSection {
+                            icon,
+                            label,
+                            metadata,
+                        } => {
+                            this.update(&mut cx, |this, cx| {
+                                this.buffer.update(cx, |buffer, cx| {
+                                    let insert_point = insert_position.to_point(buffer);
+                                    if insert_point.column > 0 {
+                                        buffer.edit([(insert_point..insert_point, "\n")], None, cx);
+                                    }
+
+                                    pending_section_stack.push(PendingSection {
+                                        start: buffer.anchor_before(insert_position),
+                                        icon,
+                                        label,
+                                        metadata,
+                                    });
                                 });
-                            if !has_newline_after_last_section {
-                                output.text.push('\n');
+                            })?;
+                        }
+                        SlashCommandEvent::Content(SlashCommandContent::Text {
+                            text,
+                            run_commands_in_text,
+                        }) => {
+                            this.update(&mut cx, |this, cx| {
+                                let start = this.buffer.read(cx).anchor_before(insert_position);
+
+                                let result = this.buffer.update(cx, |buffer, cx| {
+                                    buffer.edit(
+                                        [(insert_position..insert_position, text)],
+                                        None,
+                                        cx,
+                                    )
+                                });
+
+                                let end = this.buffer.read(cx).anchor_before(insert_position);
+                                if run_commands_in_text {
+                                    run_commands_in_ranges.push(start..end);
+                                }
+
+                                result
+                            })?;
+                        }
+                        SlashCommandEvent::EndSection { metadata } => {
+                            if let Some(pending_section) = pending_section_stack.pop() {
+                                this.update(&mut cx, |this, cx| {
+                                    let offset_range = (pending_section.start..insert_position)
+                                        .to_offset(this.buffer.read(cx));
+                                    if offset_range.is_empty() {
+                                        return;
+                                    }
+
+                                    let range = this.buffer.update(cx, |buffer, _cx| {
+                                        buffer.anchor_after(offset_range.start)
+                                            ..buffer.anchor_before(offset_range.end)
+                                    });
+                                    this.insert_slash_command_output_section(
+                                        SlashCommandOutputSection {
+                                            range: range.clone(),
+                                            icon: pending_section.icon,
+                                            label: pending_section.label,
+                                            metadata: metadata.or(pending_section.metadata),
+                                        },
+                                        cx,
+                                    );
+                                    last_section_range = Some(range);
+                                })?;
                             }
                         }
+                    }
+                }
 
-                        let version = this.version.clone();
-                        let command_id = SlashCommandId(this.next_timestamp());
-                        let (operation, event) = this.buffer.update(cx, |buffer, cx| {
-                            let start = command_range.start.to_offset(buffer);
-                            let old_end = command_range.end.to_offset(buffer);
-                            let new_end = start + output.text.len();
-                            buffer.edit([(start..old_end, output.text)], None, cx);
-
-                            let mut sections = output
-                                .sections
-                                .into_iter()
-                                .map(|section| SlashCommandOutputSection {
-                                    range: buffer.anchor_after(start + section.range.start)
-                                        ..buffer.anchor_before(start + section.range.end),
-                                    icon: section.icon,
-                                    label: section.label,
-                                    metadata: section.metadata,
+                this.update(&mut cx, |this, cx| {
+                    this.buffer.update(cx, |buffer, cx| {
+                        let mut deletions = vec![(command_source_range.to_offset(buffer), "")];
+                        let insert_position = insert_position.to_offset(buffer);
+                        let command_range_end = command_range.end.to_offset(buffer);
+
+                        if buffer.contains_str_at(insert_position, PENDING_OUTPUT_END_MARKER) {
+                            deletions.push((
+                                insert_position..insert_position + PENDING_OUTPUT_END_MARKER.len(),
+                                "",
+                            ));
+                        }
+
+                        if ensure_trailing_newline
+                            && buffer.contains_str_at(command_range_end, "\n")
+                        {
+                            let newline_offset = insert_position.saturating_sub(1);
+                            if buffer.contains_str_at(newline_offset, "\n")
+                                && last_section_range.map_or(true, |last_section_range| {
+                                    !last_section_range
+                                        .to_offset(buffer)
+                                        .contains(&newline_offset)
                                 })
-                                .collect::<Vec<_>>();
-                            sections.sort_by(|a, b| a.range.cmp(&b.range, buffer));
-
-                            this.slash_command_output_sections
-                                .extend(sections.iter().cloned());
-                            this.slash_command_output_sections
-                                .sort_by(|a, b| a.range.cmp(&b.range, buffer));
-
-                            let output_range =
-                                buffer.anchor_after(start)..buffer.anchor_before(new_end);
-                            this.finished_slash_commands.insert(command_id);
-
-                            (
-                                ContextOperation::SlashCommandFinished {
-                                    id: command_id,
-                                    output_range: output_range.clone(),
-                                    sections: sections.clone(),
-                                    version,
-                                },
-                                ContextEvent::SlashCommandFinished {
-                                    output_range,
-                                    sections,
-                                    run_commands_in_output: output.run_commands_in_text,
-                                    expand_result,
-                                },
-                            )
-                        });
+                            {
+                                deletions.push((command_range_end..command_range_end + 1, ""));
+                            }
+                        }
+
+                        buffer.edit(deletions, None, cx);
+                    });
+                })?;
+
+                debug_assert!(pending_section_stack.is_empty());
+
+                anyhow::Ok(())
+            };
+
+            let command_result = run_command.await;
 
-                        this.push_op(operation, cx);
-                        cx.emit(event);
+            this.update(&mut cx, |this, cx| {
+                let version = this.version.clone();
+                let timestamp = this.next_timestamp();
+                let Some(invoked_slash_command) = this.invoked_slash_commands.get_mut(&command_id)
+                else {
+                    return;
+                };
+                let mut error_message = None;
+                match command_result {
+                    Ok(()) => {
+                        invoked_slash_command.status = InvokedSlashCommandStatus::Finished;
                     }
                     Err(error) => {
-                        if let Some(pending_command) =
-                            this.pending_command_for_position(command_range.start, cx)
-                        {
-                            pending_command.status =
-                                PendingSlashCommandStatus::Error(error.to_string());
-                            cx.emit(ContextEvent::PendingSlashCommandsUpdated {
-                                removed: vec![pending_command.source_range.clone()],
-                                updated: vec![pending_command.clone()],
-                            });
-                        }
+                        let message = error.to_string();
+                        invoked_slash_command.status =
+                            InvokedSlashCommandStatus::Error(message.clone().into());
+                        error_message = Some(message);
                     }
-                })
-                .ok();
-            }
+                }
+
+                cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
+                this.push_op(
+                    ContextOperation::SlashCommandFinished {
+                        id: command_id,
+                        timestamp,
+                        error_message,
+                        version,
+                    },
+                    cx,
+                );
+            })
+            .ok();
         });
 
-        if let Some(pending_command) = self.pending_command_for_position(command_range.start, cx) {
-            pending_command.status = PendingSlashCommandStatus::Running {
-                _task: insert_output_task.shared(),
-            };
-            cx.emit(ContextEvent::PendingSlashCommandsUpdated {
-                removed: vec![pending_command.source_range.clone()],
-                updated: vec![pending_command.clone()],
-            });
-        }
+        self.invoked_slash_commands.insert(
+            command_id,
+            InvokedSlashCommand {
+                name: name.to_string().into(),
+                range: command_range.clone(),
+                status: InvokedSlashCommandStatus::Running(insert_output_task),
+            },
+        );
+        cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
+        self.push_op(
+            ContextOperation::SlashCommandStarted {
+                id: command_id,
+                output_range: command_range,
+                name: name.to_string(),
+                version,
+            },
+            cx,
+        );
+    }
+
+    fn insert_slash_command_output_section(
+        &mut self,
+        section: SlashCommandOutputSection<language::Anchor>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let buffer = self.buffer.read(cx);
+        let insertion_ix = match self
+            .slash_command_output_sections
+            .binary_search_by(|probe| probe.range.cmp(&section.range, buffer))
+        {
+            Ok(ix) | Err(ix) => ix,
+        };
+        self.slash_command_output_sections
+            .insert(insertion_ix, section.clone());
+        cx.emit(ContextEvent::SlashCommandOutputSectionAdded {
+            section: section.clone(),
+        });
+        let version = self.version.clone();
+        let timestamp = self.next_timestamp();
+        self.push_op(
+            ContextOperation::SlashCommandOutputSectionAdded {
+                timestamp,
+                section,
+                version,
+            },
+            cx,
+        );
     }
 
     pub fn insert_tool_output(
@@ -2312,43 +2576,54 @@ impl Context {
                 next_message_ix += 1;
             }
 
-            let start = self.buffer.update(cx, |buffer, cx| {
-                let offset = self
-                    .message_anchors
-                    .get(next_message_ix)
-                    .map_or(buffer.len(), |message| {
-                        buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left)
-                    });
-                buffer.edit([(offset..offset, "\n")], None, cx);
-                buffer.anchor_before(offset + 1)
-            });
-
-            let version = self.version.clone();
-            let anchor = MessageAnchor {
-                id: MessageId(self.next_timestamp()),
-                start,
-            };
-            let metadata = MessageMetadata {
-                role,
-                status,
-                timestamp: anchor.id.0,
-                cache: None,
-            };
-            self.insert_message(anchor.clone(), metadata.clone(), cx);
-            self.push_op(
-                ContextOperation::InsertMessage {
-                    anchor: anchor.clone(),
-                    metadata,
-                    version,
-                },
-                cx,
-            );
-            Some(anchor)
+            let buffer = self.buffer.read(cx);
+            let offset = self
+                .message_anchors
+                .get(next_message_ix)
+                .map_or(buffer.len(), |message| {
+                    buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left)
+                });
+            Some(self.insert_message_at_offset(offset, role, status, cx))
         } else {
             None
         }
     }
 
+    fn insert_message_at_offset(
+        &mut self,
+        offset: usize,
+        role: Role,
+        status: MessageStatus,
+        cx: &mut ModelContext<Self>,
+    ) -> MessageAnchor {
+        let start = self.buffer.update(cx, |buffer, cx| {
+            buffer.edit([(offset..offset, "\n")], None, cx);
+            buffer.anchor_before(offset + 1)
+        });
+
+        let version = self.version.clone();
+        let anchor = MessageAnchor {
+            id: MessageId(self.next_timestamp()),
+            start,
+        };
+        let metadata = MessageMetadata {
+            role,
+            status,
+            timestamp: anchor.id.0,
+            cache: None,
+        };
+        self.insert_message(anchor.clone(), metadata.clone(), cx);
+        self.push_op(
+            ContextOperation::InsertMessage {
+                anchor: anchor.clone(),
+                metadata,
+                version,
+            },
+            cx,
+        );
+        anchor
+    }
+
     pub fn insert_content(&mut self, content: Content, cx: &mut ModelContext<Self>) {
         let buffer = self.buffer.read(cx);
         let insertion_ix = match self
@@ -2814,13 +3089,27 @@ impl ContextVersion {
 }
 
 #[derive(Debug, Clone)]
-pub struct PendingSlashCommand {
+pub struct ParsedSlashCommand {
     pub name: String,
     pub arguments: SmallVec<[String; 3]>,
     pub status: PendingSlashCommandStatus,
     pub source_range: Range<language::Anchor>,
 }
 
+#[derive(Debug)]
+pub struct InvokedSlashCommand {
+    pub name: SharedString,
+    pub range: Range<language::Anchor>,
+    pub status: InvokedSlashCommandStatus,
+}
+
+#[derive(Debug)]
+pub enum InvokedSlashCommandStatus {
+    Running(Task<()>),
+    Error(SharedString),
+    Finished,
+}
+
 #[derive(Debug, Clone)]
 pub enum PendingSlashCommandStatus {
     Idle,
@@ -2960,27 +3249,23 @@ impl SavedContext {
             version.observe(timestamp);
         }
 
-        let timestamp = next_timestamp.tick();
-        operations.push(ContextOperation::SlashCommandFinished {
-            id: SlashCommandId(timestamp),
-            output_range: language::Anchor::MIN..language::Anchor::MAX,
-            sections: self
-                .slash_command_output_sections
-                .into_iter()
-                .map(|section| {
-                    let buffer = buffer.read(cx);
-                    SlashCommandOutputSection {
-                        range: buffer.anchor_after(section.range.start)
-                            ..buffer.anchor_before(section.range.end),
-                        icon: section.icon,
-                        label: section.label,
-                        metadata: section.metadata,
-                    }
-                })
-                .collect(),
-            version: version.clone(),
-        });
-        version.observe(timestamp);
+        let buffer = buffer.read(cx);
+        for section in self.slash_command_output_sections {
+            let timestamp = next_timestamp.tick();
+            operations.push(ContextOperation::SlashCommandOutputSectionAdded {
+                timestamp,
+                section: SlashCommandOutputSection {
+                    range: buffer.anchor_after(section.range.start)
+                        ..buffer.anchor_before(section.range.end),
+                    icon: section.icon,
+                    label: section.label,
+                    metadata: section.metadata,
+                },
+                version: version.clone(),
+            });
+
+            version.observe(timestamp);
+        }
 
         let timestamp = next_timestamp.tick();
         operations.push(ContextOperation::UpdateSummary {

crates/assistant/src/context/context_tests.rs 🔗

@@ -2,14 +2,19 @@ use super::{AssistantEdit, MessageCacheMetadata};
 use crate::{
     assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus,
     Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
+    SlashCommandId,
 };
 use anyhow::Result;
 use assistant_slash_command::{
-    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
-    SlashCommandRegistry, SlashCommandResult,
+    ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput,
+    SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
 };
-use collections::HashSet;
+use collections::{HashMap, HashSet};
 use fs::FakeFs;
+use futures::{
+    channel::mpsc,
+    stream::{self, StreamExt},
+};
 use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
 use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
 use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
@@ -27,8 +32,8 @@ use std::{
     rc::Rc,
     sync::{atomic::AtomicBool, Arc},
 };
-use text::{network::Network, OffsetRangeExt as _, ReplicaId};
-use ui::{Context as _, WindowContext};
+use text::{network::Network, OffsetRangeExt as _, ReplicaId, ToOffset};
+use ui::{Context as _, IconName, WindowContext};
 use unindent::Unindent;
 use util::{
     test::{generate_marked_text, marked_text_ranges},
@@ -381,20 +386,41 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
     let context =
         cx.new_model(|cx| Context::local(registry.clone(), None, None, prompt_builder.clone(), cx));
 
-    let output_ranges = Rc::new(RefCell::new(HashSet::default()));
+    #[derive(Default)]
+    struct ContextRanges {
+        parsed_commands: HashSet<Range<language::Anchor>>,
+        command_outputs: HashMap<SlashCommandId, Range<language::Anchor>>,
+        output_sections: HashSet<Range<language::Anchor>>,
+    }
+
+    let context_ranges = Rc::new(RefCell::new(ContextRanges::default()));
     context.update(cx, |_, cx| {
         cx.subscribe(&context, {
-            let ranges = output_ranges.clone();
-            move |_, _, event, _| match event {
-                ContextEvent::PendingSlashCommandsUpdated { removed, updated } => {
-                    for range in removed {
-                        ranges.borrow_mut().remove(range);
+            let context_ranges = context_ranges.clone();
+            move |context, _, event, _| {
+                let mut context_ranges = context_ranges.borrow_mut();
+                match event {
+                    ContextEvent::InvokedSlashCommandChanged { command_id } => {
+                        let command = context.invoked_slash_command(command_id).unwrap();
+                        context_ranges
+                            .command_outputs
+                            .insert(*command_id, command.range.clone());
+                    }
+                    ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => {
+                        for range in removed {
+                            context_ranges.parsed_commands.remove(range);
+                        }
+                        for command in updated {
+                            context_ranges
+                                .parsed_commands
+                                .insert(command.source_range.clone());
+                        }
                     }
-                    for command in updated {
-                        ranges.borrow_mut().insert(command.source_range.clone());
+                    ContextEvent::SlashCommandOutputSectionAdded { section } => {
+                        context_ranges.output_sections.insert(section.range.clone());
                     }
+                    _ => {}
                 }
-                _ => {}
             }
         })
         .detach();
@@ -406,14 +432,12 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
     buffer.update(cx, |buffer, cx| {
         buffer.edit([(0..0, "/file src/lib.rs")], None, cx);
     });
-    assert_text_and_output_ranges(
+    assert_text_and_context_ranges(
         &buffer,
-        &output_ranges.borrow(),
-        "
-        «/file src/lib.rs»
-        "
-        .unindent()
-        .trim_end(),
+        &context_ranges,
+        &"
+        «/file src/lib.rs»"
+            .unindent(),
         cx,
     );
 
@@ -422,14 +446,12 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
         let edit_offset = buffer.text().find("lib.rs").unwrap();
         buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx);
     });
-    assert_text_and_output_ranges(
+    assert_text_and_context_ranges(
         &buffer,
-        &output_ranges.borrow(),
-        "
-        «/file src/main.rs»
-        "
-        .unindent()
-        .trim_end(),
+        &context_ranges,
+        &"
+        «/file src/main.rs»"
+            .unindent(),
         cx,
     );
 
@@ -442,36 +464,180 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
             cx,
         );
     });
-    assert_text_and_output_ranges(
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        /unknown src/main.rs"
+            .unindent(),
+        cx,
+    );
+
+    // Undoing the insertion of an non-existent slash command resorts the previous one.
+    buffer.update(cx, |buffer, cx| buffer.undo(cx));
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        «/file src/main.rs»"
+            .unindent(),
+        cx,
+    );
+
+    let (command_output_tx, command_output_rx) = mpsc::unbounded();
+    context.update(cx, |context, cx| {
+        let command_source_range = context.parsed_slash_commands[0].source_range.clone();
+        context.insert_command_output(
+            command_source_range,
+            "file",
+            Task::ready(Ok(command_output_rx.boxed())),
+            true,
+            cx,
+        );
+    });
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        ⟦«/file src/main.rs»
+        …⟧
+        "
+        .unindent(),
+        cx,
+    );
+
+    command_output_tx
+        .unbounded_send(Ok(SlashCommandEvent::StartSection {
+            icon: IconName::Ai,
+            label: "src/main.rs".into(),
+            metadata: None,
+        }))
+        .unwrap();
+    command_output_tx
+        .unbounded_send(Ok(SlashCommandEvent::Content("src/main.rs".into())))
+        .unwrap();
+    cx.run_until_parked();
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        ⟦«/file src/main.rs»
+        src/main.rs…⟧
+        "
+        .unindent(),
+        cx,
+    );
+
+    command_output_tx
+        .unbounded_send(Ok(SlashCommandEvent::Content("\nfn main() {}".into())))
+        .unwrap();
+    cx.run_until_parked();
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        ⟦«/file src/main.rs»
+        src/main.rs
+        fn main() {}…⟧
+        "
+        .unindent(),
+        cx,
+    );
+
+    command_output_tx
+        .unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))
+        .unwrap();
+    cx.run_until_parked();
+    assert_text_and_context_ranges(
         &buffer,
-        &output_ranges.borrow(),
+        &context_ranges,
+        &"
+        ⟦«/file src/main.rs»
+        ⟪src/main.rs
+        fn main() {}⟫…⟧
         "
-        /unknown src/main.rs
+        .unindent(),
+        cx,
+    );
+
+    drop(command_output_tx);
+    cx.run_until_parked();
+    assert_text_and_context_ranges(
+        &buffer,
+        &context_ranges,
+        &"
+        ⟦⟪src/main.rs
+        fn main() {}⟫⟧
         "
-        .unindent()
-        .trim_end(),
+        .unindent(),
         cx,
     );
 
     #[track_caller]
-    fn assert_text_and_output_ranges(
+    fn assert_text_and_context_ranges(
         buffer: &Model<Buffer>,
-        ranges: &HashSet<Range<language::Anchor>>,
+        ranges: &RefCell<ContextRanges>,
         expected_marked_text: &str,
         cx: &mut TestAppContext,
     ) {
-        let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false);
-        let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| {
-            let mut ranges = ranges
-                .iter()
-                .map(|range| range.to_offset(buffer))
-                .collect::<Vec<_>>();
-            ranges.sort_by_key(|a| a.start);
-            (buffer.text(), ranges)
+        let mut actual_marked_text = String::new();
+        buffer.update(cx, |buffer, _| {
+            struct Endpoint {
+                offset: usize,
+                marker: char,
+            }
+
+            let ranges = ranges.borrow();
+            let mut endpoints = Vec::new();
+            for range in ranges.command_outputs.values() {
+                endpoints.push(Endpoint {
+                    offset: range.start.to_offset(buffer),
+                    marker: '⟦',
+                });
+            }
+            for range in ranges.parsed_commands.iter() {
+                endpoints.push(Endpoint {
+                    offset: range.start.to_offset(buffer),
+                    marker: '«',
+                });
+            }
+            for range in ranges.output_sections.iter() {
+                endpoints.push(Endpoint {
+                    offset: range.start.to_offset(buffer),
+                    marker: '⟪',
+                });
+            }
+
+            for range in ranges.output_sections.iter() {
+                endpoints.push(Endpoint {
+                    offset: range.end.to_offset(buffer),
+                    marker: '⟫',
+                });
+            }
+            for range in ranges.parsed_commands.iter() {
+                endpoints.push(Endpoint {
+                    offset: range.end.to_offset(buffer),
+                    marker: '»',
+                });
+            }
+            for range in ranges.command_outputs.values() {
+                endpoints.push(Endpoint {
+                    offset: range.end.to_offset(buffer),
+                    marker: '⟧',
+                });
+            }
+
+            endpoints.sort_by_key(|endpoint| endpoint.offset);
+            let mut offset = 0;
+            for endpoint in endpoints {
+                actual_marked_text.extend(buffer.text_for_range(offset..endpoint.offset));
+                actual_marked_text.push(endpoint.marker);
+                offset = endpoint.offset;
+            }
+            actual_marked_text.extend(buffer.text_for_range(offset..buffer.len()));
         });
 
-        assert_eq!(actual_text, expected_text);
-        assert_eq!(actual_ranges, expected_ranges);
+        assert_eq!(actual_marked_text, expected_marked_text);
     }
 }
 
@@ -1063,44 +1229,57 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
                         offset + 1..offset + 1 + command_text.len()
                     });
 
-                    let output_len = rng.gen_range(1..=10);
                     let output_text = RandomCharIter::new(&mut rng)
                         .filter(|c| *c != '\r')
-                        .take(output_len)
+                        .take(10)
                         .collect::<String>();
 
+                    let mut events = vec![Ok(SlashCommandEvent::StartMessage {
+                        role: Role::User,
+                        merge_same_roles: true,
+                    })];
+
                     let num_sections = rng.gen_range(0..=3);
-                    let mut sections = Vec::with_capacity(num_sections);
+                    let mut section_start = 0;
                     for _ in 0..num_sections {
-                        let section_start = rng.gen_range(0..output_len);
-                        let section_end = rng.gen_range(section_start..=output_len);
-                        sections.push(SlashCommandOutputSection {
-                            range: section_start..section_end,
-                            icon: ui::IconName::Ai,
+                        let mut section_end = rng.gen_range(section_start..=output_text.len());
+                        while !output_text.is_char_boundary(section_end) {
+                            section_end += 1;
+                        }
+                        events.push(Ok(SlashCommandEvent::StartSection {
+                            icon: IconName::Ai,
                             label: "section".into(),
                             metadata: None,
-                        });
+                        }));
+                        events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                            text: output_text[section_start..section_end].to_string(),
+                            run_commands_in_text: false,
+                        })));
+                        events.push(Ok(SlashCommandEvent::EndSection { metadata: None }));
+                        section_start = section_end;
+                    }
+
+                    if section_start < output_text.len() {
+                        events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                            text: output_text[section_start..].to_string(),
+                            run_commands_in_text: false,
+                        })));
                     }
 
                     log::info!(
-                        "Context {}: insert slash command output at {:?} with {:?}",
+                        "Context {}: insert slash command output at {:?} with {:?} events",
                         context_index,
                         command_range,
-                        sections
+                        events.len()
                     );
 
                     let command_range = context.buffer.read(cx).anchor_after(command_range.start)
                         ..context.buffer.read(cx).anchor_after(command_range.end);
                     context.insert_command_output(
                         command_range,
-                        Task::ready(Ok(SlashCommandOutput {
-                            text: output_text,
-                            sections,
-                            run_commands_in_text: false,
-                        }
-                        .to_event_stream())),
+                        "/command",
+                        Task::ready(Ok(stream::iter(events).boxed())),
                         true,
-                        false,
                         cx,
                     );
                 });

crates/assistant/src/slash_command.rs 🔗

@@ -127,7 +127,6 @@ impl SlashCommandCompletionProvider {
                                                             &command_name,
                                                             &[],
                                                             true,
-                                                            false,
                                                             workspace.clone(),
                                                             cx,
                                                         );
@@ -212,7 +211,6 @@ impl SlashCommandCompletionProvider {
                                                             &command_name,
                                                             &completed_arguments,
                                                             true,
-                                                            false,
                                                             workspace.clone(),
                                                             cx,
                                                         );

crates/assistant/src/slash_command/streaming_example_command.rs 🔗

@@ -75,12 +75,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
                     },
                 )))?;
                 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,
-                    },
-                )))?;
 
                 Timer::after(Duration::from_secs(1)).await;
 
@@ -96,12 +90,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
                     },
                 )))?;
                 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,
-                    },
-                )))?;
 
                 for n in 1..=10 {
                     Timer::after(Duration::from_secs(1)).await;
@@ -119,12 +107,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
                     )))?;
                     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,
-                        },
-                    )))?;
                 }
 
                 anyhow::Ok(())

crates/assistant_slash_command/Cargo.toml 🔗

@@ -18,6 +18,7 @@ derive_more.workspace = true
 futures.workspace = true
 gpui.workspace = true
 language.workspace = true
+language_model.workspace = true
 parking_lot.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -5,6 +5,7 @@ use futures::stream::{self, BoxStream};
 use futures::StreamExt;
 use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
 use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
+pub use language_model::Role;
 use serde::{Deserialize, Serialize};
 pub use slash_command_registry::*;
 use std::{
@@ -103,7 +104,7 @@ pub type RenderFoldPlaceholder = Arc<
         + Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
 >;
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq)]
 pub enum SlashCommandContent {
     Text {
         text: String,
@@ -111,8 +112,21 @@ pub enum SlashCommandContent {
     },
 }
 
-#[derive(Debug, PartialEq, Eq)]
+impl<'a> From<&'a str> for SlashCommandContent {
+    fn from(text: &'a str) -> Self {
+        Self::Text {
+            text: text.into(),
+            run_commands_in_text: false,
+        }
+    }
+}
+
+#[derive(Debug, PartialEq)]
 pub enum SlashCommandEvent {
+    StartMessage {
+        role: Role,
+        merge_same_roles: bool,
+    },
     StartSection {
         icon: IconName,
         label: SharedString,
@@ -232,6 +246,7 @@ impl SlashCommandOutput {
                         output.sections.push(section);
                     }
                 }
+                SlashCommandEvent::StartMessage { .. } => {}
             }
         }
 

crates/editor/src/display_map.rs 🔗

@@ -36,7 +36,7 @@ use block_map::{BlockRow, BlockSnapshot};
 use collections::{HashMap, HashSet};
 pub use crease_map::*;
 pub use fold_map::{Fold, FoldId, FoldPlaceholder, FoldPoint};
-use fold_map::{FoldMap, FoldSnapshot};
+use fold_map::{FoldMap, FoldMapWriter, FoldOffset, FoldSnapshot};
 use gpui::{
     AnyElement, Font, HighlightStyle, LineLayout, Model, ModelContext, Pixels, UnderlineStyle,
 };
@@ -65,7 +65,7 @@ use std::{
 };
 use sum_tree::{Bias, TreeMap};
 use tab_map::{TabMap, TabSnapshot};
-use text::LineIndent;
+use text::{Edit, LineIndent};
 use ui::{div, px, IntoElement, ParentElement, SharedString, Styled, WindowContext};
 use unicode_segmentation::UnicodeSegmentation;
 use wrap_map::{WrapMap, WrapSnapshot};
@@ -206,34 +206,41 @@ impl DisplayMap {
         );
     }
 
+    /// Creates folds for the given ranges.
     pub fn fold<T: ToOffset>(
         &mut self,
         ranges: impl IntoIterator<Item = (Range<T>, FoldPlaceholder)>,
         cx: &mut ModelContext<Self>,
     ) {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        let edits = self.buffer_subscription.consume().into_inner();
-        let tab_size = Self::tab_size(&self.buffer, cx);
-        let (snapshot, edits) = self.inlay_map.sync(snapshot, edits);
-        let (mut fold_map, snapshot, edits) = self.fold_map.write(snapshot, edits);
-        let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
-        let (snapshot, edits) = self
-            .wrap_map
-            .update(cx, |map, cx| map.sync(snapshot, edits, cx));
-        self.block_map.read(snapshot, edits);
-        let (snapshot, edits) = fold_map.fold(ranges);
-        let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
-        let (snapshot, edits) = self
-            .wrap_map
-            .update(cx, |map, cx| map.sync(snapshot, edits, cx));
-        self.block_map.read(snapshot, edits);
+        self.update_fold_map(cx, |fold_map| fold_map.fold(ranges))
+    }
+
+    /// Removes any folds with the given ranges.
+    pub fn remove_folds_with_type<T: ToOffset>(
+        &mut self,
+        ranges: impl IntoIterator<Item = Range<T>>,
+        type_id: TypeId,
+        cx: &mut ModelContext<Self>,
+    ) {
+        self.update_fold_map(cx, |fold_map| fold_map.remove_folds(ranges, type_id))
     }
 
-    pub fn unfold<T: ToOffset>(
+    /// Removes any folds whose ranges intersect any of the given ranges.
+    pub fn unfold_intersecting<T: ToOffset>(
         &mut self,
         ranges: impl IntoIterator<Item = Range<T>>,
         inclusive: bool,
         cx: &mut ModelContext<Self>,
+    ) {
+        self.update_fold_map(cx, |fold_map| {
+            fold_map.unfold_intersecting(ranges, inclusive)
+        })
+    }
+
+    fn update_fold_map(
+        &mut self,
+        cx: &mut ModelContext<Self>,
+        callback: impl FnOnce(&mut FoldMapWriter) -> (FoldSnapshot, Vec<Edit<FoldOffset>>),
     ) {
         let snapshot = self.buffer.read(cx).snapshot(cx);
         let edits = self.buffer_subscription.consume().into_inner();
@@ -245,7 +252,7 @@ impl DisplayMap {
             .wrap_map
             .update(cx, |map, cx| map.sync(snapshot, edits, cx));
         self.block_map.read(snapshot, edits);
-        let (snapshot, edits) = fold_map.unfold(ranges, inclusive);
+        let (snapshot, edits) = callback(&mut fold_map);
         let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
         let (snapshot, edits) = self
             .wrap_map
@@ -1442,7 +1449,7 @@ pub mod tests {
                     if rng.gen() && fold_count > 0 {
                         log::info!("unfolding ranges: {:?}", ranges);
                         map.update(cx, |map, cx| {
-                            map.unfold(ranges, true, cx);
+                            map.unfold_intersecting(ranges, true, cx);
                         });
                     } else {
                         log::info!("folding ranges: {:?}", ranges);

crates/editor/src/display_map/fold_map.rs 🔗

@@ -6,12 +6,14 @@ use gpui::{AnyElement, ElementId, WindowContext};
 use language::{Chunk, ChunkRenderer, Edit, Point, TextSummary};
 use multi_buffer::{Anchor, AnchorRangeExt, MultiBufferRow, MultiBufferSnapshot, ToOffset};
 use std::{
+    any::TypeId,
     cmp::{self, Ordering},
     fmt, iter,
     ops::{Add, AddAssign, Deref, DerefMut, Range, Sub},
     sync::Arc,
 };
 use sum_tree::{Bias, Cursor, FilterCursor, SumTree, Summary};
+use ui::IntoElement as _;
 use util::post_inc;
 
 #[derive(Clone)]
@@ -22,17 +24,29 @@ pub struct FoldPlaceholder {
     pub constrain_width: bool,
     /// If true, merges the fold with an adjacent one.
     pub merge_adjacent: bool,
+    /// Category of the fold. Useful for carefully removing from overlapping folds.
+    pub type_tag: Option<TypeId>,
+}
+
+impl Default for FoldPlaceholder {
+    fn default() -> Self {
+        Self {
+            render: Arc::new(|_, _, _| gpui::Empty.into_any_element()),
+            constrain_width: true,
+            merge_adjacent: true,
+            type_tag: None,
+        }
+    }
 }
 
 impl FoldPlaceholder {
     #[cfg(any(test, feature = "test-support"))]
     pub fn test() -> Self {
-        use gpui::IntoElement;
-
         Self {
             render: Arc::new(|_id, _range, _cx| gpui::Empty.into_any_element()),
             constrain_width: true,
             merge_adjacent: true,
+            type_tag: None,
         }
     }
 }
@@ -173,31 +187,58 @@ impl<'a> FoldMapWriter<'a> {
         (self.0.snapshot.clone(), edits)
     }
 
-    pub(crate) fn unfold<T: ToOffset>(
+    /// Removes any folds with the given ranges.
+    pub(crate) fn remove_folds<T: ToOffset>(
+        &mut self,
+        ranges: impl IntoIterator<Item = Range<T>>,
+        type_id: TypeId,
+    ) -> (FoldSnapshot, Vec<FoldEdit>) {
+        self.remove_folds_with(
+            ranges,
+            |fold| fold.placeholder.type_tag == Some(type_id),
+            false,
+        )
+    }
+
+    /// Removes any folds whose ranges intersect the given ranges.
+    pub(crate) fn unfold_intersecting<T: ToOffset>(
         &mut self,
         ranges: impl IntoIterator<Item = Range<T>>,
         inclusive: bool,
+    ) -> (FoldSnapshot, Vec<FoldEdit>) {
+        self.remove_folds_with(ranges, |_| true, inclusive)
+    }
+
+    /// Removes any folds that intersect the given ranges and for which the given predicate
+    /// returns true.
+    fn remove_folds_with<T: ToOffset>(
+        &mut self,
+        ranges: impl IntoIterator<Item = Range<T>>,
+        should_unfold: impl Fn(&Fold) -> bool,
+        inclusive: bool,
     ) -> (FoldSnapshot, Vec<FoldEdit>) {
         let mut edits = Vec::new();
         let mut fold_ixs_to_delete = Vec::new();
         let snapshot = self.0.snapshot.inlay_snapshot.clone();
         let buffer = &snapshot.buffer;
         for range in ranges.into_iter() {
-            // Remove intersecting folds and add their ranges to edits that are passed to sync.
+            let range = range.start.to_offset(buffer)..range.end.to_offset(buffer);
             let mut folds_cursor =
-                intersecting_folds(&snapshot, &self.0.snapshot.folds, range, inclusive);
+                intersecting_folds(&snapshot, &self.0.snapshot.folds, range.clone(), inclusive);
             while let Some(fold) = folds_cursor.item() {
                 let offset_range =
                     fold.range.start.to_offset(buffer)..fold.range.end.to_offset(buffer);
-                if offset_range.end > offset_range.start {
-                    let inlay_range = snapshot.to_inlay_offset(offset_range.start)
-                        ..snapshot.to_inlay_offset(offset_range.end);
-                    edits.push(InlayEdit {
-                        old: inlay_range.clone(),
-                        new: inlay_range,
-                    });
+                if should_unfold(fold) {
+                    if offset_range.end > offset_range.start {
+                        let inlay_range = snapshot.to_inlay_offset(offset_range.start)
+                            ..snapshot.to_inlay_offset(offset_range.end);
+                        edits.push(InlayEdit {
+                            old: inlay_range.clone(),
+                            new: inlay_range,
+                        });
+                    }
+                    fold_ixs_to_delete.push(*folds_cursor.start());
                 }
-                fold_ixs_to_delete.push(*folds_cursor.start());
                 folds_cursor.next(buffer);
             }
         }
@@ -665,6 +706,8 @@ impl FoldSnapshot {
     where
         T: ToOffset,
     {
+        let buffer = &self.inlay_snapshot.buffer;
+        let range = range.start.to_offset(buffer)..range.end.to_offset(buffer);
         let mut folds = intersecting_folds(&self.inlay_snapshot, &self.folds, range, false);
         iter::from_fn(move || {
             let item = folds.item();
@@ -821,15 +864,12 @@ fn push_isomorphic(transforms: &mut SumTree<Transform>, summary: TextSummary) {
     }
 }
 
-fn intersecting_folds<'a, T>(
+fn intersecting_folds<'a>(
     inlay_snapshot: &'a InlaySnapshot,
     folds: &'a SumTree<Fold>,
-    range: Range<T>,
+    range: Range<usize>,
     inclusive: bool,
-) -> FilterCursor<'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, usize>
-where
-    T: ToOffset,
-{
+) -> FilterCursor<'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, usize> {
     let buffer = &inlay_snapshot.buffer;
     let start = buffer.anchor_before(range.start.to_offset(buffer));
     let end = buffer.anchor_after(range.end.to_offset(buffer));
@@ -1419,12 +1459,12 @@ mod tests {
         assert_eq!(snapshot4.text(), "123a⋯c123456eee");
 
         let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
-        writer.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), false);
+        writer.unfold_intersecting(Some(Point::new(0, 4)..Point::new(0, 4)), false);
         let (snapshot5, _) = map.read(inlay_snapshot.clone(), vec![]);
         assert_eq!(snapshot5.text(), "123a⋯c123456eee");
 
         let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]);
-        writer.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), true);
+        writer.unfold_intersecting(Some(Point::new(0, 4)..Point::new(0, 4)), true);
         let (snapshot6, _) = map.read(inlay_snapshot, vec![]);
         assert_eq!(snapshot6.text(), "123aaaaa\nbbbbbb\nccc123456eee");
     }
@@ -1913,7 +1953,7 @@ mod tests {
                     log::info!("unfolding {:?} (inclusive: {})", to_unfold, inclusive);
                     let (mut writer, snapshot, edits) = self.write(inlay_snapshot, vec![]);
                     snapshot_edits.push((snapshot, edits));
-                    let (snapshot, edits) = writer.unfold(to_unfold, inclusive);
+                    let (snapshot, edits) = writer.unfold_intersecting(to_unfold, inclusive);
                     snapshot_edits.push((snapshot, edits));
                 }
                 _ => {

crates/editor/src/editor.rs 🔗

@@ -75,8 +75,8 @@ use gpui::{
     AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry,
     ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent,
     FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext,
-    ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString,
-    Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle,
+    ListSizingBehavior, Model, ModelContext, MouseButton, PaintQuad, ParentElement, Pixels, Render,
+    SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle,
     TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, View,
     ViewContext, ViewInputHandler, VisualContext, WeakFocusHandle, WeakView, WindowContext,
 };
@@ -1849,7 +1849,7 @@ impl Editor {
                         editor
                             .update(cx, |editor, cx| {
                                 editor.unfold_ranges(
-                                    [fold_range.start..fold_range.end],
+                                    &[fold_range.start..fold_range.end],
                                     true,
                                     false,
                                     cx,
@@ -1861,6 +1861,7 @@ impl Editor {
                     .into_any()
             }),
             merge_adjacent: true,
+            ..Default::default()
         };
         let display_map = cx.new_model(|cx| {
             DisplayMap::new(
@@ -6810,7 +6811,7 @@ impl Editor {
         }
 
         self.transact(cx, |this, cx| {
-            this.unfold_ranges(unfold_ranges, true, true, cx);
+            this.unfold_ranges(&unfold_ranges, true, true, cx);
             this.buffer.update(cx, |buffer, cx| {
                 for (range, text) in edits {
                     buffer.edit([(range, text)], None, cx);
@@ -6904,7 +6905,7 @@ impl Editor {
         }
 
         self.transact(cx, |this, cx| {
-            this.unfold_ranges(unfold_ranges, true, true, cx);
+            this.unfold_ranges(&unfold_ranges, true, true, cx);
             this.buffer.update(cx, |buffer, cx| {
                 for (range, text) in edits {
                     buffer.edit([(range, text)], None, cx);
@@ -8256,7 +8257,7 @@ impl Editor {
                 to_unfold.push(selection.start..selection.end);
             }
         }
-        self.unfold_ranges(to_unfold, true, true, cx);
+        self.unfold_ranges(&to_unfold, true, true, cx);
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.select_ranges(new_selection_ranges);
         });
@@ -8387,7 +8388,7 @@ impl Editor {
             auto_scroll: Option<Autoscroll>,
             cx: &mut ViewContext<Editor>,
         ) {
-            this.unfold_ranges([range.clone()], false, true, cx);
+            this.unfold_ranges(&[range.clone()], false, true, cx);
             this.change_selections(auto_scroll, cx, |s| {
                 if replace_newest {
                     s.delete(s.newest_anchor().id);
@@ -8598,7 +8599,10 @@ impl Editor {
 
         select_next_state.done = true;
         self.unfold_ranges(
-            new_selections.iter().map(|selection| selection.range()),
+            &new_selections
+                .iter()
+                .map(|selection| selection.range())
+                .collect::<Vec<_>>(),
             false,
             false,
             cx,
@@ -8667,7 +8671,7 @@ impl Editor {
                 }
 
                 if let Some(next_selected_range) = next_selected_range {
-                    self.unfold_ranges([next_selected_range.clone()], false, true, cx);
+                    self.unfold_ranges(&[next_selected_range.clone()], false, true, cx);
                     self.change_selections(Some(Autoscroll::newest()), cx, |s| {
                         if action.replace_newest {
                             s.delete(s.newest_anchor().id);
@@ -8744,7 +8748,7 @@ impl Editor {
                 }
 
                 self.unfold_ranges(
-                    selections.iter().map(|s| s.range()).collect::<Vec<_>>(),
+                    &selections.iter().map(|s| s.range()).collect::<Vec<_>>(),
                     false,
                     true,
                     cx,
@@ -10986,7 +10990,7 @@ impl Editor {
             })
             .collect::<Vec<_>>();
 
-        self.unfold_ranges(ranges, true, true, cx);
+        self.unfold_ranges(&ranges, true, true, cx);
     }
 
     pub fn unfold_recursive(&mut self, _: &UnfoldRecursive, cx: &mut ViewContext<Self>) {
@@ -11004,7 +11008,7 @@ impl Editor {
             })
             .collect::<Vec<_>>();
 
-        self.unfold_ranges(ranges, true, true, cx);
+        self.unfold_ranges(&ranges, true, true, cx);
     }
 
     pub fn unfold_at(&mut self, unfold_at: &UnfoldAt, cx: &mut ViewContext<Self>) {
@@ -11022,13 +11026,13 @@ impl Editor {
             .iter()
             .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range));
 
-        self.unfold_ranges(std::iter::once(intersection_range), true, autoscroll, cx)
+        self.unfold_ranges(&[intersection_range], true, autoscroll, cx)
     }
 
     pub fn unfold_all(&mut self, _: &actions::UnfoldAll, cx: &mut ViewContext<Self>) {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         self.unfold_ranges(
-            [Point::zero()..display_map.max_point().to_point(&display_map)],
+            &[Point::zero()..display_map.max_point().to_point(&display_map)],
             true,
             true,
             cx,
@@ -11104,39 +11108,63 @@ impl Editor {
         }
     }
 
+    /// Removes any folds whose ranges intersect any of the given ranges.
     pub fn unfold_ranges<T: ToOffset + Clone>(
         &mut self,
-        ranges: impl IntoIterator<Item = Range<T>>,
+        ranges: &[Range<T>],
         inclusive: bool,
         auto_scroll: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        let mut unfold_ranges = Vec::new();
+        self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| {
+            map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx)
+        });
+    }
+
+    /// Removes any folds with the given ranges.
+    pub fn remove_folds_with_type<T: ToOffset + Clone>(
+        &mut self,
+        ranges: &[Range<T>],
+        type_id: TypeId,
+        auto_scroll: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| {
+            map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx)
+        });
+    }
+
+    fn remove_folds_with<T: ToOffset + Clone>(
+        &mut self,
+        ranges: &[Range<T>],
+        auto_scroll: bool,
+        cx: &mut ViewContext<Self>,
+        update: impl FnOnce(&mut DisplayMap, &mut ModelContext<DisplayMap>),
+    ) {
+        if ranges.is_empty() {
+            return;
+        }
+
         let mut buffers_affected = HashMap::default();
         let multi_buffer = self.buffer().read(cx);
         for range in ranges {
             if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) {
                 buffers_affected.insert(buffer.read(cx).remote_id(), buffer);
             };
-            unfold_ranges.push(range);
         }
 
-        let mut ranges = unfold_ranges.into_iter().peekable();
-        if ranges.peek().is_some() {
-            self.display_map
-                .update(cx, |map, cx| map.unfold(ranges, inclusive, cx));
-            if auto_scroll {
-                self.request_autoscroll(Autoscroll::fit(), cx);
-            }
-
-            for buffer in buffers_affected.into_values() {
-                self.sync_expanded_diff_hunks(buffer, cx);
-            }
+        self.display_map.update(cx, update);
+        if auto_scroll {
+            self.request_autoscroll(Autoscroll::fit(), cx);
+        }
 
-            cx.notify();
-            self.scrollbar_marker_state.dirty = true;
-            self.active_indent_guides_state.dirty = true;
+        for buffer in buffers_affected.into_values() {
+            self.sync_expanded_diff_hunks(buffer, cx);
         }
+
+        cx.notify();
+        self.scrollbar_marker_state.dirty = true;
+        self.active_indent_guides_state.dirty = true;
     }
 
     pub fn default_fold_placeholder(&self, cx: &AppContext) -> FoldPlaceholder {

crates/editor/src/items.rs 🔗

@@ -1280,7 +1280,7 @@ impl SearchableItem for Editor {
         matches: &[Range<Anchor>],
         cx: &mut ViewContext<Self>,
     ) {
-        self.unfold_ranges([matches[index].clone()], false, true, cx);
+        self.unfold_ranges(&[matches[index].clone()], false, true, cx);
         let range = self.range_for_match(&matches[index]);
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.select_ranges([range]);
@@ -1288,7 +1288,7 @@ impl SearchableItem for Editor {
     }
 
     fn select_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>) {
-        self.unfold_ranges(matches.to_vec(), false, false, cx);
+        self.unfold_ranges(matches, false, false, cx);
         let mut ranges = Vec::new();
         for m in matches {
             ranges.push(self.range_for_match(m))

crates/proto/proto/zed.proto 🔗

@@ -2251,10 +2251,14 @@ message ContextOperation {
         InsertMessage insert_message = 1;
         UpdateMessage update_message = 2;
         UpdateSummary update_summary = 3;
-        SlashCommandFinished slash_command_finished = 4;
         BufferOperation buffer_operation = 5;
+        SlashCommandStarted slash_command_started = 6;
+        SlashCommandOutputSectionAdded slash_command_output_section_added = 7;
+        SlashCommandCompleted slash_command_completed = 8;
     }
 
+    reserved 4;
+
     message InsertMessage {
         ContextMessage message = 1;
         repeated VectorClockEntry version = 2;
@@ -2275,13 +2279,26 @@ message ContextOperation {
         repeated VectorClockEntry version = 4;
     }
 
-    message SlashCommandFinished {
+    message SlashCommandStarted {
         LamportTimestamp id = 1;
         AnchorRange output_range = 2;
-        repeated SlashCommandOutputSection sections = 3;
+        string name = 3;
         repeated VectorClockEntry version = 4;
     }
 
+    message SlashCommandOutputSectionAdded {
+        LamportTimestamp timestamp = 1;
+        SlashCommandOutputSection section = 2;
+        repeated VectorClockEntry version = 3;
+    }
+
+    message SlashCommandCompleted {
+        LamportTimestamp id = 1;
+        LamportTimestamp timestamp = 3;
+        optional string error_message = 4;
+        repeated VectorClockEntry version = 5;
+    }
+
     message BufferOperation {
         Operation operation = 1;
     }

crates/search/src/project_search.rs 🔗

@@ -1067,7 +1067,7 @@ impl ProjectSearchView {
             let range_to_select = match_ranges[new_index].clone();
             self.results_editor.update(cx, |editor, cx| {
                 let range_to_select = editor.range_for_match(&range_to_select);
-                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
+                editor.unfold_ranges(&[range_to_select.clone()], false, true, cx);
                 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                     s.select_ranges([range_to_select])
                 });