Preserve sections generated by slash commands when reloading a context (#13199)

Antonio Scandurra created

Release Notes:

- N/A

Change summary

Cargo.lock                                                    |   2 
crates/assistant/src/assistant_panel.rs                       |  84 ++
crates/assistant/src/context_store.rs                         |  30 +
crates/assistant/src/slash_command/active_command.rs          |  22 
crates/assistant/src/slash_command/default_command.rs         |  12 
crates/assistant/src/slash_command/diagnostics_command.rs     | 128 +---
crates/assistant/src/slash_command/fetch_command.rs           |  32 -
crates/assistant/src/slash_command/file_command.rs            |  89 +--
crates/assistant/src/slash_command/now_command.rs             |   5 
crates/assistant/src/slash_command/project_command.rs         |  13 
crates/assistant/src/slash_command/prompt_command.rs          |  31 -
crates/assistant/src/slash_command/rustdoc_command.rs         |  57 -
crates/assistant/src/slash_command/search_command.rs          |  35 
crates/assistant/src/slash_command/tabs_command.rs            |  25 
crates/assistant_slash_command/Cargo.toml                     |   1 
crates/assistant_slash_command/src/assistant_slash_command.rs |  10 
crates/extension/src/extension_slash_command.rs               |  18 
crates/ui/Cargo.toml                                          |   1 
crates/ui/src/components/icon.rs                              |   3 
19 files changed, 250 insertions(+), 348 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -425,6 +425,7 @@ dependencies = [
  "gpui",
  "language",
  "parking_lot",
+ "serde",
  "workspace",
 ]
 
@@ -11523,6 +11524,7 @@ dependencies = [
  "gpui",
  "itertools 0.11.0",
  "menu",
+ "serde",
  "settings",
  "smallvec",
  "story",

crates/assistant/src/assistant_panel.rs 🔗

@@ -15,9 +15,8 @@ use anyhow::{anyhow, Result};
 use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
 use client::telemetry::Telemetry;
 use collections::{BTreeSet, HashMap, HashSet};
-use editor::actions::ShowCompletions;
 use editor::{
-    actions::{FoldAt, MoveToEndOfLine, Newline, UnfoldAt},
+    actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
     display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, ToDisplayPoint},
     scroll::{Autoscroll, AutoscrollStrategy},
     Anchor, Editor, EditorEvent, RowExt, ToOffset as _, ToPoint,
@@ -36,7 +35,7 @@ use gpui::{
     WindowContext,
 };
 use language::{
-    language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry,
+    language_settings::SoftWrap, AnchorRangeExt as _, AutoindentMode, Buffer, LanguageRegistry,
     LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _,
 };
 use multi_buffer::MultiBufferRow;
@@ -1013,6 +1012,7 @@ pub struct Context {
     edit_suggestions: Vec<EditSuggestion>,
     pending_slash_commands: Vec<PendingSlashCommand>,
     edits_since_last_slash_command_parse: language::Subscription,
+    slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
@@ -1054,6 +1054,7 @@ impl Context {
             next_message_id: Default::default(),
             edit_suggestions: Vec::new(),
             pending_slash_commands: Vec::new(),
+            slash_command_output_sections: Vec::new(),
             edits_since_last_slash_command_parse,
             summary: None,
             pending_summary: Task::ready(None),
@@ -1090,11 +1091,12 @@ impl Context {
     }
 
     fn serialize(&self, cx: &AppContext) -> SavedContext {
+        let buffer = self.buffer.read(cx);
         SavedContext {
             id: self.id.clone(),
             zed: "context".into(),
             version: SavedContext::VERSION.into(),
-            text: self.buffer.read(cx).text(),
+            text: buffer.text(),
             message_metadata: self.messages_metadata.clone(),
             messages: self
                 .messages(cx)
@@ -1108,6 +1110,22 @@ impl Context {
                 .as_ref()
                 .map(|summary| summary.text.clone())
                 .unwrap_or_default(),
+            slash_command_output_sections: self
+                .slash_command_output_sections
+                .iter()
+                .filter_map(|section| {
+                    let range = section.range.to_offset(buffer);
+                    if section.range.start.is_valid(buffer) && !range.is_empty() {
+                        Some(SlashCommandOutputSection {
+                            range,
+                            icon: section.icon,
+                            label: section.label.clone(),
+                        })
+                    } else {
+                        None
+                    }
+                })
+                .collect(),
         }
     }
 
@@ -1159,6 +1177,19 @@ impl Context {
                 next_message_id,
                 edit_suggestions: Vec::new(),
                 pending_slash_commands: Vec::new(),
+                slash_command_output_sections: saved_context
+                    .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,
+                        }
+                    })
+                    .collect(),
                 edits_since_last_slash_command_parse,
                 summary: Some(Summary {
                     text: saved_context.summary,
@@ -1457,10 +1488,17 @@ impl Context {
                                 .map(|section| SlashCommandOutputSection {
                                     range: buffer.anchor_after(start + section.range.start)
                                         ..buffer.anchor_before(start + section.range.end),
-                                    render_placeholder: section.render_placeholder,
+                                    icon: section.icon,
+                                    label: section.label,
                                 })
                                 .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));
+
                             ContextEvent::SlashCommandFinished {
                                 output_range: buffer.anchor_after(start)
                                     ..buffer.anchor_before(new_end),
@@ -2224,6 +2262,7 @@ impl ContextEditor {
             cx.subscribe(&editor, Self::handle_editor_event),
         ];
 
+        let sections = context.read(cx).slash_command_output_sections.clone();
         let mut this = Self {
             context,
             editor,
@@ -2237,6 +2276,7 @@ impl ContextEditor {
             _subscriptions,
         };
         this.update_message_headers(cx);
+        this.insert_slash_command_output_sections(sections, cx);
         this
     }
 
@@ -2631,21 +2671,27 @@ impl ContextEditor {
                     FoldPlaceholder {
                         render: Arc::new({
                             let editor = cx.view().downgrade();
-                            let render_placeholder = section.render_placeholder.clone();
-                            move |fold_id, fold_range, cx| {
+                            let icon = section.icon;
+                            let label = section.label.clone();
+                            move |fold_id, fold_range, _cx| {
                                 let editor = editor.clone();
-                                let unfold = Arc::new(move |cx: &mut WindowContext| {
-                                    editor
-                                        .update(cx, |editor, cx| {
-                                            let buffer_start = fold_range
-                                                .start
-                                                .to_point(&editor.buffer().read(cx).read(cx));
-                                            let buffer_row = MultiBufferRow(buffer_start.row);
-                                            editor.unfold_at(&UnfoldAt { buffer_row }, cx);
-                                        })
-                                        .ok();
-                                });
-                                render_placeholder(fold_id.into(), unfold, cx)
+                                ButtonLike::new(fold_id)
+                                    .style(ButtonStyle::Filled)
+                                    .layer(ElevationIndex::ElevatedSurface)
+                                    .child(Icon::new(icon))
+                                    .child(Label::new(label.clone()).single_line())
+                                    .on_click(move |_, cx| {
+                                        editor
+                                            .update(cx, |editor, cx| {
+                                                let buffer_start = fold_range
+                                                    .start
+                                                    .to_point(&editor.buffer().read(cx).read(cx));
+                                                let buffer_row = MultiBufferRow(buffer_start.row);
+                                                editor.unfold_at(&UnfoldAt { buffer_row }, cx);
+                                            })
+                                            .ok();
+                                    })
+                                    .into_any_element()
                             }
                         }),
                         constrain_width: false,

crates/assistant/src/context_store.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata};
 use anyhow::{anyhow, Result};
+use assistant_slash_command::SlashCommandOutputSection;
 use collections::HashMap;
 use fs::Fs;
 use futures::StreamExt;
@@ -27,10 +28,22 @@ pub struct SavedContext {
     pub messages: Vec<SavedMessage>,
     pub message_metadata: HashMap<MessageId, MessageMetadata>,
     pub summary: String,
+    pub slash_command_output_sections: Vec<SlashCommandOutputSection<usize>>,
 }
 
 impl SavedContext {
-    pub const VERSION: &'static str = "0.2.0";
+    pub const VERSION: &'static str = "0.3.0";
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct SavedContextV0_2_0 {
+    pub id: Option<String>,
+    pub zed: String,
+    pub version: String,
+    pub text: String,
+    pub messages: Vec<SavedMessage>,
+    pub message_metadata: HashMap<MessageId, MessageMetadata>,
+    pub summary: String,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -100,6 +113,20 @@ impl ContextStore {
                     SavedContext::VERSION => {
                         Ok(serde_json::from_value::<SavedContext>(saved_context_json)?)
                     }
+                    "0.2.0" => {
+                        let saved_context =
+                            serde_json::from_value::<SavedContextV0_2_0>(saved_context_json)?;
+                        Ok(SavedContext {
+                            id: saved_context.id,
+                            zed: saved_context.zed,
+                            version: saved_context.version,
+                            text: saved_context.text,
+                            messages: saved_context.messages,
+                            message_metadata: saved_context.message_metadata,
+                            summary: saved_context.summary,
+                            slash_command_output_sections: Vec::new(),
+                        })
+                    }
                     "0.1.0" => {
                         let saved_context =
                             serde_json::from_value::<SavedContextV0_1_0>(saved_context_json)?;
@@ -111,6 +138,7 @@ impl ContextStore {
                             messages: saved_context.messages,
                             message_metadata: saved_context.message_metadata,
                             summary: saved_context.summary,
+                            slash_command_output_sections: Vec::new(),
                         })
                     }
                     _ => Err(anyhow!("unrecognized saved context version: {}", version)),

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

@@ -1,14 +1,13 @@
 use super::{
-    file_command::{codeblock_fence_for_path, EntryPlaceholder},
+    file_command::{build_entry_output_section, codeblock_fence_for_path},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::{anyhow, Result};
-use assistant_slash_command::SlashCommandOutputSection;
 use editor::Editor;
 use gpui::{AppContext, Task, WeakView};
 use language::LspAdapterDelegate;
 use std::sync::Arc;
-use ui::{IntoElement, WindowContext};
+use ui::WindowContext;
 use workspace::Workspace;
 
 pub(crate) struct ActiveSlashCommand;
@@ -81,19 +80,12 @@ impl SlashCommand for ActiveSlashCommand {
                 let range = 0..text.len();
                 Ok(SlashCommandOutput {
                     text,
-                    sections: vec![SlashCommandOutputSection {
+                    sections: vec![build_entry_output_section(
                         range,
-                        render_placeholder: Arc::new(move |id, unfold, _| {
-                            EntryPlaceholder {
-                                id,
-                                path: path.clone(),
-                                is_directory: false,
-                                line_range: None,
-                                unfold,
-                            }
-                            .into_any_element()
-                        }),
-                    }],
+                        path.as_deref(),
+                        false,
+                        None,
+                    )],
                     run_commands_in_text: false,
                 })
             })

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

@@ -1,4 +1,4 @@
-use super::{prompt_command::PromptPlaceholder, SlashCommand, SlashCommandOutput};
+use super::{SlashCommand, SlashCommandOutput};
 use crate::prompt_library::PromptStore;
 use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandOutputSection;
@@ -68,14 +68,8 @@ impl SlashCommand for DefaultSlashCommand {
             Ok(SlashCommandOutput {
                 sections: vec![SlashCommandOutputSection {
                     range: 0..text.len(),
-                    render_placeholder: Arc::new(move |id, unfold, _cx| {
-                        PromptPlaceholder {
-                            title: "Default".into(),
-                            id,
-                            unfold,
-                        }
-                        .into_any_element()
-                    }),
+                    icon: IconName::Library,
+                    label: "Default".into(),
                 }],
                 text,
                 run_commands_in_text: true,

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

@@ -2,7 +2,7 @@ use super::{SlashCommand, SlashCommandOutput};
 use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandOutputSection;
 use fuzzy::{PathMatch, StringMatchCandidate};
-use gpui::{svg, AppContext, Model, RenderOnce, Task, View, WeakView};
+use gpui::{AppContext, Model, Task, View, WeakView};
 use language::{
     Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate,
     OffsetRangeExt, ToOffset,
@@ -14,7 +14,7 @@ use std::{
     ops::Range,
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use util::paths::PathMatcher;
 use util::ResultExt;
 use workspace::Workspace;
@@ -164,14 +164,45 @@ impl SlashCommand for DiagnosticsCommand {
                     .into_iter()
                     .map(|(range, placeholder_type)| SlashCommandOutputSection {
                         range,
-                        render_placeholder: Arc::new(move |id, unfold, _cx| {
-                            DiagnosticsPlaceholder {
-                                id,
-                                unfold,
-                                placeholder_type: placeholder_type.clone(),
+                        icon: match placeholder_type {
+                            PlaceholderType::Root(_, _) => IconName::ExclamationTriangle,
+                            PlaceholderType::File(_) => IconName::File,
+                            PlaceholderType::Diagnostic(DiagnosticType::Error, _) => {
+                                IconName::XCircle
                             }
-                            .into_any_element()
-                        }),
+                            PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => {
+                                IconName::ExclamationTriangle
+                            }
+                        },
+                        label: match placeholder_type {
+                            PlaceholderType::Root(summary, source) => {
+                                let mut label = String::new();
+                                label.push_str("Diagnostics");
+                                if let Some(source) = source {
+                                    write!(label, " ({})", source).unwrap();
+                                }
+
+                                if summary.error_count > 0 || summary.warning_count > 0 {
+                                    label.push(':');
+
+                                    if summary.error_count > 0 {
+                                        write!(label, " {} errors", summary.error_count).unwrap();
+                                        if summary.warning_count > 0 {
+                                            label.push_str(",");
+                                        }
+                                    }
+
+                                    if summary.warning_count > 0 {
+                                        write!(label, " {} warnings", summary.warning_count)
+                                            .unwrap();
+                                    }
+                                }
+
+                                label.into()
+                            }
+                            PlaceholderType::File(file_path) => file_path.into(),
+                            PlaceholderType::Diagnostic(_, message) => message.into(),
+                        },
                     })
                     .collect(),
                 run_commands_in_text: false,
@@ -223,10 +254,10 @@ fn collect_diagnostics(
     options: Options,
     cx: &mut AppContext,
 ) -> Task<Result<(String, Vec<(Range<usize>, PlaceholderType)>)>> {
-    let header = if let Some(path_matcher) = &options.path_matcher {
-        format!("diagnostics: {}", path_matcher.source())
+    let error_source = if let Some(path_matcher) = &options.path_matcher {
+        Some(path_matcher.source().to_string())
     } else {
-        "diagnostics".to_string()
+        None
     };
 
     let project_handle = project.downgrade();
@@ -234,7 +265,11 @@ fn collect_diagnostics(
 
     cx.spawn(|mut cx| async move {
         let mut text = String::new();
-        writeln!(text, "{}", &header).unwrap();
+        if let Some(error_source) = error_source.as_ref() {
+            writeln!(text, "diagnostics: {}", error_source).unwrap();
+        } else {
+            writeln!(text, "diagnostics").unwrap();
+        }
         let mut sections: Vec<(Range<usize>, PlaceholderType)> = Vec::new();
 
         let mut project_summary = DiagnosticSummary::default();
@@ -276,7 +311,7 @@ fn collect_diagnostics(
         }
         sections.push((
             0..text.len(),
-            PlaceholderType::Root(project_summary, header),
+            PlaceholderType::Root(project_summary, error_source),
         ));
 
         Ok((text, sections))
@@ -362,12 +397,12 @@ fn collect_diagnostic(
 
 #[derive(Clone)]
 pub enum PlaceholderType {
-    Root(DiagnosticSummary, String),
+    Root(DiagnosticSummary, Option<String>),
     File(String),
     Diagnostic(DiagnosticType, String),
 }
 
-#[derive(Copy, Clone, IntoElement)]
+#[derive(Copy, Clone)]
 pub enum DiagnosticType {
     Warning,
     Error,
@@ -381,64 +416,3 @@ impl DiagnosticType {
         }
     }
 }
-
-#[derive(IntoElement)]
-pub struct DiagnosticsPlaceholder {
-    pub id: ElementId,
-    pub placeholder_type: PlaceholderType,
-    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
-}
-
-impl RenderOnce for DiagnosticsPlaceholder {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let unfold = self.unfold;
-        let (icon, content) = match self.placeholder_type {
-            PlaceholderType::Root(summary, title) => (
-                h_flex()
-                    .w_full()
-                    .gap_0p5()
-                    .when(summary.error_count > 0, |this| {
-                        this.child(DiagnosticType::Error)
-                            .child(Label::new(summary.error_count.to_string()))
-                    })
-                    .when(summary.warning_count > 0, |this| {
-                        this.child(DiagnosticType::Warning)
-                            .child(Label::new(summary.warning_count.to_string()))
-                    })
-                    .into_any_element(),
-                Label::new(title),
-            ),
-            PlaceholderType::File(file) => (
-                Icon::new(IconName::File).into_any_element(),
-                Label::new(file),
-            ),
-            PlaceholderType::Diagnostic(diagnostic_type, message) => (
-                diagnostic_type.into_any_element(),
-                Label::new(message).single_line(),
-            ),
-        };
-
-        ButtonLike::new(self.id)
-            .style(ButtonStyle::Filled)
-            .layer(ElevationIndex::ElevatedSurface)
-            .child(icon)
-            .child(content)
-            .on_click(move |_, cx| unfold(cx))
-    }
-}
-
-impl RenderOnce for DiagnosticType {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        svg()
-            .size(cx.text_style().font_size)
-            .flex_none()
-            .map(|icon| match self {
-                DiagnosticType::Error => icon
-                    .path(IconName::XCircle.path())
-                    .text_color(Color::Error.color(cx)),
-                DiagnosticType::Warning => icon
-                    .path(IconName::ExclamationTriangle.path())
-                    .text_color(Color::Warning.color(cx)),
-            })
-    }
-}

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

@@ -10,7 +10,7 @@ use gpui::{AppContext, Task, WeakView};
 use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler};
 use http::{AsyncBody, HttpClient, HttpClientWithUrl};
 use language::LspAdapterDelegate;
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use workspace::Workspace;
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
@@ -152,37 +152,11 @@ impl SlashCommand for FetchSlashCommand {
                 text,
                 sections: vec![SlashCommandOutputSection {
                     range,
-                    render_placeholder: Arc::new(move |id, unfold, _cx| {
-                        FetchPlaceholder {
-                            id,
-                            unfold,
-                            url: url.clone(),
-                        }
-                        .into_any_element()
-                    }),
+                    icon: IconName::AtSign,
+                    label: format!("fetch {}", url).into(),
                 }],
                 run_commands_in_text: false,
             })
         })
     }
 }
-
-#[derive(IntoElement)]
-struct FetchPlaceholder {
-    pub id: ElementId,
-    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
-    pub url: SharedString,
-}
-
-impl RenderOnce for FetchPlaceholder {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let unfold = self.unfold;
-
-        ButtonLike::new(self.id)
-            .style(ButtonStyle::Filled)
-            .layer(ElevationIndex::ElevatedSurface)
-            .child(Icon::new(IconName::AtSign))
-            .child(Label::new(format!("fetch {url}", url = self.url)))
-            .on_click(move |_, cx| unfold(cx))
-    }
-}

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

@@ -3,7 +3,7 @@ use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandOutputSection;
 use fs::Fs;
 use fuzzy::PathMatch;
-use gpui::{AppContext, Model, RenderOnce, SharedString, Task, View, WeakView};
+use gpui::{AppContext, Model, Task, View, WeakView};
 use language::{LineEnding, LspAdapterDelegate};
 use project::{PathMatchCandidateSet, Worktree};
 use std::{
@@ -12,7 +12,7 @@ use std::{
     path::{Path, PathBuf},
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use util::{paths::PathMatcher, ResultExt};
 use workspace::Workspace;
 
@@ -156,18 +156,13 @@ impl SlashCommand for FileSlashCommand {
                 text,
                 sections: ranges
                     .into_iter()
-                    .map(|(range, path, entry_type)| SlashCommandOutputSection {
-                        range,
-                        render_placeholder: Arc::new(move |id, unfold, _cx| {
-                            EntryPlaceholder {
-                                path: Some(path.clone()),
-                                is_directory: entry_type == EntryType::Directory,
-                                line_range: None,
-                                id,
-                                unfold,
-                            }
-                            .into_any_element()
-                        }),
+                    .map(|(range, path, entry_type)| {
+                        build_entry_output_section(
+                            range,
+                            Some(&path),
+                            entry_type == EntryType::Directory,
+                            None,
+                        )
                     })
                     .collect(),
                 run_commands_in_text: false,
@@ -349,44 +344,6 @@ async fn collect_file_content(
     anyhow::Ok(())
 }
 
-#[derive(IntoElement)]
-pub struct EntryPlaceholder {
-    pub path: Option<PathBuf>,
-    pub is_directory: bool,
-    pub line_range: Option<Range<u32>>,
-    pub id: ElementId,
-    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
-}
-
-impl RenderOnce for EntryPlaceholder {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let unfold = self.unfold;
-        let title = if let Some(path) = self.path.as_ref() {
-            SharedString::from(path.to_string_lossy().to_string())
-        } else {
-            SharedString::from("untitled")
-        };
-        let icon = if self.is_directory {
-            IconName::Folder
-        } else {
-            IconName::File
-        };
-
-        ButtonLike::new(self.id)
-            .style(ButtonStyle::Filled)
-            .layer(ElevationIndex::ElevatedSurface)
-            .child(Icon::new(icon))
-            .child(Label::new(title))
-            .when_some(self.line_range, |button, line_range| {
-                button.child(Label::new(":")).child(Label::new(format!(
-                    "{}-{}",
-                    line_range.start, line_range.end
-                )))
-            })
-            .on_click(move |_, cx| unfold(cx))
-    }
-}
-
 pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
     let mut text = String::new();
     write!(text, "```").unwrap();
@@ -408,3 +365,31 @@ pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32
     text.push('\n');
     text
 }
+
+pub fn build_entry_output_section(
+    range: Range<usize>,
+    path: Option<&Path>,
+    is_directory: bool,
+    line_range: Option<Range<u32>>,
+) -> SlashCommandOutputSection<usize> {
+    let mut label = if let Some(path) = path {
+        path.to_string_lossy().to_string()
+    } else {
+        "untitled".to_string()
+    };
+    if let Some(line_range) = line_range {
+        write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
+    }
+
+    let icon = if is_directory {
+        IconName::Folder
+    } else {
+        IconName::File
+    };
+
+    SlashCommandOutputSection {
+        range,
+        icon,
+        label: label.into(),
+    }
+}

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

@@ -53,9 +53,8 @@ impl SlashCommand for NowSlashCommand {
             text,
             sections: vec![SlashCommandOutputSection {
                 range,
-                render_placeholder: Arc::new(move |id, unfold, _cx| {
-                    NowPlaceholder { id, unfold, now }.into_any_element()
-                }),
+                icon: IconName::CountdownTimer,
+                label: now.to_rfc3339().into(),
             }],
             run_commands_in_text: false,
         }))

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

@@ -10,7 +10,7 @@ use std::{
     path::Path,
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use workspace::Workspace;
 
 pub(crate) struct ProjectSlashCommand;
@@ -138,15 +138,8 @@ impl SlashCommand for ProjectSlashCommand {
                     text,
                     sections: vec![SlashCommandOutputSection {
                         range,
-                        render_placeholder: Arc::new(move |id, unfold, _cx| {
-                            ButtonLike::new(id)
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ElevatedSurface)
-                                .child(Icon::new(IconName::FileTree))
-                                .child(Label::new("Project"))
-                                .on_click(move |_, cx| unfold(cx))
-                                .into_any_element()
-                        }),
+                        icon: IconName::FileTree,
+                        label: "Project".into(),
                     }],
                     run_commands_in_text: false,
                 })

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

@@ -5,7 +5,7 @@ use assistant_slash_command::SlashCommandOutputSection;
 use gpui::{AppContext, Task, WeakView};
 use language::LspAdapterDelegate;
 use std::sync::{atomic::AtomicBool, Arc};
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use workspace::Workspace;
 
 pub(crate) struct PromptSlashCommand;
@@ -78,36 +78,11 @@ impl SlashCommand for PromptSlashCommand {
                 text: prompt,
                 sections: vec![SlashCommandOutputSection {
                     range,
-                    render_placeholder: Arc::new(move |id, unfold, _cx| {
-                        PromptPlaceholder {
-                            id,
-                            unfold,
-                            title: title.clone(),
-                        }
-                        .into_any_element()
-                    }),
+                    icon: IconName::Library,
+                    label: title,
                 }],
                 run_commands_in_text: true,
             })
         })
     }
 }
-
-#[derive(IntoElement)]
-pub struct PromptPlaceholder {
-    pub title: SharedString,
-    pub id: ElementId,
-    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
-}
-
-impl RenderOnce for PromptPlaceholder {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let unfold = self.unfold;
-        ButtonLike::new(self.id)
-            .style(ButtonStyle::Filled)
-            .layer(ElevationIndex::ElevatedSurface)
-            .child(Icon::new(IconName::Library))
-            .child(Label::new(self.title))
-            .on_click(move |_, cx| unfold(cx))
-    }
-}

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

@@ -11,7 +11,7 @@ use http::{AsyncBody, HttpClient, HttpClientWithUrl};
 use language::LspAdapterDelegate;
 use project::{Project, ProjectPath};
 use rustdoc::{convert_rustdoc_to_markdown, CrateName, LocalProvider, RustdocSource, RustdocStore};
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use util::{maybe, ResultExt};
 use workspace::Workspace;
 
@@ -213,57 +213,26 @@ impl SlashCommand for RustdocSlashCommand {
         cx.foreground_executor().spawn(async move {
             let (source, text) = text.await?;
             let range = 0..text.len();
+            let crate_path = module_path
+                .map(|module_path| format!("{}::{}", crate_name, module_path))
+                .unwrap_or_else(|| crate_name.to_string());
             Ok(SlashCommandOutput {
                 text,
                 sections: vec![SlashCommandOutputSection {
                     range,
-                    render_placeholder: Arc::new(move |id, unfold, _cx| {
-                        RustdocPlaceholder {
-                            id,
-                            unfold,
-                            source,
-                            crate_name: crate_name.clone(),
-                            module_path: module_path.clone(),
+                    icon: IconName::FileRust,
+                    label: format!(
+                        "rustdoc ({source}): {crate_path}",
+                        source = match source {
+                            RustdocSource::Index => "index",
+                            RustdocSource::Local => "local",
+                            RustdocSource::DocsDotRs => "docs.rs",
                         }
-                        .into_any_element()
-                    }),
+                    )
+                    .into(),
                 }],
                 run_commands_in_text: false,
             })
         })
     }
 }
-
-#[derive(IntoElement)]
-struct RustdocPlaceholder {
-    pub id: ElementId,
-    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
-    pub source: RustdocSource,
-    pub crate_name: CrateName,
-    pub module_path: Option<SharedString>,
-}
-
-impl RenderOnce for RustdocPlaceholder {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let unfold = self.unfold;
-
-        let crate_path = self
-            .module_path
-            .map(|module_path| format!("{crate_name}::{module_path}", crate_name = self.crate_name))
-            .unwrap_or(self.crate_name.to_string());
-
-        ButtonLike::new(self.id)
-            .style(ButtonStyle::Filled)
-            .layer(ElevationIndex::ElevatedSurface)
-            .child(Icon::new(IconName::FileRust))
-            .child(Label::new(format!(
-                "rustdoc ({source}): {crate_path}",
-                source = match self.source {
-                    RustdocSource::Index => "index",
-                    RustdocSource::Local => "local",
-                    RustdocSource::DocsDotRs => "docs.rs",
-                }
-            )))
-            .on_click(move |_, cx| unfold(cx))
-    }
-}

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

@@ -1,5 +1,5 @@
 use super::{
-    file_command::{codeblock_fence_for_path, EntryPlaceholder},
+    file_command::{build_entry_output_section, codeblock_fence_for_path},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::Result;
@@ -12,7 +12,7 @@ use std::{
     path::PathBuf,
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{prelude::*, ButtonLike, ElevationIndex, Icon, IconName};
+use ui::{prelude::*, IconName};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -151,34 +151,19 @@ impl SlashCommand for SearchSlashCommand {
                         text.push_str(&excerpt);
                         writeln!(text, "\n```\n").unwrap();
                         let section_end_ix = text.len() - 1;
-
-                        sections.push(SlashCommandOutputSection {
-                            range: section_start_ix..section_end_ix,
-                            render_placeholder: Arc::new(move |id, unfold, _| {
-                                EntryPlaceholder {
-                                    id,
-                                    path: Some(full_path.clone()),
-                                    is_directory: false,
-                                    line_range: Some(start_row + 1..end_row + 1),
-                                    unfold,
-                                }
-                                .into_any_element()
-                            }),
-                        });
+                        sections.push(build_entry_output_section(
+                            section_start_ix..section_end_ix,
+                            Some(&full_path),
+                            false,
+                            Some(start_row + 1..end_row + 1),
+                        ));
                     }
 
                     let query = SharedString::from(query);
                     sections.push(SlashCommandOutputSection {
                         range: 0..text.len(),
-                        render_placeholder: Arc::new(move |id, unfold, _cx| {
-                            ButtonLike::new(id)
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ElevatedSurface)
-                                .child(Icon::new(IconName::MagnifyingGlass))
-                                .child(Label::new(query.clone()))
-                                .on_click(move |_, cx| unfold(cx))
-                                .into_any_element()
-                        }),
+                        icon: IconName::MagnifyingGlass,
+                        label: query,
                     });
 
                     SlashCommandOutput {

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

@@ -1,15 +1,14 @@
 use super::{
-    file_command::{codeblock_fence_for_path, EntryPlaceholder},
+    file_command::{build_entry_output_section, codeblock_fence_for_path},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::{anyhow, Result};
-use assistant_slash_command::SlashCommandOutputSection;
 use collections::HashMap;
 use editor::Editor;
 use gpui::{AppContext, Entity, Task, WeakView};
 use language::LspAdapterDelegate;
 use std::{fmt::Write, sync::Arc};
-use ui::{IntoElement, WindowContext};
+use ui::WindowContext;
 use workspace::Workspace;
 
 pub(crate) struct TabsSlashCommand;
@@ -89,20 +88,12 @@ impl SlashCommand for TabsSlashCommand {
                     }
                     writeln!(text, "```\n").unwrap();
                     let section_end_ix = text.len() - 1;
-
-                    sections.push(SlashCommandOutputSection {
-                        range: section_start_ix..section_end_ix,
-                        render_placeholder: Arc::new(move |id, unfold, _| {
-                            EntryPlaceholder {
-                                id,
-                                path: full_path.clone(),
-                                is_directory: false,
-                                line_range: None,
-                                unfold,
-                            }
-                            .into_any_element()
-                        }),
-                    });
+                    sections.push(build_entry_output_section(
+                        section_start_ix..section_end_ix,
+                        full_path.as_deref(),
+                        false,
+                        None,
+                    ));
                 }
 
                 Ok(SlashCommandOutput {

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -1,14 +1,15 @@
 mod slash_command_registry;
 
 use anyhow::Result;
-use gpui::{AnyElement, AppContext, ElementId, Task, WeakView, WindowContext};
+use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
 use language::{CodeLabel, LspAdapterDelegate};
+use serde::{Deserialize, Serialize};
 pub use slash_command_registry::*;
 use std::{
     ops::Range,
     sync::{atomic::AtomicBool, Arc},
 };
-use workspace::Workspace;
+use workspace::{ui::IconName, Workspace};
 
 pub fn init(cx: &mut AppContext) {
     SlashCommandRegistry::default_global(cx);
@@ -55,8 +56,9 @@ pub struct SlashCommandOutput {
     pub run_commands_in_text: bool,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Serialize, Deserialize)]
 pub struct SlashCommandOutputSection<T> {
     pub range: Range<T>,
-    pub render_placeholder: RenderFoldPlaceholder,
+    pub icon: IconName,
+    pub label: SharedString,
 }

crates/extension/src/extension_slash_command.rs 🔗

@@ -3,9 +3,9 @@ use std::sync::{atomic::AtomicBool, Arc};
 use anyhow::{anyhow, Result};
 use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
 use futures::FutureExt;
-use gpui::{AppContext, IntoElement, Task, WeakView, WindowContext};
+use gpui::{AppContext, Task, WeakView, WindowContext};
 use language::LspAdapterDelegate;
-use ui::{prelude::*, ButtonLike, ElevationIndex};
+use ui::prelude::*;
 use wasmtime_wasi::WasiView;
 use workspace::Workspace;
 
@@ -87,18 +87,8 @@ impl SlashCommand for ExtensionSlashCommand {
                 text,
                 sections: vec![SlashCommandOutputSection {
                     range,
-                    render_placeholder: Arc::new({
-                        let command_name = command_name.clone();
-                        move |id, unfold, _cx| {
-                            ButtonLike::new(id)
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ElevatedSurface)
-                                .child(Icon::new(IconName::Code))
-                                .child(Label::new(command_name.clone()))
-                                .on_click(move |_event, cx| unfold(cx))
-                                .into_any_element()
-                        }
-                    }),
+                    icon: IconName::Code,
+                    label: command_name,
                 }],
                 run_commands_in_text: false,
             })

crates/ui/Cargo.toml 🔗

@@ -17,6 +17,7 @@ chrono.workspace = true
 gpui.workspace = true
 itertools = { workspace = true, optional = true }
 menu.workspace = true
+serde.workspace = true
 settings.workspace = true
 smallvec.workspace = true
 story = { workspace = true, optional = true }

crates/ui/src/components/icon.rs 🔗

@@ -1,4 +1,5 @@
 use gpui::{svg, AnimationElement, Hsla, IntoElement, Rems, Transformation};
+use serde::{Deserialize, Serialize};
 use strum::EnumIter;
 
 use crate::{prelude::*, Indicator};
@@ -76,7 +77,7 @@ impl IconSize {
     }
 }
 
-#[derive(Debug, PartialEq, Copy, Clone, EnumIter)]
+#[derive(Debug, PartialEq, Copy, Clone, EnumIter, Serialize, Deserialize)]
 pub enum IconName {
     Ai,
     ArrowCircle,