Allow saving prompts from the Prompt Manager (#12359)

Nate Butler created

Adds the following features to the prompt manager:

- New prompt – Create a new prompt from the UI. It will only persist if
it is saved.
- Save prompt – Save a prompt by clicking the save button in the UI. A
keybinding will be added for this in the future.
- Reveal prompt - Show the selected prompt on the file system. Only
available for saved prompts.

New prompts that are saved will use the
`{slugified_title}_{ver}_{id}.md` format which all imported prompts will
move to in the near future.

Also orders prompts in alphabetical order by default.

Release Notes:

- N/A

Change summary

assets/icons/reveal.svg                              |   1 
assets/icons/save.svg                                |   1 
crates/assistant/src/assistant_panel.rs              |   6 
crates/assistant/src/prompts.rs                      |  10 
crates/assistant/src/prompts/prompt.rs               | 154 +++++++
crates/assistant/src/prompts/prompt_library.rs       | 112 ++++-
crates/assistant/src/prompts/prompt_manager.rs       | 275 +++++++++++--
crates/assistant/src/slash_command/prompt_command.rs |   2 
crates/ui/src/components/icon.rs                     |   4 
9 files changed, 476 insertions(+), 89 deletions(-)

Detailed changes

assets/icons/reveal.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-search"><circle cx="17" cy="17" r="3"/><path d="M10.7 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v4.1"/><path d="m21 21-1.5-1.5"/></svg>

assets/icons/save.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,12 +1,10 @@
 use crate::ambient_context::{AmbientContext, ContextUpdated, RecentBuffer};
-use crate::prompts::prompt_library::PromptLibrary;
-use crate::prompts::prompt_manager::PromptManager;
+use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
 use crate::{
     ambient_context::*,
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
     omit_ranges::text_in_range_omitting_ranges,
-    prompts::prompt::generate_content_prompt,
     search::*,
     slash_command::{
         current_file_command, file_command, prompt_command, SlashCommandCleanup,
@@ -148,7 +146,7 @@ impl AssistantPanel {
                 .unwrap_or_default();
 
             let prompt_library = Arc::new(
-                PromptLibrary::load(fs.clone())
+                PromptLibrary::load_index(fs.clone())
                     .await
                     .log_err()
                     .unwrap_or_default(),

crates/assistant/src/prompts.rs 🔗

@@ -1,3 +1,7 @@
-pub mod prompt;
-pub mod prompt_library;
-pub mod prompt_manager;
+mod prompt;
+mod prompt_library;
+mod prompt_manager;
+
+pub use prompt::*;
+pub use prompt_library::*;
+pub use prompt_manager::*;

crates/assistant/src/prompts/prompt.rs 🔗

@@ -1,10 +1,34 @@
+use fs::Fs;
 use language::BufferSnapshot;
-use std::{fmt::Write, ops::Range};
+use std::{fmt::Write, ops::Range, path::PathBuf, sync::Arc};
 use ui::SharedString;
+use util::paths::PROMPTS_DIR;
 
 use gray_matter::{engine::YAML, Matter};
 use serde::{Deserialize, Serialize};
 
+use super::prompt_library::PromptId;
+
+pub const PROMPT_DEFAULT_TITLE: &str = "Untitled Prompt";
+
+fn standardize_value(value: String) -> String {
+    value.replace(['\n', '\r', '"', '\''], "")
+}
+
+fn slugify(input: String) -> String {
+    let mut slug = String::new();
+    for c in input.chars() {
+        if c.is_alphanumeric() {
+            slug.push(c.to_ascii_lowercase());
+        } else if c.is_whitespace() {
+            slug.push('-');
+        } else {
+            slug.push('_');
+        }
+    }
+    slug
+}
+
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 pub struct StaticPromptFrontmatter {
     title: String,
@@ -19,15 +43,51 @@ pub struct StaticPromptFrontmatter {
 impl Default for StaticPromptFrontmatter {
     fn default() -> Self {
         Self {
-            title: "Untitled Prompt".to_string(),
+            title: PROMPT_DEFAULT_TITLE.to_string(),
             version: "1.0".to_string(),
-            author: "No Author".to_string(),
-            languages: vec!["*".to_string()],
+            author: "You <you@email.com>".to_string(),
+            languages: vec![],
             dependencies: vec![],
         }
     }
 }
 
+impl StaticPromptFrontmatter {
+    /// Returns the frontmatter as a markdown frontmatter string
+    pub fn frontmatter_string(&self) -> String {
+        let mut frontmatter = format!(
+            "---\ntitle: \"{}\"\nversion: \"{}\"\nauthor: \"{}\"\n",
+            standardize_value(self.title.clone()),
+            standardize_value(self.version.clone()),
+            standardize_value(self.author.clone()),
+        );
+
+        if !self.languages.is_empty() {
+            let languages = self
+                .languages
+                .iter()
+                .map(|l| standardize_value(l.clone()))
+                .collect::<Vec<String>>()
+                .join(", ");
+            writeln!(frontmatter, "languages: [{}]", languages).unwrap();
+        }
+
+        if !self.dependencies.is_empty() {
+            let dependencies = self
+                .dependencies
+                .iter()
+                .map(|d| standardize_value(d.clone()))
+                .collect::<Vec<String>>()
+                .join(", ");
+            writeln!(frontmatter, "dependencies: [{}]", dependencies).unwrap();
+        }
+
+        frontmatter.push_str("---\n");
+
+        frontmatter
+    }
+}
+
 /// A static prompt that can be loaded into the prompt library
 /// from Markdown with a frontmatter header
 ///
@@ -62,16 +122,39 @@ impl Default for StaticPromptFrontmatter {
 /// ```
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 pub struct StaticPrompt {
+    #[serde(skip_deserializing)]
+    id: PromptId,
     #[serde(skip)]
     metadata: StaticPromptFrontmatter,
     content: String,
-    file_name: Option<String>,
+    file_name: Option<SharedString>,
+}
+
+impl Default for StaticPrompt {
+    fn default() -> Self {
+        let metadata = StaticPromptFrontmatter::default();
+
+        let content = metadata.clone().frontmatter_string();
+
+        Self {
+            id: PromptId::new(),
+            metadata,
+            content,
+            file_name: None,
+        }
+    }
 }
 
 impl StaticPrompt {
     pub fn new(content: String, file_name: Option<String>) -> Self {
         let matter = Matter::<YAML>::new();
         let result = matter.parse(&content);
+        let file_name = if let Some(file_name) = file_name {
+            let shared_filename: SharedString = file_name.into();
+            Some(shared_filename)
+        } else {
+            None
+        };
 
         let metadata = result
             .data
@@ -91,19 +174,48 @@ impl StaticPrompt {
                 StaticPromptFrontmatter::default()
             });
 
+        let id = if let Some(file_name) = &file_name {
+            PromptId::from_str(file_name).unwrap_or_default()
+        } else {
+            PromptId::new()
+        };
+
         StaticPrompt {
+            id,
             content,
             file_name,
             metadata,
         }
     }
+
+    pub fn update(&mut self, id: PromptId, content: String) {
+        let mut updated_prompt =
+            StaticPrompt::new(content, self.file_name.clone().map(|s| s.to_string()));
+        updated_prompt.id = id;
+        *self = updated_prompt;
+    }
 }
 
 impl StaticPrompt {
+    /// Returns the prompt's id
+    pub fn id(&self) -> &PromptId {
+        &self.id
+    }
+
+    pub fn file_name(&self) -> Option<&SharedString> {
+        self.file_name.as_ref()
+    }
+
     /// Sets the file name of the prompt
-    pub fn _file_name(&mut self, file_name: String) -> &mut Self {
-        self.file_name = Some(file_name);
-        self
+    pub fn new_file_name(&self) -> String {
+        let in_name = format!(
+            "{}_{}_{}",
+            standardize_value(self.metadata.title.clone()),
+            standardize_value(self.metadata.version.clone()),
+            standardize_value(self.id.0.to_string())
+        );
+        let out_name = slugify(in_name);
+        out_name
     }
 
     /// Returns the prompt's content
@@ -126,6 +238,32 @@ impl StaticPrompt {
         let result = matter.parse(self.content.as_str());
         result.content.clone()
     }
+
+    pub fn path(&self) -> Option<PathBuf> {
+        if let Some(file_name) = self.file_name() {
+            let path_str = format!("{}", file_name);
+            Some(PROMPTS_DIR.join(path_str))
+        } else {
+            None
+        }
+    }
+
+    pub async fn save(&self, fs: Arc<dyn Fs>) -> anyhow::Result<()> {
+        let file_name = self.file_name();
+        let new_file_name = self.new_file_name();
+
+        let out_name = if let Some(file_name) = file_name {
+            file_name.to_owned().to_string()
+        } else {
+            format!("{}.md", new_file_name)
+        };
+        let path = PROMPTS_DIR.join(&out_name);
+        let json = self.content.clone();
+
+        fs.atomic_write(path, json).await?;
+
+        Ok(())
+    }
 }
 
 pub fn generate_content_prompt(

crates/assistant/src/prompts/prompt_library.rs 🔗

@@ -25,6 +25,16 @@ impl PromptId {
     pub fn new() -> Self {
         Self(Uuid::new_v4())
     }
+
+    pub fn from_str(id: &str) -> anyhow::Result<Self> {
+        Ok(Self(Uuid::parse_str(id)?))
+    }
+}
+
+impl Default for PromptId {
+    fn default() -> Self {
+        Self::new()
+    }
 }
 
 #[derive(Default, Serialize, Deserialize)]
@@ -56,13 +66,20 @@ impl PromptLibrary {
         }
     }
 
-    pub fn prompts(&self) -> Vec<(PromptId, StaticPrompt)> {
+    pub fn new_prompt(&self) -> StaticPrompt {
+        StaticPrompt::default()
+    }
+
+    pub fn add_prompt(&self, prompt: StaticPrompt) {
+        let mut state = self.state.write();
+        let id = *prompt.id();
+        state.prompts.insert(id, prompt);
+        state.version += 1;
+    }
+
+    pub fn prompts(&self) -> HashMap<PromptId, StaticPrompt> {
         let state = self.state.read();
-        state
-            .prompts
-            .iter()
-            .map(|(id, prompt)| (*id, prompt.clone()))
-            .collect()
+        state.prompts.clone()
     }
 
     pub fn sorted_prompts(&self, sort_order: SortOrder) -> Vec<(PromptId, StaticPrompt)> {
@@ -81,36 +98,37 @@ impl PromptLibrary {
         prompts
     }
 
+    pub fn prompt_by_id(&self, id: PromptId) -> Option<StaticPrompt> {
+        let state = self.state.read();
+        state.prompts.get(&id).cloned()
+    }
+
     pub fn first_prompt_id(&self) -> Option<PromptId> {
         let state = self.state.read();
         state.prompts.keys().next().cloned()
     }
 
-    pub fn prompt(&self, id: PromptId) -> Option<StaticPrompt> {
+    pub fn is_dirty(&self, id: &PromptId) -> bool {
         let state = self.state.read();
-        state.prompts.get(&id).cloned()
+        state.dirty_prompts.contains(&id)
     }
 
-    /// Save the current state of the prompt library to the
-    /// file system as a JSON file
-    pub async fn save(&self, fs: Arc<dyn Fs>) -> anyhow::Result<()> {
-        fs.create_dir(&PROMPTS_DIR).await?;
-
-        let path = PROMPTS_DIR.join("index.json");
-
-        let json = {
-            let state = self.state.read();
-            serde_json::to_string(&*state)?
-        };
-
-        fs.atomic_write(path, json).await?;
-
-        Ok(())
+    pub fn set_dirty(&self, id: PromptId, dirty: bool) {
+        let mut state = self.state.write();
+        if dirty {
+            if !state.dirty_prompts.contains(&id) {
+                state.dirty_prompts.push(id);
+            }
+            state.version += 1;
+        } else {
+            state.dirty_prompts.retain(|&i| i != id);
+            state.version += 1;
+        }
     }
 
     /// Load the state of the prompt library from the file system
     /// or create a new one if it doesn't exist
-    pub async fn load(fs: Arc<dyn Fs>) -> anyhow::Result<Self> {
+    pub async fn load_index(fs: Arc<dyn Fs>) -> anyhow::Result<Self> {
         let path = PROMPTS_DIR.join("index.json");
 
         let state = if fs.is_file(&path).await {
@@ -132,9 +150,6 @@ impl PromptLibrary {
     /// Load all prompts from the file system
     /// adding them to the library if they don't already exist
     pub async fn load_prompts(&mut self, fs: Arc<dyn Fs>) -> anyhow::Result<()> {
-        // let current_prompts = self.all_prompt_contents().clone();
-
-        // For now, we'll just clear the prompts and reload them all
         self.state.get_mut().prompts.clear();
 
         let mut prompt_paths = fs.read_dir(&PROMPTS_DIR).await?;
@@ -182,7 +197,48 @@ impl PromptLibrary {
         }
 
         // Write any changes back to the file system
-        self.save(fs.clone()).await?;
+        self.save_index(fs.clone()).await?;
+
+        Ok(())
+    }
+
+    /// Save the current state of the prompt library to the
+    /// file system as a JSON file
+    pub async fn save_index(&self, fs: Arc<dyn Fs>) -> anyhow::Result<()> {
+        fs.create_dir(&PROMPTS_DIR).await?;
+
+        let path = PROMPTS_DIR.join("index.json");
+
+        let json = {
+            let state = self.state.read();
+            serde_json::to_string(&*state)?
+        };
+
+        fs.atomic_write(path, json).await?;
+
+        Ok(())
+    }
+
+    pub async fn save_prompt(
+        &self,
+        prompt_id: PromptId,
+        updated_content: Option<String>,
+        fs: Arc<dyn Fs>,
+    ) -> anyhow::Result<()> {
+        if let Some(updated_content) = updated_content {
+            let mut state = self.state.write();
+            if let Some(prompt) = state.prompts.get_mut(&prompt_id) {
+                prompt.update(prompt_id, updated_content);
+                state.version += 1;
+            }
+        }
+
+        if let Some(prompt) = self.prompt_by_id(prompt_id) {
+            prompt.save(fs).await?;
+            self.set_dirty(prompt_id, false);
+        } else {
+            log::warn!("Failed to save prompt: {:?}", prompt_id);
+        }
 
         Ok(())
     }

crates/assistant/src/prompts/prompt_manager.rs 🔗

@@ -1,16 +1,17 @@
 use collections::HashMap;
-use editor::Editor;
+use editor::{Editor, EditorEvent};
 use fs::Fs;
 use gpui::{prelude::FluentBuilder, *};
 use language::{language_settings, Buffer, LanguageRegistry};
 use picker::{Picker, PickerDelegate};
 use std::sync::Arc;
-use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing};
+use ui::{prelude::*, IconButtonShape, Indicator, ListItem, ListItemSpacing, Tooltip};
 use util::{ResultExt, TryFutureExt};
 use workspace::ModalView;
 
-use super::prompt_library::{PromptId, PromptLibrary, SortOrder};
-use crate::prompts::prompt::StaticPrompt;
+use crate::prompts::{PromptId, PromptLibrary, SortOrder, StaticPrompt, PROMPT_DEFAULT_TITLE};
+
+actions!(prompt_manager, [NewPrompt, SavePrompt]);
 
 pub struct PromptManager {
     focus_handle: FocusHandle,
@@ -21,6 +22,8 @@ pub struct PromptManager {
     picker: View<Picker<PromptManagerDelegate>>,
     prompt_editors: HashMap<PromptId, View<Editor>>,
     active_prompt_id: Option<PromptId>,
+    last_new_prompt_id: Option<PromptId>,
+    _subscriptions: Vec<Subscription>,
 }
 
 impl PromptManager {
@@ -39,6 +42,7 @@ impl PromptManager {
                     matching_prompt_ids: vec![],
                     prompt_library: prompt_library.clone(),
                     selected_index: 0,
+                    _subscriptions: vec![],
                 },
                 cx,
             )
@@ -48,6 +52,11 @@ impl PromptManager {
 
         let focus_handle = picker.focus_handle(cx);
 
+        let subscriptions = vec![
+            // cx.on_focus_in(&focus_handle, Self::focus_in),
+            // cx.on_focus_out(&focus_handle, Self::focus_out),
+        ];
+
         let mut manager = Self {
             focus_handle,
             prompt_library,
@@ -56,6 +65,8 @@ impl PromptManager {
             picker,
             prompt_editors: HashMap::default(),
             active_prompt_id: None,
+            last_new_prompt_id: None,
+            _subscriptions: subscriptions,
         };
 
         manager.active_prompt_id = manager.prompt_library.first_prompt_id();
@@ -63,11 +74,105 @@ impl PromptManager {
         manager
     }
 
+    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
+        let mut dispatch_context = KeyContext::new_with_defaults();
+        dispatch_context.add("PromptManager");
+
+        let identifier = match self.active_editor() {
+            Some(active_editor) if active_editor.focus_handle(cx).is_focused(cx) => "editing",
+            _ => "not_editing",
+        };
+
+        dispatch_context.add(identifier);
+        dispatch_context
+    }
+
+    pub fn new_prompt(&mut self, _: &NewPrompt, cx: &mut ViewContext<Self>) {
+        // TODO: Why doesn't this prevent making a new prompt if you
+        // move the picker selection/maybe unfocus the editor?
+
+        // Prevent making a new prompt if the last new prompt is still empty
+        //
+        // Instead, we'll focus the last new prompt
+        if let Some(last_new_prompt_id) = self.last_new_prompt_id() {
+            if let Some(last_new_prompt) = self.prompt_library.prompt_by_id(last_new_prompt_id) {
+                let normalized_body = last_new_prompt
+                    .body()
+                    .trim()
+                    .replace(['\r', '\n'], "")
+                    .to_string();
+
+                if last_new_prompt.title() == PROMPT_DEFAULT_TITLE && normalized_body.is_empty() {
+                    self.set_editor_for_prompt(last_new_prompt_id, cx);
+                    self.focus_active_editor(cx);
+                }
+            }
+        }
+
+        let prompt = self.prompt_library.new_prompt();
+        self.set_last_new_prompt_id(Some(prompt.id().to_owned()));
+
+        self.prompt_library.add_prompt(prompt.clone());
+
+        let id = *prompt.id();
+        self.picker.update(cx, |picker, _cx| {
+            let prompts = self
+                .prompt_library
+                .sorted_prompts(SortOrder::Alphabetical)
+                .clone()
+                .into_iter();
+
+            picker.delegate.prompt_library = self.prompt_library.clone();
+            picker.delegate.matching_prompts = prompts.clone().map(|(_, p)| Arc::new(p)).collect();
+            picker.delegate.matching_prompt_ids = prompts.map(|(id, _)| id).collect();
+            picker.delegate.selected_index = picker
+                .delegate
+                .matching_prompts
+                .iter()
+                .position(|p| p.id() == &id)
+                .unwrap_or(0);
+        });
+
+        self.active_prompt_id = Some(id);
+
+        cx.notify();
+    }
+
+    pub fn save_prompt(
+        &mut self,
+        fs: Arc<dyn Fs>,
+        prompt_id: PromptId,
+        new_content: String,
+        cx: &mut ViewContext<Self>,
+    ) -> Result<()> {
+        let library = self.prompt_library.clone();
+        if library.prompt_by_id(prompt_id).is_some() {
+            cx.spawn(|_, _| async move {
+                library
+                    .save_prompt(prompt_id, Some(new_content), fs)
+                    .log_err()
+                    .await;
+            })
+            .detach();
+            cx.notify();
+        }
+
+        Ok(())
+    }
+
     pub fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
         self.active_prompt_id = prompt_id;
         cx.notify();
     }
 
+    pub fn last_new_prompt_id(&self) -> Option<PromptId> {
+        self.last_new_prompt_id
+    }
+
+    pub fn set_last_new_prompt_id(&mut self, id: Option<PromptId>) {
+        self.last_new_prompt_id = id;
+    }
+
     pub fn focus_active_editor(&self, cx: &mut ViewContext<Self>) {
         if let Some(active_prompt_id) = self.active_prompt_id {
             if let Some(editor) = self.prompt_editors.get(&active_prompt_id) {
@@ -78,38 +183,9 @@ impl PromptManager {
         }
     }
 
-    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
-        cx.emit(DismissEvent);
-    }
-
-    fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let picker = self.picker.clone();
-
-        v_flex()
-            .id("prompt-list")
-            .bg(cx.theme().colors().surface_background)
-            .h_full()
-            .w_2_5()
-            .child(
-                h_flex()
-                    .bg(cx.theme().colors().background)
-                    .p(Spacing::Small.rems(cx))
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .h(rems(1.75))
-                    .w_full()
-                    .flex_none()
-                    .justify_between()
-                    .child(Label::new("Prompt Library").size(LabelSize::Small))
-                    .child(IconButton::new("new-prompt", IconName::Plus).disabled(true)),
-            )
-            .child(
-                v_flex()
-                    .h(rems(38.25))
-                    .flex_grow()
-                    .justify_start()
-                    .child(picker),
-            )
+    pub fn active_editor(&self) -> Option<&View<Editor>> {
+        self.active_prompt_id
+            .and_then(|active_prompt_id| self.prompt_editors.get(&active_prompt_id))
     }
 
     fn set_editor_for_prompt(
@@ -121,7 +197,7 @@ impl PromptManager {
 
         let editor_for_prompt = self.prompt_editors.entry(prompt_id).or_insert_with(|| {
             cx.new_view(|cx| {
-                let text = if let Some(prompt) = prompt_library.prompt(prompt_id) {
+                let text = if let Some(prompt) = prompt_library.prompt_by_id(prompt_id) {
                     prompt.content().to_owned()
                 } else {
                     "".to_string()
@@ -147,17 +223,76 @@ impl PromptManager {
                 editor
             })
         });
+
         editor_for_prompt.clone()
     }
+
+    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let picker = self.picker.clone();
+
+        v_flex()
+            .id("prompt-list")
+            .bg(cx.theme().colors().surface_background)
+            .h_full()
+            .w_1_3()
+            .overflow_hidden()
+            .child(
+                h_flex()
+                    .bg(cx.theme().colors().background)
+                    .p(Spacing::Small.rems(cx))
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .h(rems(1.75))
+                    .w_full()
+                    .flex_none()
+                    .justify_between()
+                    .child(Label::new("Prompt Library").size(LabelSize::Small))
+                    .child(
+                        IconButton::new("new-prompt", IconName::Plus)
+                            .shape(IconButtonShape::Square)
+                            .tooltip(move |cx| Tooltip::text("New Prompt", cx))
+                            .on_click(|_, cx| {
+                                cx.dispatch_action(NewPrompt.boxed_clone());
+                            }),
+                    ),
+            )
+            .child(
+                v_flex()
+                    .h(rems(38.25))
+                    .flex_grow()
+                    .justify_start()
+                    .child(picker),
+            )
+    }
 }
 
 impl Render for PromptManager {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let active_prompt_id = self.active_prompt_id;
+        let active_prompt = if let Some(active_prompt_id) = active_prompt_id {
+            self.prompt_library.clone().prompt_by_id(active_prompt_id)
+        } else {
+            None
+        };
+        let active_editor = self.active_editor().map(|editor| editor.clone());
+        let updated_content = if let Some(editor) = active_editor {
+            Some(editor.read(cx).text(cx))
+        } else {
+            None
+        };
+        let can_save = active_prompt_id.is_some() && updated_content.is_some();
+        let fs = self.fs.clone();
+
         h_flex()
-            .key_context("PromptManager")
+            .id("prompt-manager")
+            .key_context(self.dispatch_context(cx))
             .track_focus(&self.focus_handle)
             .on_action(cx.listener(Self::dismiss))
-            // .on_action(cx.listener(Self::save_active_prompt))
+            .on_action(cx.listener(Self::new_prompt))
             .elevation_3(cx)
             .size_full()
             .flex_none()
@@ -166,7 +301,7 @@ impl Render for PromptManager {
             .overflow_hidden()
             .child(self.render_prompt_list(cx))
             .child(
-                div().w_3_5().h_full().child(
+                div().w_2_3().h_full().child(
                     v_flex()
                         .id("prompt-editor")
                         .border_l_1()
@@ -176,6 +311,7 @@ impl Render for PromptManager {
                         .flex_none()
                         .min_w_64()
                         .h_full()
+                        .overflow_hidden()
                         .child(
                             h_flex()
                                 .bg(cx.theme().colors().background)
@@ -185,16 +321,60 @@ impl Render for PromptManager {
                                 .h_7()
                                 .w_full()
                                 .justify_between()
-                                .child(div())
+                                .child(
+                                    h_flex()
+                                        .gap(Spacing::XXLarge.rems(cx))
+                                        .child(if can_save {
+                                            IconButton::new("save", IconName::Save)
+                                                .shape(IconButtonShape::Square)
+                                                .tooltip(move |cx| Tooltip::text("Save Prompt", cx))
+                                                .on_click(cx.listener(move |this, _event, cx| {
+                                                    if let Some(prompt_id) = active_prompt_id {
+                                                        this.save_prompt(
+                                                            fs.clone(),
+                                                            prompt_id,
+                                                            updated_content.clone().unwrap_or(
+                                                                "TODO: make unreachable"
+                                                                    .to_string(),
+                                                            ),
+                                                            cx,
+                                                        )
+                                                        .log_err();
+                                                    }
+                                                }))
+                                        } else {
+                                            IconButton::new("save", IconName::Save)
+                                                .shape(IconButtonShape::Square)
+                                                .disabled(true)
+                                        })
+                                        .when_some(active_prompt, |this, active_prompt| {
+                                            let path = active_prompt.path();
+
+                                            this.child(
+                                                IconButton::new("reveal", IconName::Reveal)
+                                                    .shape(IconButtonShape::Square)
+                                                    .disabled(path.is_none())
+                                                    .tooltip(move |cx| {
+                                                        Tooltip::text("Reveal in Finder", cx)
+                                                    })
+                                                    .on_click(cx.listener(move |_, _event, cx| {
+                                                        if let Some(path) = path.clone() {
+                                                            cx.reveal_path(&path);
+                                                        }
+                                                    })),
+                                            )
+                                        }),
+                                )
                                 .child(
                                     IconButton::new("dismiss", IconName::Close)
                                         .shape(IconButtonShape::Square)
+                                        .tooltip(move |cx| Tooltip::text("Close", cx))
                                         .on_click(|_, cx| {
                                             cx.dispatch_action(menu::Cancel.boxed_clone());
                                         }),
                                 ),
                         )
-                        .when_some(self.active_prompt_id, |this, active_prompt_id| {
+                        .when_some(active_prompt_id, |this, active_prompt_id| {
                             this.child(
                                 h_flex()
                                     .flex_1()
@@ -210,6 +390,8 @@ impl Render for PromptManager {
 }
 
 impl EventEmitter<DismissEvent> for PromptManager {}
+impl EventEmitter<EditorEvent> for PromptManager {}
+
 impl ModalView for PromptManager {}
 
 impl FocusableView for PromptManager {
@@ -224,6 +406,7 @@ pub struct PromptManagerDelegate {
     matching_prompt_ids: Vec<PromptId>,
     prompt_library: Arc<PromptLibrary>,
     selected_index: usize,
+    _subscriptions: Vec<Subscription>,
 }
 
 impl PickerDelegate for PromptManagerDelegate {
@@ -313,15 +496,17 @@ impl PickerDelegate for PromptManagerDelegate {
         selected: bool,
         _cx: &mut ViewContext<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let matching_prompt = self.matching_prompts.get(ix)?;
-        let prompt = matching_prompt.clone();
+        let prompt = self.matching_prompts.get(ix)?;
+
+        let is_diry = self.prompt_library.is_dirty(prompt.id());
 
         Some(
             ListItem::new(ix)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
-                .child(Label::new(prompt.title())),
+                .child(Label::new(prompt.title()))
+                .end_slot(div().when(is_diry, |this| this.child(Indicator::dot()))),
         )
     }
 }

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

@@ -1,5 +1,5 @@
 use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
-use crate::prompts::prompt_library::PromptLibrary;
+use crate::prompts::PromptLibrary;
 use anyhow::{anyhow, Context, Result};
 use futures::channel::oneshot;
 use fuzzy::StringMatchCandidate;

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

@@ -164,6 +164,8 @@ pub enum IconName {
     ReplaceNext,
     ReplyArrowRight,
     Return,
+    Reveal,
+    Save,
     Screen,
     SelectAll,
     Server,
@@ -277,10 +279,12 @@ impl IconName {
             IconName::Quote => "icons/quote.svg",
             IconName::Regex => "icons/regex.svg",
             IconName::Replace => "icons/replace.svg",
+            IconName::Reveal => "icons/reveal.svg",
             IconName::ReplaceAll => "icons/replace_all.svg",
             IconName::ReplaceNext => "icons/replace_next.svg",
             IconName::ReplyArrowRight => "icons/reply_arrow_right.svg",
             IconName::Return => "icons/return.svg",
+            IconName::Save => "icons/save.svg",
             IconName::Screen => "icons/desktop.svg",
             IconName::SelectAll => "icons/select_all.svg",
             IconName::Server => "icons/server.svg",