Start on a database-backed prompt library (#12468)

Using the file system as a database seems like it's easy, but it's
actually a real pain. I'd like to use LMDB to store the prompts locally
so we have more control. We can always add an export option, but I want
the source of truth to be somewhere other than the file system.

So far, I have a PromptStore which is global to the application and can
be initialized on startup. Then there's a `PromptLibrary` which is
intended to be the root of a new kind of Zed window. I haven't actually
seen pixels yet, but I've sketched out the basics needed to create a new
prompt, save, etc.

Still lots to figure out but the foundations of being backed by a DB and
rendering in an independent window are in place.

/cc @iamnbutler @as-cii 

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
This commit is contained in:
Nathan Sobo 2024-06-03 07:58:43 -06:00 committed by GitHub
parent 18e2b43d6d
commit 5f98b9617a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1427 additions and 1429 deletions

View file

@ -16,18 +16,19 @@ doctest = false
anyhow.workspace = true
anthropic = { workspace = true, features = ["schemars"] }
assistant_slash_command.workspace = true
async-watch.workspace = true
cargo_toml.workspace = true
chrono.workspace = true
client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
editor.workspace = true
feature_flags.workspace = true
file_icons.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
heed.workspace = true
http.workspace = true
indoc.workspace = true
language.workspace = true
@ -59,7 +60,6 @@ util.workspace = true
uuid.workspace = true
workspace.workspace = true
picker.workspace = true
gray_matter = "0.2.7"
[dev-dependencies]
ctor.workspace = true

View file

@ -3,6 +3,7 @@ pub mod assistant_settings;
mod codegen;
mod completion_provider;
mod model_selector;
mod prompt_library;
mod prompts;
mod saved_conversation;
mod search;
@ -12,15 +13,21 @@ mod streaming_diff;
pub use assistant_panel::AssistantPanel;
use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel};
use assistant_slash_command::SlashCommandRegistry;
use client::{proto, Client};
use command_palette_hooks::CommandPaletteFilter;
pub(crate) use completion_provider::*;
use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
pub(crate) use model_selector::*;
use prompt_library::PromptStore;
pub(crate) use saved_conversation::*;
use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use slash_command::{
active_command, file_command, project_command, prompt_command, rustdoc_command, search_command,
tabs_command,
};
use std::{
fmt::{self, Display},
sync::Arc,
@ -251,8 +258,11 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
}
})
.detach();
prompt_library::init(cx);
completion_provider::init(client, cx);
assistant_slash_command::init(cx);
register_slash_commands(cx);
assistant_panel::init(cx);
CommandPaletteFilter::update_global(cx, |filter, _cx| {
@ -266,13 +276,32 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
cx.observe_global::<SettingsStore>(|cx| {
Assistant::update_global(cx, |assistant, cx| {
let settings = AssistantSettings::get_global(cx);
assistant.set_enabled(settings.enabled, cx);
});
})
.detach();
}
fn register_slash_commands(cx: &mut AppContext) {
let slash_command_registry = SlashCommandRegistry::global(cx);
slash_command_registry.register_command(file_command::FileSlashCommand, true);
slash_command_registry.register_command(active_command::ActiveSlashCommand, true);
slash_command_registry.register_command(tabs_command::TabsSlashCommand, true);
slash_command_registry.register_command(project_command::ProjectSlashCommand, true);
slash_command_registry.register_command(search_command::SearchSlashCommand, true);
slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false);
let store = PromptStore::global(cx);
cx.background_executor()
.spawn(async move {
let store = store.await?;
slash_command_registry
.register_command(prompt_command::PromptSlashCommand::new(store), true);
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
#[cfg(test)]
#[ctor::ctor]
fn init_logger() {

View file

@ -1,19 +1,18 @@
use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
use crate::slash_command::{rustdoc_command, search_command, tabs_command};
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings},
codegen::{self, Codegen, CodegenKind},
prompt_library::{open_prompt_library, PromptMetadata, PromptStore},
prompts::generate_content_prompt,
search::*,
slash_command::{
active_command, file_command, project_command, prompt_command,
SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry,
prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine,
SlashCommandRegistry,
},
ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus,
QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
Split, ToggleFocus, ToggleHistory,
ModelSelector, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector,
};
use crate::{ModelSelector, ToggleModelSelector};
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection};
use client::telemetry::Telemetry;
@ -29,19 +28,17 @@ use editor::{
ToOffset as _, ToPoint,
};
use editor::{display_map::FlapId, FoldPlaceholder};
use feature_flags::{FeatureFlag, FeatureFlagAppExt, FeatureFlagViewExt};
use file_icons::FileIcons;
use fs::Fs;
use futures::future::Shared;
use futures::{FutureExt, StreamExt};
use gpui::{
canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty,
EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle,
InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render,
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle,
UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace,
WindowContext,
div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
AsyncAppContext, AsyncWindowContext, ClipboardItem, Context, Empty, EventEmitter, FocusHandle,
FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
WeakModel, WeakView, WhiteSpace, WindowContext,
};
use language::LspAdapterDelegate;
use language::{
@ -111,7 +108,6 @@ pub struct AssistantPanel {
toolbar: View<Toolbar>,
languages: Arc<LanguageRegistry>,
slash_commands: Arc<SlashCommandRegistry>,
prompt_library: Arc<PromptLibrary>,
fs: Arc<dyn Fs>,
telemetry: Arc<Telemetry>,
_subscriptions: Vec<Subscription>,
@ -122,6 +118,14 @@ pub struct AssistantPanel {
_watch_saved_conversations: Task<Result<()>>,
authentication_prompt: Option<AnyView>,
model_menu_handle: PopoverMenuHandle<ContextMenu>,
default_prompt: DefaultPrompt,
_watch_prompt_store: Task<()>,
}
#[derive(Default)]
struct DefaultPrompt {
text: String,
sections: Vec<SlashCommandOutputSection<usize>>,
}
struct ActiveConversationEditor {
@ -129,12 +133,6 @@ struct ActiveConversationEditor {
_subscriptions: Vec<Subscription>,
}
struct PromptLibraryFeatureFlag;
impl FeatureFlag for PromptLibraryFeatureFlag {
const NAME: &'static str = "prompt-library";
}
impl AssistantPanel {
const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20;
@ -148,21 +146,13 @@ impl AssistantPanel {
.await
.log_err()
.unwrap_or_default();
let prompt_library = Arc::new(
PromptLibrary::load_index(fs.clone())
.await
.log_err()
.unwrap_or_default(),
);
let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?;
let default_prompts = prompt_store.load_default().await?;
// TODO: deserialize state.
let workspace_handle = workspace.clone();
workspace.update(&mut cx, |workspace, cx| {
cx.new_view::<Self>(|cx| {
cx.observe_flag::<PromptLibraryFeatureFlag, _>(|_, _, cx| cx.notify())
.detach();
const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move {
let mut events = fs
@ -183,6 +173,22 @@ impl AssistantPanel {
anyhow::Ok(())
});
let _watch_prompt_store = cx.spawn(|this, mut cx| async move {
let mut updates = prompt_store.updates();
while updates.changed().await.is_ok() {
let Some(prompts) = prompt_store.load_default().await.log_err() else {
continue;
};
if this
.update(&mut cx, |this, _cx| this.update_default_prompt(prompts))
.is_err()
{
break;
}
}
});
let toolbar = cx.new_view(|cx| {
let mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx);
@ -210,24 +216,7 @@ impl AssistantPanel {
})
.detach();
let slash_command_registry = SlashCommandRegistry::global(cx);
slash_command_registry.register_command(file_command::FileSlashCommand, true);
slash_command_registry.register_command(
prompt_command::PromptSlashCommand::new(prompt_library.clone()),
true,
);
slash_command_registry
.register_command(active_command::ActiveSlashCommand, true);
slash_command_registry.register_command(tabs_command::TabsSlashCommand, true);
slash_command_registry
.register_command(project_command::ProjectSlashCommand, true);
slash_command_registry
.register_command(search_command::SearchSlashCommand, true);
slash_command_registry
.register_command(rustdoc_command::RustdocSlashCommand, false);
Self {
let mut this = Self {
workspace: workspace_handle,
active_conversation_editor: None,
show_saved_conversations: false,
@ -237,8 +226,7 @@ impl AssistantPanel {
focus_handle,
toolbar,
languages: workspace.app_state().languages.clone(),
slash_commands: slash_command_registry,
prompt_library,
slash_commands: SlashCommandRegistry::global(cx),
fs: workspace.app_state().fs.clone(),
telemetry: workspace.client().telemetry().clone(),
width: None,
@ -251,7 +239,11 @@ impl AssistantPanel {
_watch_saved_conversations,
authentication_prompt: None,
model_menu_handle: PopoverMenuHandle::default(),
}
default_prompt: DefaultPrompt::default(),
_watch_prompt_store,
};
this.update_default_prompt(default_prompts);
this
})
})
})
@ -274,6 +266,55 @@ impl AssistantPanel {
cx.notify();
}
fn update_default_prompt(&mut self, prompts: Vec<(PromptMetadata, String)>) {
self.default_prompt.text.clear();
self.default_prompt.sections.clear();
if !prompts.is_empty() {
self.default_prompt.text.push_str("Default Prompt:\n");
}
for (metadata, body) in prompts {
let section_start = self.default_prompt.text.len();
self.default_prompt.text.push_str(&body);
let section_end = self.default_prompt.text.len();
self.default_prompt
.sections
.push(SlashCommandOutputSection {
range: section_start..section_end,
render_placeholder: Arc::new(move |id, unfold, _cx| {
PromptPlaceholder {
title: metadata
.title
.clone()
.unwrap_or_else(|| SharedString::from("Untitled")),
id,
unfold,
}
.into_any_element()
}),
});
self.default_prompt.text.push('\n');
}
self.default_prompt.text.pop();
if !self.default_prompt.text.is_empty() {
self.default_prompt.sections.insert(
0,
SlashCommandOutputSection {
range: 0..self.default_prompt.text.len(),
render_placeholder: Arc::new(move |id, unfold, _cx| {
PromptPlaceholder {
title: "Default".into(),
id,
unfold,
}
.into_any_element()
}),
},
)
}
}
fn completion_provider_changed(
&mut self,
prev_settings_version: usize,
@ -823,6 +864,7 @@ impl AssistantPanel {
let editor = cx.new_view(|cx| {
ConversationEditor::new(
&self.default_prompt,
self.languages.clone(),
self.slash_commands.clone(),
self.fs.clone(),
@ -1154,21 +1196,6 @@ impl AssistantPanel {
})
}
fn show_prompt_manager(&mut self, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(cx, |cx| {
PromptManager::new(
self.prompt_library.clone(),
self.languages.clone(),
self.fs.clone(),
cx,
)
})
})
}
}
fn is_authenticated(&mut self, cx: &mut ViewContext<Self>) -> bool {
CompletionProvider::global(cx).is_authenticated()
}
@ -1211,15 +1238,17 @@ impl AssistantPanel {
h_flex()
.gap_1()
.child(self.render_inject_context_menu(cx))
.children(
cx.has_flag::<PromptLibraryFeatureFlag>().then_some(
IconButton::new("show_prompt_manager", IconName::Library)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _event, cx| {
this.show_prompt_manager(cx)
}))
.tooltip(|cx| Tooltip::text("Prompt Library…", cx)),
),
.child(
IconButton::new("show-prompt-library", IconName::Library)
.icon_size(IconSize::Small)
.on_click({
let language_registry = self.languages.clone();
cx.listener(move |_this, _event, cx| {
open_prompt_library(language_registry.clone(), cx)
.detach_and_log_err(cx);
})
})
.tooltip(|cx| Tooltip::text("Prompt Library…", cx)),
),
),
);
@ -1261,30 +1290,18 @@ impl AssistantPanel {
let view = cx.view().clone();
let scroll_handle = self.saved_conversations_scroll_handle.clone();
let conversation_count = self.saved_conversations.len();
canvas(
move |bounds, cx| {
let mut saved_conversations = uniform_list(
view,
"saved_conversations",
conversation_count,
|this, range, cx| {
range
.map(|ix| this.render_saved_conversation(ix, cx))
.collect()
},
)
.track_scroll(scroll_handle)
.into_any_element();
saved_conversations.prepaint_as_root(
bounds.origin,
bounds.size.map(AvailableSpace::Definite),
cx,
);
saved_conversations
uniform_list(
view,
"saved_conversations",
conversation_count,
|this, range, cx| {
range
.map(|ix| this.render_saved_conversation(ix, cx))
.collect()
},
|_bounds, mut saved_conversations, cx| saved_conversations.paint(cx),
)
.size_full()
.track_scroll(scroll_handle)
.into_any_element()
} else if let Some(editor) = self.active_conversation_editor() {
let editor = editor.clone();
@ -2581,6 +2598,7 @@ pub struct ConversationEditor {
impl ConversationEditor {
fn new(
default_prompt: &DefaultPrompt,
language_registry: Arc<LanguageRegistry>,
slash_command_registry: Arc<SlashCommandRegistry>,
fs: Arc<dyn Fs>,
@ -2600,7 +2618,34 @@ impl ConversationEditor {
)
});
Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx)
let mut this =
Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx);
if !default_prompt.text.is_empty() {
this.editor
.update(cx, |editor, cx| editor.insert(&default_prompt.text, cx));
let snapshot = this.conversation.read(cx).buffer.read(cx).text_snapshot();
this.insert_slash_command_output_sections(
default_prompt
.sections
.iter()
.map(|section| SlashCommandOutputSection {
range: snapshot.anchor_after(section.range.start)
..snapshot.anchor_before(section.range.end),
render_placeholder: section.render_placeholder.clone(),
}),
cx,
);
this.split(&Split, cx);
this.conversation.update(cx, |this, _cx| {
this.messages_metadata
.get_mut(&MessageId::default())
.unwrap()
.role = Role::System;
});
}
this
}
fn for_conversation(
@ -2949,61 +2994,68 @@ impl ConversationEditor {
})
}
ConversationEvent::SlashCommandFinished { sections } => {
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
let excerpt_id = *buffer.as_singleton().unwrap().0;
let mut buffer_rows_to_fold = BTreeSet::new();
let mut flaps = Vec::new();
for section in sections {
let start = buffer
.anchor_in_excerpt(excerpt_id, section.range.start)
.unwrap();
let end = buffer
.anchor_in_excerpt(excerpt_id, section.range.end)
.unwrap();
let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
buffer_rows_to_fold.insert(buffer_row);
flaps.push(Flap::new(
start..end,
FoldPlaceholder {
render: Arc::new({
let editor = cx.view().downgrade();
let render_placeholder = section.render_placeholder.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)
}
}),
constrain_width: false,
merge_adjacent: false,
},
render_slash_command_output_toggle,
|_, _, _| Empty.into_any_element(),
));
}
editor.insert_flaps(flaps, cx);
for buffer_row in buffer_rows_to_fold.into_iter().rev() {
editor.fold_at(&FoldAt { buffer_row }, cx);
}
});
self.insert_slash_command_output_sections(sections.iter().cloned(), cx);
}
}
}
fn insert_slash_command_output_sections(
&mut self,
sections: impl IntoIterator<Item = SlashCommandOutputSection<language::Anchor>>,
cx: &mut ViewContext<Self>,
) {
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
let excerpt_id = *buffer.as_singleton().unwrap().0;
let mut buffer_rows_to_fold = BTreeSet::new();
let mut flaps = Vec::new();
for section in sections {
let start = buffer
.anchor_in_excerpt(excerpt_id, section.range.start)
.unwrap();
let end = buffer
.anchor_in_excerpt(excerpt_id, section.range.end)
.unwrap();
let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
buffer_rows_to_fold.insert(buffer_row);
flaps.push(Flap::new(
start..end,
FoldPlaceholder {
render: Arc::new({
let editor = cx.view().downgrade();
let render_placeholder = section.render_placeholder.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)
}
}),
constrain_width: false,
merge_adjacent: false,
},
render_slash_command_output_toggle,
|_, _, _| Empty.into_any_element(),
));
}
editor.insert_flaps(flaps, cx);
for buffer_row in buffer_rows_to_fold.into_iter().rev() {
editor.fold_at(&FoldAt { buffer_row }, cx);
}
});
}
fn handle_editor_event(
&mut self,
_: View<Editor>,
@ -3827,7 +3879,10 @@ fn make_lsp_adapter_delegate(
#[cfg(test)]
mod tests {
use super::*;
use crate::{FakeCompletionProvider, MessageId};
use crate::{
slash_command::{active_command, file_command},
FakeCompletionProvider, MessageId,
};
use fs::FakeFs;
use gpui::{AppContext, TestAppContext};
use rope::Rope;
@ -4177,14 +4232,9 @@ mod tests {
)
.await;
let prompt_library = Arc::new(PromptLibrary::default());
let slash_command_registry = SlashCommandRegistry::new();
slash_command_registry.register_command(file_command::FileSlashCommand, false);
slash_command_registry.register_command(
prompt_command::PromptSlashCommand::new(prompt_library.clone()),
false,
);
slash_command_registry.register_command(active_command::ActiveSlashCommand, false);
let registry = Arc::new(LanguageRegistry::test(cx.executor()));
let conversation = cx

View file

@ -0,0 +1,935 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use collections::HashMap;
use editor::{Editor, EditorEvent};
use futures::{
future::{self, BoxFuture, Shared},
FutureExt,
};
use fuzzy::StringMatchCandidate;
use gpui::{
actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty,
EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View,
WindowBounds, WindowHandle, WindowOptions,
};
use heed::{types::SerdeBincode, Database, RoTxn};
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
use parking_lot::RwLock;
use picker::{Picker, PickerDelegate};
use rope::Rope;
use serde::{Deserialize, Serialize};
use std::{
cmp::Reverse,
future::Future,
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
time::Duration,
};
use ui::{
div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render,
SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext,
};
use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt};
use uuid::Uuid;
actions!(
prompt_library,
[NewPrompt, DeletePrompt, ToggleDefaultPrompt]
);
/// Init starts loading the PromptStore in the background and assigns
/// a shared future to a global.
pub fn init(cx: &mut AppContext) {
let db_path = PROMPTS_DIR.join("prompts-library-db.0.mdb");
let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone())
.then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
.boxed()
.shared();
cx.set_global(GlobalPromptStore(prompt_store_future))
}
/// This function opens a new prompt library window if one doesn't exist already.
/// If one exists, it brings it to the foreground.
///
/// Note that, when opening a new window, this waits for the PromptStore to be
/// initialized. If it was initialized successfully, it returns a window handle
/// to a prompt library.
pub fn open_prompt_library(
language_registry: Arc<LanguageRegistry>,
cx: &mut AppContext,
) -> Task<Result<WindowHandle<PromptLibrary>>> {
let existing_window = cx
.windows()
.into_iter()
.find_map(|window| window.downcast::<PromptLibrary>());
if let Some(existing_window) = existing_window {
existing_window
.update(cx, |_, cx| cx.activate_window())
.ok();
Task::ready(Ok(existing_window))
} else {
let store = PromptStore::global(cx);
cx.spawn(|cx| async move {
let store = store.await?;
cx.update(|cx| {
let bounds = Bounds::centered(
None,
size(DevicePixels::from(1024), DevicePixels::from(768)),
cx,
);
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))),
}),
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)),
)
})
})
}
}
pub struct PromptLibrary {
store: Arc<PromptStore>,
language_registry: Arc<LanguageRegistry>,
prompt_editors: HashMap<PromptId, PromptEditor>,
active_prompt_id: Option<PromptId>,
picker: View<Picker<PromptPickerDelegate>>,
pending_load: Task<()>,
_subscriptions: Vec<Subscription>,
}
struct PromptEditor {
editor: View<Editor>,
next_body_to_save: Option<Rope>,
pending_save: Option<Task<Option<()>>>,
_subscription: Subscription,
}
struct PromptPickerDelegate {
store: Arc<PromptStore>,
selected_index: usize,
matches: Vec<PromptMetadata>,
}
enum PromptPickerEvent {
Confirmed { prompt_id: PromptId },
Deleted { prompt_id: PromptId },
ToggledDefault { prompt_id: PromptId },
}
impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
impl PickerDelegate for PromptPickerDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
"Search...".into()
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
let search = self.store.search(query);
cx.spawn(|this, mut cx| async move {
let matches = search.await;
this.update(&mut cx, |this, cx| {
this.delegate.selected_index = 0;
this.delegate.matches = matches;
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some(prompt) = self.matches.get(self.selected_index) {
cx.emit(PromptPickerEvent::Confirmed {
prompt_id: prompt.id,
});
}
}
fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let prompt = self.matches.get(ix)?;
let default = prompt.default;
let prompt_id = prompt.id;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.selected(selected)
.child(Label::new(
prompt.title.clone().unwrap_or("Untitled".into()),
))
.end_slot(if default {
IconButton::new("toggle-default-prompt", IconName::StarFilled)
.shape(IconButtonShape::Square)
.into_any_element()
} else {
Empty.into_any()
})
.end_hover_slot(
h_flex()
.gap_2()
.child(
IconButton::new("delete-prompt", IconName::Trash)
.shape(IconButtonShape::Square)
.tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
.on_click(cx.listener(move |_, _, cx| {
cx.emit(PromptPickerEvent::Deleted { prompt_id })
})),
)
.child(
IconButton::new(
"toggle-default-prompt",
if default {
IconName::StarFilled
} else {
IconName::Star
},
)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::text(
if default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
},
cx,
)
})
.on_click(cx.listener(move |_, _, cx| {
cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
})),
),
),
)
}
}
impl PromptLibrary {
fn new(
store: Arc<PromptStore>,
language_registry: Arc<LanguageRegistry>,
cx: &mut ViewContext<Self>,
) -> Self {
let delegate = PromptPickerDelegate {
store: store.clone(),
selected_index: 0,
matches: Vec::new(),
};
let picker = cx.new_view(|cx| {
let picker = Picker::uniform_list(delegate, cx)
.modal(false)
.max_height(None);
picker.focus(cx);
picker
});
let mut this = Self {
store: store.clone(),
language_registry,
prompt_editors: HashMap::default(),
active_prompt_id: None,
pending_load: Task::ready(()),
_subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
picker,
};
if let Some(prompt_id) = store.most_recently_saved() {
this.load_prompt(prompt_id, false, cx);
}
this
}
fn handle_picker_event(
&mut self,
_: View<Picker<PromptPickerDelegate>>,
event: &PromptPickerEvent,
cx: &mut ViewContext<Self>,
) {
match event {
PromptPickerEvent::Confirmed { prompt_id } => {
self.load_prompt(*prompt_id, true, cx);
}
PromptPickerEvent::ToggledDefault { prompt_id } => {
self.toggle_default_for_prompt(*prompt_id, cx);
}
PromptPickerEvent::Deleted { prompt_id } => {
self.delete_prompt(*prompt_id, cx);
}
}
}
pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
let prompt_id = PromptId::new();
let save = self.store.save(prompt_id, None, false, "".into());
self.picker.update(cx, |picker, cx| picker.refresh(cx));
cx.spawn(|this, mut cx| async move {
save.await?;
this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx))
})
.detach_and_log_err(cx);
}
pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
let prompt_metadata = self.store.metadata(prompt_id).unwrap();
let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
let body = prompt_editor.editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.as_rope()
.clone()
});
let store = self.store.clone();
let executor = cx.background_executor().clone();
prompt_editor.next_body_to_save = Some(body);
if prompt_editor.pending_save.is_none() {
prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
async move {
loop {
let next_body_to_save = this.update(&mut cx, |this, _| {
this.prompt_editors
.get_mut(&prompt_id)?
.next_body_to_save
.take()
})?;
if let Some(body) = next_body_to_save {
let title = title_from_body(body.chars_at(0));
store
.save(prompt_id, title, prompt_metadata.default, body)
.await
.log_err();
this.update(&mut cx, |this, cx| {
this.picker.update(cx, |picker, cx| picker.refresh(cx));
cx.notify();
})?;
executor.timer(SAVE_THROTTLE).await;
} else {
break;
}
}
this.update(&mut cx, |this, _cx| {
if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
prompt_editor.pending_save = None;
}
})
}
.log_err()
}));
}
}
pub fn delete_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
if let Some(active_prompt_id) = self.active_prompt_id {
self.delete_prompt(active_prompt_id, cx);
}
}
pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
if let Some(active_prompt_id) = self.active_prompt_id {
self.toggle_default_for_prompt(active_prompt_id, cx);
}
}
pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
self.store
.save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
.detach_and_log_err(cx);
self.picker.update(cx, |picker, cx| picker.refresh(cx));
cx.notify();
}
}
pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext<Self>) {
if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
if focus {
prompt_editor
.editor
.update(cx, |editor, cx| editor.focus(cx));
}
self.active_prompt_id = Some(prompt_id);
} else {
let language_registry = self.language_registry.clone();
let prompt = self.store.load(prompt_id);
self.pending_load = cx.spawn(|this, mut cx| async move {
let prompt = prompt.await;
let markdown = language_registry.language_for_name("Markdown").await;
this.update(&mut cx, |this, cx| match prompt {
Ok(prompt) => {
let buffer = cx.new_model(|cx| {
let mut buffer = Buffer::local(prompt, cx);
buffer.set_language(markdown.log_err(), cx);
buffer.set_language_registry(language_registry);
buffer
});
let editor = cx.new_view(|cx| {
let mut editor = Editor::for_buffer(buffer, None, cx);
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor.set_show_gutter(false, cx);
editor.set_show_wrap_guides(false, cx);
editor.set_show_indent_guides(false, cx);
if focus {
editor.focus(cx);
}
editor
});
let _subscription =
cx.subscribe(&editor, move |this, _editor, event, cx| {
this.handle_prompt_editor_event(prompt_id, event, cx)
});
this.prompt_editors.insert(
prompt_id,
PromptEditor {
editor,
next_body_to_save: None,
pending_save: None,
_subscription,
},
);
this.active_prompt_id = Some(prompt_id);
cx.notify();
}
Err(error) => {
// TODO: we should show the error in the UI.
log::error!("error while loading prompt: {:?}", error);
}
})
.ok();
});
}
}
pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
if let Some(metadata) = self.store.metadata(prompt_id) {
let confirmation = cx.prompt(
PromptLevel::Warning,
&format!(
"Are you sure you want to delete {}",
metadata.title.unwrap_or("Untitled".into())
),
None,
&["Delete", "Cancel"],
);
cx.spawn(|this, mut cx| async move {
if confirmation.await.ok() == Some(0) {
this.update(&mut cx, |this, cx| {
if this.active_prompt_id == Some(prompt_id) {
this.active_prompt_id = None;
}
this.prompt_editors.remove(&prompt_id);
this.store.delete(prompt_id).detach_and_log_err(cx);
this.picker.update(cx, |picker, cx| picker.refresh(cx));
cx.notify();
})?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
fn handle_prompt_editor_event(
&mut self,
prompt_id: PromptId,
event: &EditorEvent,
cx: &mut ViewContext<Self>,
) {
if let EditorEvent::BufferEdited = event {
let prompt_editor = self.prompt_editors.get(&prompt_id).unwrap();
let buffer = prompt_editor
.editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.unwrap();
buffer.update(cx, |buffer, cx| {
let mut chars = buffer.chars_at(0);
match chars.next() {
Some('#') => {
if chars.next() != Some(' ') {
drop(chars);
buffer.edit([(1..1, " ")], None, cx);
}
}
Some(' ') => {
drop(chars);
buffer.edit([(0..0, "#")], None, cx);
}
_ => {
drop(chars);
buffer.edit([(0..0, "# ")], None, cx);
}
}
});
self.save_prompt(prompt_id, cx);
}
}
fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.id("prompt-list")
.bg(cx.theme().colors().panel_background)
.h_full()
.w_1_3()
.overflow_x_hidden()
.child(
h_flex()
.p(Spacing::Small.rems(cx))
.border_b_1()
.border_color(cx.theme().colors().border)
.h(TitleBar::height(cx))
.w_full()
.flex_none()
.justify_end()
.child(
IconButton::new("new-prompt", IconName::Plus)
.shape(IconButtonShape::Square)
.tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx))
.on_click(|_, cx| {
cx.dispatch_action(Box::new(NewPrompt));
}),
),
)
.child(div().flex_grow().child(self.picker.clone()))
}
fn render_active_prompt(&mut self, cx: &mut ViewContext<PromptLibrary>) -> gpui::Stateful<Div> {
div()
.w_2_3()
.h_full()
.id("prompt-editor")
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.flex_none()
.min_w_64()
.children(self.active_prompt_id.and_then(|prompt_id| {
let prompt_metadata = self.store.metadata(prompt_id)?;
let editor = self.prompt_editors[&prompt_id].editor.clone();
Some(
v_flex()
.size_full()
.child(
h_flex()
.h(TitleBar::height(cx))
.px(Spacing::Large.rems(cx))
.justify_end()
.child(
h_flex()
.gap_4()
.child(
IconButton::new(
"toggle-default-prompt",
if prompt_metadata.default {
IconName::StarFilled
} else {
IconName::Star
},
)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
if prompt_metadata.default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
},
&ToggleDefaultPrompt,
cx,
)
})
.on_click(
|_, cx| {
cx.dispatch_action(Box::new(
ToggleDefaultPrompt,
));
},
),
)
.child(
IconButton::new("delete-prompt", IconName::Trash)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
"Delete Prompt",
&DeletePrompt,
cx,
)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(DeletePrompt));
}),
),
),
)
.child(div().flex_grow().p(Spacing::Large.rems(cx)).child(editor)),
)
}))
}
}
impl Render for PromptLibrary {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
h_flex()
.id("prompt-manager")
.key_context("PromptLibrary")
.on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx)))
.on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx)))
.on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| {
this.toggle_default_for_active_prompt(cx)
}))
.size_full()
.overflow_hidden()
.child(self.render_prompt_list(cx))
.child(self.render_active_prompt(cx))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PromptMetadata {
pub id: PromptId,
pub title: Option<SharedString>,
pub default: bool,
pub saved_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PromptId(Uuid);
impl PromptId {
pub fn new() -> PromptId {
PromptId(Uuid::new_v4())
}
}
pub struct PromptStore {
executor: BackgroundExecutor,
env: heed::Env,
bodies: Database<SerdeBincode<PromptId>, SerdeBincode<String>>,
metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
metadata_cache: RwLock<MetadataCache>,
updates: (Arc<async_watch::Sender<()>>, async_watch::Receiver<()>),
}
#[derive(Default)]
struct MetadataCache {
metadata: Vec<PromptMetadata>,
metadata_by_id: HashMap<PromptId, PromptMetadata>,
}
impl MetadataCache {
fn from_db(
db: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
txn: &RoTxn,
) -> Result<Self> {
let mut cache = MetadataCache::default();
for result in db.iter(txn)? {
let (prompt_id, metadata) = result?;
cache.metadata.push(metadata.clone());
cache.metadata_by_id.insert(prompt_id, metadata);
}
cache
.metadata
.sort_unstable_by_key(|metadata| Reverse(metadata.saved_at));
Ok(cache)
}
fn insert(&mut self, metadata: PromptMetadata) {
self.metadata_by_id.insert(metadata.id, metadata.clone());
if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
*old_metadata = metadata;
} else {
self.metadata.push(metadata);
}
self.metadata.sort_by_key(|m| Reverse(m.saved_at));
}
fn remove(&mut self, id: PromptId) {
self.metadata.retain(|metadata| metadata.id != id);
self.metadata_by_id.remove(&id);
}
}
impl PromptStore {
pub fn global(cx: &AppContext) -> impl Future<Output = Result<Arc<Self>>> {
let store = GlobalPromptStore::global(cx).0.clone();
async move { store.await.map_err(|err| anyhow!(err)) }
}
pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task<Result<Self>> {
executor.spawn({
let executor = executor.clone();
async move {
std::fs::create_dir_all(&db_path)?;
let db_env = unsafe {
heed::EnvOpenOptions::new()
.map_size(1024 * 1024 * 1024) // 1GB
.max_dbs(2) // bodies and metadata
.open(db_path)?
};
let mut txn = db_env.write_txn()?;
let bodies = db_env.create_database(&mut txn, Some("bodies"))?;
let metadata = db_env.create_database(&mut txn, Some("metadata"))?;
let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
txn.commit()?;
let (updates_tx, updates_rx) = async_watch::channel(());
Ok(PromptStore {
executor,
env: db_env,
bodies,
metadata,
metadata_cache: RwLock::new(metadata_cache),
updates: (Arc::new(updates_tx), updates_rx),
})
}
})
}
pub fn updates(&self) -> async_watch::Receiver<()> {
self.updates.1.clone()
}
pub fn load(&self, id: PromptId) -> Task<Result<String>> {
let env = self.env.clone();
let bodies = self.bodies;
self.executor.spawn(async move {
let txn = env.read_txn()?;
bodies
.get(&txn, &id)?
.ok_or_else(|| anyhow!("prompt not found"))
})
}
pub fn load_default(&self) -> Task<Result<Vec<(PromptMetadata, String)>>> {
let default_metadatas = self
.metadata_cache
.read()
.metadata
.iter()
.filter(|metadata| metadata.default)
.cloned()
.collect::<Vec<_>>();
let env = self.env.clone();
let bodies = self.bodies;
self.executor.spawn(async move {
let txn = env.read_txn()?;
let mut default_prompts = Vec::new();
for metadata in default_metadatas {
if let Some(body) = bodies.get(&txn, &metadata.id)? {
if !body.is_empty() {
default_prompts.push((metadata, body));
}
}
}
default_prompts.sort_unstable_by_key(|(metadata, _)| metadata.saved_at);
Ok(default_prompts)
})
}
pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
self.metadata_cache.write().remove(id);
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata = self.metadata;
self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.delete(&mut txn, &id)?;
bodies.delete(&mut txn, &id)?;
txn.commit()?;
Ok(())
})
}
fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
}
pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
let metadata_cache = self.metadata_cache.read();
let metadata = metadata_cache
.metadata
.iter()
.find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
Some(metadata.id)
}
pub fn search(&self, query: String) -> Task<Vec<PromptMetadata>> {
let cached_metadata = self.metadata_cache.read().metadata.clone();
let executor = self.executor.clone();
self.executor.spawn(async move {
if query.is_empty() {
cached_metadata
} else {
let candidates = cached_metadata
.iter()
.enumerate()
.filter_map(|(ix, metadata)| {
Some(StringMatchCandidate::new(
ix,
metadata.title.as_ref()?.to_string(),
))
})
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
100,
&AtomicBool::default(),
executor,
)
.await;
matches
.into_iter()
.map(|mat| cached_metadata[mat.candidate_id].clone())
.collect()
}
})
}
fn save(
&self,
id: PromptId,
title: Option<SharedString>,
default: bool,
body: Rope,
) -> Task<Result<()>> {
let prompt_metadata = PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
};
self.metadata_cache.write().insert(prompt_metadata.clone());
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?;
bodies.put(&mut txn, &id, &body.to_string())?;
txn.commit()?;
updates.send(()).ok();
Ok(())
})
}
fn save_metadata(
&self,
id: PromptId,
title: Option<SharedString>,
default: bool,
) -> Task<Result<()>> {
let prompt_metadata = PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
};
self.metadata_cache.write().insert(prompt_metadata.clone());
let db_connection = self.env.clone();
let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?;
txn.commit()?;
updates.send(()).ok();
Ok(())
})
}
fn most_recently_saved(&self) -> Option<PromptId> {
self.metadata_cache
.read()
.metadata
.first()
.map(|metadata| metadata.id)
}
}
/// Wraps a shared future to a prompt store so it can be assigned as a context global.
pub struct GlobalPromptStore(
Shared<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
);
impl Global for GlobalPromptStore {}
fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString> {
let mut chars = body.into_iter().take_while(|c| *c != '\n').peekable();
let mut level = 0;
while let Some('#') = chars.peek() {
level += 1;
chars.next();
}
if level > 0 {
let title = chars.collect::<String>().trim().to_string();
if title.is_empty() {
None
} else {
Some(title.into())
}
} else {
None
}
}

View file

@ -1,7 +1,95 @@
mod prompt;
mod prompt_library;
mod prompt_manager;
use language::BufferSnapshot;
use std::{fmt::Write, ops::Range};
pub use prompt::*;
pub use prompt_library::*;
pub use prompt_manager::*;
pub fn generate_content_prompt(
user_prompt: String,
language_name: Option<&str>,
buffer: BufferSnapshot,
range: Range<usize>,
project_name: Option<String>,
) -> anyhow::Result<String> {
let mut prompt = String::new();
let content_type = match language_name {
None | Some("Markdown" | "Plain Text") => {
writeln!(prompt, "You are an expert engineer.")?;
"Text"
}
Some(language_name) => {
writeln!(prompt, "You are an expert {language_name} engineer.")?;
writeln!(
prompt,
"Your answer MUST always and only be valid {}.",
language_name
)?;
"Code"
}
};
if let Some(project_name) = project_name {
writeln!(
prompt,
"You are currently working inside the '{project_name}' project in code editor Zed."
)?;
}
// Include file content.
for chunk in buffer.text_for_range(0..range.start) {
prompt.push_str(chunk);
}
if range.is_empty() {
prompt.push_str("<|START|>");
} else {
prompt.push_str("<|START|");
}
for chunk in buffer.text_for_range(range.clone()) {
prompt.push_str(chunk);
}
if !range.is_empty() {
prompt.push_str("|END|>");
}
for chunk in buffer.text_for_range(range.end..buffer.len()) {
prompt.push_str(chunk);
}
prompt.push('\n');
if range.is_empty() {
writeln!(
prompt,
"Assume the cursor is located where the `<|START|>` span is."
)
.unwrap();
writeln!(
prompt,
"{content_type} can't be replaced, so assume your answer will be inserted at the cursor.",
)
.unwrap();
writeln!(
prompt,
"Generate {content_type} based on the users prompt: {user_prompt}",
)
.unwrap();
} else {
writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap();
writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap();
writeln!(
prompt,
"Double check that you only return code and not the '<|START|' and '|END|'> spans"
)
.unwrap();
}
writeln!(prompt, "Never make remarks about the output.").unwrap();
writeln!(
prompt,
"Do not return anything else, except the generated {content_type}."
)
.unwrap();
Ok(prompt)
}

View file

@ -1,360 +0,0 @@
use fs::Fs;
use language::BufferSnapshot;
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,
version: String,
author: String,
#[serde(default)]
languages: Vec<String>,
#[serde(default)]
dependencies: Vec<String>,
}
impl Default for StaticPromptFrontmatter {
fn default() -> Self {
Self {
title: PROMPT_DEFAULT_TITLE.to_string(),
version: "1.0".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
///
/// Examples:
///
/// ### Globally available prompt
///
/// ```markdown
/// ---
/// title: Foo
/// version: 1.0
/// author: Jane Kim <jane@kim.com
/// languages: ["*"]
/// dependencies: []
/// ---
///
/// Foo and bar are terms used in programming to describe generic concepts.
/// ```
///
/// ### Language-specific prompt
///
/// ```markdown
/// ---
/// title: UI with GPUI
/// version: 1.0
/// author: Nate Butler <iamnbutler@gmail.com>
/// languages: ["rust"]
/// dependencies: ["gpui"]
/// ---
///
/// When building a UI with GPUI, ensure you...
/// ```
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StaticPrompt {
#[serde(skip_deserializing)]
id: PromptId,
#[serde(skip)]
metadata: StaticPromptFrontmatter,
content: 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
.map_or_else(
|| Err(anyhow::anyhow!("Failed to parse frontmatter")),
|data| {
let front_matter: StaticPromptFrontmatter = data.deserialize()?;
Ok(front_matter)
},
)
.unwrap_or_else(|e| {
if let Some(file_name) = &file_name {
log::error!("Failed to parse frontmatter for {}: {}", file_name, e);
} else {
log::error!("Failed to parse frontmatter: {}", e);
}
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 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
pub fn content(&self) -> &String {
&self.content
}
/// Returns the prompt's metadata
pub fn _metadata(&self) -> &StaticPromptFrontmatter {
&self.metadata
}
/// Returns the prompt's title
pub fn title(&self) -> SharedString {
self.metadata.title.clone().into()
}
pub fn body(&self) -> String {
let matter = Matter::<YAML>::new();
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(
user_prompt: String,
language_name: Option<&str>,
buffer: BufferSnapshot,
range: Range<usize>,
project_name: Option<String>,
) -> anyhow::Result<String> {
let mut prompt = String::new();
let content_type = match language_name {
None | Some("Markdown" | "Plain Text") => {
writeln!(prompt, "You are an expert engineer.")?;
"Text"
}
Some(language_name) => {
writeln!(prompt, "You are an expert {language_name} engineer.")?;
writeln!(
prompt,
"Your answer MUST always and only be valid {}.",
language_name
)?;
"Code"
}
};
if let Some(project_name) = project_name {
writeln!(
prompt,
"You are currently working inside the '{project_name}' project in code editor Zed."
)?;
}
// Include file content.
for chunk in buffer.text_for_range(0..range.start) {
prompt.push_str(chunk);
}
if range.is_empty() {
prompt.push_str("<|START|>");
} else {
prompt.push_str("<|START|");
}
for chunk in buffer.text_for_range(range.clone()) {
prompt.push_str(chunk);
}
if !range.is_empty() {
prompt.push_str("|END|>");
}
for chunk in buffer.text_for_range(range.end..buffer.len()) {
prompt.push_str(chunk);
}
prompt.push('\n');
if range.is_empty() {
writeln!(
prompt,
"Assume the cursor is located where the `<|START|>` span is."
)
.unwrap();
writeln!(
prompt,
"{content_type} can't be replaced, so assume your answer will be inserted at the cursor.",
)
.unwrap();
writeln!(
prompt,
"Generate {content_type} based on the users prompt: {user_prompt}",
)
.unwrap();
} else {
writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap();
writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap();
writeln!(
prompt,
"Double check that you only return code and not the '<|START|' and '|END|'> spans"
)
.unwrap();
}
writeln!(prompt, "Never make remarks about the output.").unwrap();
writeln!(
prompt,
"Do not return anything else, except the generated {content_type}."
)
.unwrap();
Ok(prompt)
}

View file

@ -1,245 +0,0 @@
use anyhow::Context;
use collections::HashMap;
use fs::Fs;
use gray_matter::{engine::YAML, Matter};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use smol::stream::StreamExt;
use std::sync::Arc;
use util::paths::PROMPTS_DIR;
use uuid::Uuid;
use super::prompt::StaticPrompt;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct PromptId(pub Uuid);
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SortOrder {
Alphabetical,
}
#[allow(unused)]
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)]
pub struct PromptLibraryState {
/// A set of prompts that all assistant contexts will start with
default_prompt: Vec<PromptId>,
/// All [Prompt]s loaded into the library
prompts: HashMap<PromptId, StaticPrompt>,
/// Prompts that have been changed but haven't been
/// saved back to the file system
dirty_prompts: Vec<PromptId>,
version: usize,
}
pub struct PromptLibrary {
state: RwLock<PromptLibraryState>,
}
impl Default for PromptLibrary {
fn default() -> Self {
Self::new()
}
}
impl PromptLibrary {
fn new() -> Self {
Self {
state: RwLock::new(PromptLibraryState::default()),
}
}
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.clone()
}
pub fn sorted_prompts(&self, sort_order: SortOrder) -> Vec<(PromptId, StaticPrompt)> {
let state = self.state.read();
let mut prompts = state
.prompts
.iter()
.map(|(id, prompt)| (*id, prompt.clone()))
.collect::<Vec<_>>();
match sort_order {
SortOrder::Alphabetical => prompts.sort_by(|(_, a), (_, b)| a.title().cmp(&b.title())),
};
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 is_dirty(&self, id: &PromptId) -> bool {
let state = self.state.read();
state.dirty_prompts.contains(&id)
}
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_index(fs: Arc<dyn Fs>) -> anyhow::Result<Self> {
let path = PROMPTS_DIR.join("index.json");
let state = if fs.is_file(&path).await {
let json = fs.load(&path).await?;
serde_json::from_str(&json)?
} else {
PromptLibraryState::default()
};
let mut prompt_library = Self {
state: RwLock::new(state),
};
prompt_library.load_prompts(fs).await?;
Ok(prompt_library)
}
/// 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<()> {
self.state.get_mut().prompts.clear();
let mut prompt_paths = fs.read_dir(&PROMPTS_DIR).await?;
while let Some(prompt_path) = prompt_paths.next().await {
let prompt_path = prompt_path.with_context(|| "Failed to read prompt path")?;
let file_name_lossy = if prompt_path.file_name().is_some() {
Some(
prompt_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
)
} else {
None
};
if !fs.is_file(&prompt_path).await
|| prompt_path.extension().and_then(|ext| ext.to_str()) != Some("md")
{
continue;
}
let json = fs
.load(&prompt_path)
.await
.with_context(|| format!("Failed to load prompt {:?}", prompt_path))?;
// Check that the prompt is valid
let matter = Matter::<YAML>::new();
let result = matter.parse(&json);
if result.data.is_none() {
log::warn!("Invalid prompt: {:?}", prompt_path);
continue;
}
let static_prompt = StaticPrompt::new(json, file_name_lossy.clone());
let state = self.state.get_mut();
let id = Uuid::new_v4();
state.prompts.insert(PromptId(id), static_prompt);
state.version += 1;
}
// Write any changes back to the file system
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(())
}
}

View file

@ -1,512 +0,0 @@
use collections::HashMap;
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, Indicator, ListItem, ListItemSpacing, Tooltip};
use util::{ResultExt, TryFutureExt};
use workspace::ModalView;
use crate::prompts::{PromptId, PromptLibrary, SortOrder, StaticPrompt, PROMPT_DEFAULT_TITLE};
actions!(prompt_manager, [NewPrompt, SavePrompt]);
pub struct PromptManager {
focus_handle: FocusHandle,
prompt_library: Arc<PromptLibrary>,
language_registry: Arc<LanguageRegistry>,
#[allow(dead_code)]
fs: Arc<dyn Fs>,
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 {
pub fn new(
prompt_library: Arc<PromptLibrary>,
language_registry: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
cx: &mut ViewContext<Self>,
) -> Self {
let prompt_manager = cx.view().downgrade();
let picker = cx.new_view(|cx| {
Picker::uniform_list(
PromptManagerDelegate {
prompt_manager,
matching_prompts: vec![],
matching_prompt_ids: vec![],
prompt_library: prompt_library.clone(),
selected_index: 0,
_subscriptions: vec![],
},
cx,
)
.max_height(rems(35.75))
.modal(false)
});
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,
language_registry,
fs,
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();
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) {
let focus_handle = editor.focus_handle(cx);
cx.focus(&focus_handle)
}
}
}
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(
&mut self,
prompt_id: PromptId,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let prompt_library = self.prompt_library.clone();
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_by_id(prompt_id) {
prompt.content().to_owned()
} else {
"".to_string()
};
let buffer = cx.new_model(|cx| {
let mut buffer = Buffer::local(text, cx);
let markdown = self.language_registry.language_for_name("Markdown");
cx.spawn(|buffer, mut cx| async move {
if let Some(markdown) = markdown.await.log_err() {
_ = buffer.update(&mut cx, |buffer, cx| {
buffer.set_language(Some(markdown), cx);
});
}
})
.detach();
buffer.set_language_registry(self.language_registry.clone());
buffer
});
let mut editor = Editor::for_buffer(buffer, None, cx);
editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
editor.set_show_gutter(false, cx);
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()
.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::new_prompt))
.elevation_3(cx)
.size_full()
.flex_none()
.w(rems(64.))
.h(rems(40.))
.overflow_hidden()
.child(self.render_prompt_list(cx))
.child(
div().w_2_3().h_full().child(
v_flex()
.id("prompt-editor")
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.size_full()
.flex_none()
.min_w_64()
.h_full()
.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_7()
.w_full()
.justify_between()
.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(active_prompt_id, |this, active_prompt_id| {
this.child(
h_flex()
.flex_1()
.w_full()
.py(Spacing::Large.rems(cx))
.px(Spacing::XLarge.rems(cx))
.child(self.set_editor_for_prompt(active_prompt_id, cx)),
)
}),
),
)
}
}
impl EventEmitter<DismissEvent> for PromptManager {}
impl EventEmitter<EditorEvent> for PromptManager {}
impl ModalView for PromptManager {}
impl FocusableView for PromptManager {
fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
pub struct PromptManagerDelegate {
prompt_manager: WeakView<PromptManager>,
matching_prompts: Vec<Arc<StaticPrompt>>,
matching_prompt_ids: Vec<PromptId>,
prompt_library: Arc<PromptLibrary>,
selected_index: usize,
_subscriptions: Vec<Subscription>,
}
impl PickerDelegate for PromptManagerDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
"Find a prompt…".into()
}
fn match_count(&self) -> usize {
self.matching_prompt_ids.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn selected_index_changed(
&self,
ix: usize,
_cx: &mut ViewContext<Picker<Self>>,
) -> Option<Box<dyn Fn(&mut WindowContext) + 'static>> {
let prompt_id = self.matching_prompt_ids.get(ix).copied()?;
let prompt_manager = self.prompt_manager.upgrade()?;
Some(Box::new(move |cx| {
prompt_manager.update(cx, |manager, cx| {
manager.set_active_prompt(Some(prompt_id), cx);
})
}))
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
let prompt_library = self.prompt_library.clone();
cx.spawn(|picker, mut cx| async move {
async {
let prompts = prompt_library.sorted_prompts(SortOrder::Alphabetical);
let matching_prompts = prompts
.into_iter()
.filter(|(_, prompt)| {
prompt
.content()
.to_lowercase()
.contains(&query.to_lowercase())
})
.collect::<Vec<_>>();
picker.update(&mut cx, |picker, cx| {
picker.delegate.matching_prompt_ids =
matching_prompts.iter().map(|(id, _)| *id).collect();
picker.delegate.matching_prompts = matching_prompts
.into_iter()
.map(|(_, prompt)| Arc::new(prompt))
.collect();
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
let prompt_manager = self.prompt_manager.upgrade().unwrap();
prompt_manager.update(cx, move |manager, cx| manager.focus_active_editor(cx));
}
fn should_dismiss(&self) -> bool {
false
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
self.prompt_manager
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
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()))
.end_slot(div().when(is_diry, |this| this.child(Indicator::dot()))),
)
}
}

View file

@ -1,8 +1,7 @@
use super::{SlashCommand, SlashCommandOutput};
use crate::prompts::PromptLibrary;
use crate::prompt_library::PromptStore;
use anyhow::{anyhow, Context, Result};
use assistant_slash_command::SlashCommandOutputSection;
use fuzzy::StringMatchCandidate;
use gpui::{AppContext, Task, WeakView};
use language::LspAdapterDelegate;
use std::sync::{atomic::AtomicBool, Arc};
@ -10,12 +9,12 @@ use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace;
pub(crate) struct PromptSlashCommand {
library: Arc<PromptLibrary>,
store: Arc<PromptStore>,
}
impl PromptSlashCommand {
pub fn new(library: Arc<PromptLibrary>) -> Self {
Self { library }
pub fn new(store: Arc<PromptStore>) -> Self {
Self { store }
}
}
@ -39,31 +38,16 @@ impl SlashCommand for PromptSlashCommand {
fn complete_argument(
&self,
query: String,
cancellation_flag: Arc<AtomicBool>,
_cancellation_flag: Arc<AtomicBool>,
_workspace: WeakView<Workspace>,
cx: &mut AppContext,
) -> Task<Result<Vec<String>>> {
let library = self.library.clone();
let executor = cx.background_executor().clone();
let store = self.store.clone();
cx.background_executor().spawn(async move {
let candidates = library
.prompts()
let prompts = store.search(query).await;
Ok(prompts
.into_iter()
.enumerate()
.map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.1.title().to_string()))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
100,
&cancellation_flag,
executor,
)
.await;
Ok(matches
.into_iter()
.map(|mat| candidates[mat.candidate_id].string.clone())
.filter_map(|prompt| Some(prompt.title?.to_string()))
.collect())
})
}
@ -79,19 +63,16 @@ impl SlashCommand for PromptSlashCommand {
return Task::ready(Err(anyhow!("missing prompt name")));
};
let library = self.library.clone();
let store = self.store.clone();
let title = SharedString::from(title.to_string());
let prompt = cx.background_executor().spawn({
let title = title.clone();
async move {
let prompt = library
.prompts()
.into_iter()
.map(|prompt| (prompt.1.title(), prompt))
.find(|(t, _)| t == &title)
.with_context(|| format!("no prompt found with title {:?}", title))?
.1;
anyhow::Ok(prompt.1.body())
let prompt_id = store
.id_for_title(&title)
.with_context(|| format!("no prompt found with title {:?}", title))?;
let body = store.load(prompt_id).await?;
anyhow::Ok(body)
}
});
cx.foreground_executor().spawn(async move {
@ -102,16 +83,34 @@ impl SlashCommand for PromptSlashCommand {
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::Library))
.child(Label::new(title.clone()))
.on_click(move |_, cx| unfold(cx))
.into_any_element()
PromptPlaceholder {
id,
unfold,
title: title.clone(),
}
.into_any_element()
}),
}],
})
})
}
}
#[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))
}
}