1use crate::{
2 slash_command::SlashCommandCompletionProvider, AssistantPanel, InlineAssist, InlineAssistant,
3};
4use anyhow::{anyhow, Result};
5use assets::Assets;
6use chrono::{DateTime, Utc};
7use collections::{HashMap, HashSet};
8use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
9use futures::{
10 future::{self, BoxFuture, Shared},
11 FutureExt,
12};
13use fuzzy::StringMatchCandidate;
14use gpui::{
15 actions, point, size, transparent_black, AppContext, AssetSource, BackgroundExecutor, Bounds,
16 EventEmitter, Global, HighlightStyle, PromptLevel, ReadGlobal, Subscription, Task, TextStyle,
17 TitlebarOptions, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions,
18};
19use heed::{types::SerdeBincode, Database, RoTxn};
20use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
21use language_model::{
22 LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
23};
24use parking_lot::RwLock;
25use picker::{Picker, PickerDelegate};
26use rope::Rope;
27use serde::{Deserialize, Serialize};
28use settings::Settings;
29use std::{
30 cmp::Reverse,
31 future::Future,
32 path::PathBuf,
33 sync::{atomic::AtomicBool, Arc},
34 time::Duration,
35};
36use theme::ThemeSettings;
37use ui::{
38 div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render,
39 SharedString, Styled, Tooltip, ViewContext, VisualContext,
40};
41use util::{ResultExt, TryFutureExt};
42use uuid::Uuid;
43use workspace::Workspace;
44
45actions!(
46 prompt_library,
47 [
48 NewPrompt,
49 DeletePrompt,
50 DuplicatePrompt,
51 ToggleDefaultPrompt
52 ]
53);
54
55/// Init starts loading the PromptStore in the background and assigns
56/// a shared future to a global.
57pub fn init(cx: &mut AppContext) {
58 let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb");
59 let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone())
60 .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
61 .boxed()
62 .shared();
63 cx.set_global(GlobalPromptStore(prompt_store_future))
64}
65
66/// This function opens a new prompt library window if one doesn't exist already.
67/// If one exists, it brings it to the foreground.
68///
69/// Note that, when opening a new window, this waits for the PromptStore to be
70/// initialized. If it was initialized successfully, it returns a window handle
71/// to a prompt library.
72pub fn open_prompt_library(
73 language_registry: Arc<LanguageRegistry>,
74 cx: &mut AppContext,
75) -> Task<Result<WindowHandle<PromptLibrary>>> {
76 let existing_window = cx
77 .windows()
78 .into_iter()
79 .find_map(|window| window.downcast::<PromptLibrary>());
80 if let Some(existing_window) = existing_window {
81 existing_window
82 .update(cx, |_, cx| cx.activate_window())
83 .ok();
84 Task::ready(Ok(existing_window))
85 } else {
86 let store = PromptStore::global(cx);
87 cx.spawn(|cx| async move {
88 let store = store.await?;
89 cx.update(|cx| {
90 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
91 cx.open_window(
92 WindowOptions {
93 titlebar: Some(TitlebarOptions {
94 title: Some("Prompt Library".into()),
95 appears_transparent: true,
96 traffic_light_position: Some(point(px(9.0), px(9.0))),
97 }),
98 window_bounds: Some(WindowBounds::Windowed(bounds)),
99 ..Default::default()
100 },
101 |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)),
102 )
103 })?
104 })
105 }
106}
107
108pub struct PromptLibrary {
109 store: Arc<PromptStore>,
110 language_registry: Arc<LanguageRegistry>,
111 prompt_editors: HashMap<PromptId, PromptEditor>,
112 active_prompt_id: Option<PromptId>,
113 picker: View<Picker<PromptPickerDelegate>>,
114 pending_load: Task<()>,
115 _subscriptions: Vec<Subscription>,
116}
117
118struct PromptEditor {
119 title_editor: View<Editor>,
120 body_editor: View<Editor>,
121 token_count: Option<usize>,
122 pending_token_count: Task<Option<()>>,
123 next_title_and_body_to_save: Option<(String, Rope)>,
124 pending_save: Option<Task<Option<()>>>,
125 _subscriptions: Vec<Subscription>,
126}
127
128struct PromptPickerDelegate {
129 store: Arc<PromptStore>,
130 selected_index: usize,
131 matches: Vec<PromptMetadata>,
132}
133
134enum PromptPickerEvent {
135 Selected { prompt_id: PromptId },
136 Confirmed { prompt_id: PromptId },
137 Deleted { prompt_id: PromptId },
138 ToggledDefault { prompt_id: PromptId },
139}
140
141impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
142
143impl PickerDelegate for PromptPickerDelegate {
144 type ListItem = ListItem;
145
146 fn match_count(&self) -> usize {
147 self.matches.len()
148 }
149
150 fn selected_index(&self) -> usize {
151 self.selected_index
152 }
153
154 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
155 self.selected_index = ix;
156 if let Some(prompt) = self.matches.get(self.selected_index) {
157 cx.emit(PromptPickerEvent::Selected {
158 prompt_id: prompt.id,
159 });
160 }
161 }
162
163 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
164 "Search...".into()
165 }
166
167 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
168 let search = self.store.search(query);
169 let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
170 cx.spawn(|this, mut cx| async move {
171 let (matches, selected_index) = cx
172 .background_executor()
173 .spawn(async move {
174 let matches = search.await;
175
176 let selected_index = prev_prompt_id
177 .and_then(|prev_prompt_id| {
178 matches.iter().position(|entry| entry.id == prev_prompt_id)
179 })
180 .unwrap_or(0);
181 (matches, selected_index)
182 })
183 .await;
184
185 this.update(&mut cx, |this, cx| {
186 this.delegate.matches = matches;
187 this.delegate.set_selected_index(selected_index, cx);
188 cx.notify();
189 })
190 .ok();
191 })
192 }
193
194 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
195 if let Some(prompt) = self.matches.get(self.selected_index) {
196 cx.emit(PromptPickerEvent::Confirmed {
197 prompt_id: prompt.id,
198 });
199 }
200 }
201
202 fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
203
204 fn render_match(
205 &self,
206 ix: usize,
207 selected: bool,
208 cx: &mut ViewContext<Picker<Self>>,
209 ) -> Option<Self::ListItem> {
210 let prompt = self.matches.get(ix)?;
211 let default = prompt.default;
212 let prompt_id = prompt.id;
213 let element = ListItem::new(ix)
214 .inset(true)
215 .spacing(ListItemSpacing::Sparse)
216 .selected(selected)
217 .child(h_flex().h_5().line_height(relative(1.)).child(Label::new(
218 prompt.title.clone().unwrap_or("Untitled".into()),
219 )))
220 .end_slot::<IconButton>(default.then(|| {
221 IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
222 .selected(true)
223 .icon_color(Color::Accent)
224 .shape(IconButtonShape::Square)
225 .tooltip(move |cx| Tooltip::text("Remove from Default Prompt", cx))
226 .on_click(cx.listener(move |_, _, cx| {
227 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
228 }))
229 }))
230 .end_hover_slot(
231 h_flex()
232 .gap_2()
233 .child(
234 IconButton::new("delete-prompt", IconName::Trash)
235 .icon_color(Color::Muted)
236 .shape(IconButtonShape::Square)
237 .tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
238 .on_click(cx.listener(move |_, _, cx| {
239 cx.emit(PromptPickerEvent::Deleted { prompt_id })
240 })),
241 )
242 .child(
243 IconButton::new("toggle-default-prompt", IconName::Sparkle)
244 .selected(default)
245 .selected_icon(IconName::SparkleFilled)
246 .icon_color(if default { Color::Accent } else { Color::Muted })
247 .shape(IconButtonShape::Square)
248 .tooltip(move |cx| {
249 Tooltip::text(
250 if default {
251 "Remove from Default Prompt"
252 } else {
253 "Add to Default Prompt"
254 },
255 cx,
256 )
257 })
258 .on_click(cx.listener(move |_, _, cx| {
259 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
260 })),
261 ),
262 );
263 Some(element)
264 }
265
266 fn render_editor(&self, editor: &View<Editor>, cx: &mut ViewContext<Picker<Self>>) -> Div {
267 h_flex()
268 .bg(cx.theme().colors().editor_background)
269 .rounded_md()
270 .overflow_hidden()
271 .flex_none()
272 .py_1()
273 .px_2()
274 .mx_1()
275 .child(editor.clone())
276 }
277}
278
279impl PromptLibrary {
280 fn new(
281 store: Arc<PromptStore>,
282 language_registry: Arc<LanguageRegistry>,
283 cx: &mut ViewContext<Self>,
284 ) -> Self {
285 let delegate = PromptPickerDelegate {
286 store: store.clone(),
287 selected_index: 0,
288 matches: Vec::new(),
289 };
290
291 let picker = cx.new_view(|cx| {
292 let picker = Picker::uniform_list(delegate, cx)
293 .modal(false)
294 .max_height(None);
295 picker.focus(cx);
296 picker
297 });
298 Self {
299 store: store.clone(),
300 language_registry,
301 prompt_editors: HashMap::default(),
302 active_prompt_id: None,
303 pending_load: Task::ready(()),
304 _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
305 picker,
306 }
307 }
308
309 fn handle_picker_event(
310 &mut self,
311 _: View<Picker<PromptPickerDelegate>>,
312 event: &PromptPickerEvent,
313 cx: &mut ViewContext<Self>,
314 ) {
315 match event {
316 PromptPickerEvent::Selected { prompt_id } => {
317 self.load_prompt(*prompt_id, false, cx);
318 }
319 PromptPickerEvent::Confirmed { prompt_id } => {
320 self.load_prompt(*prompt_id, true, cx);
321 }
322 PromptPickerEvent::ToggledDefault { prompt_id } => {
323 self.toggle_default_for_prompt(*prompt_id, cx);
324 }
325 PromptPickerEvent::Deleted { prompt_id } => {
326 self.delete_prompt(*prompt_id, cx);
327 }
328 }
329 }
330
331 pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
332 // If we already have an untitled prompt, use that instead
333 // of creating a new one.
334 if let Some(metadata) = self.store.first() {
335 if metadata.title.is_none() {
336 self.load_prompt(metadata.id, true, cx);
337 return;
338 }
339 }
340
341 let prompt_id = PromptId::new();
342 let save = self.store.save(prompt_id, None, false, "".into());
343 self.picker.update(cx, |picker, cx| picker.refresh(cx));
344 cx.spawn(|this, mut cx| async move {
345 save.await?;
346 this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx))
347 })
348 .detach_and_log_err(cx);
349 }
350
351 pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
352 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
353
354 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
355 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
356 let title = prompt_editor.title_editor.read(cx).text(cx);
357 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
358 editor
359 .buffer()
360 .read(cx)
361 .as_singleton()
362 .unwrap()
363 .read(cx)
364 .as_rope()
365 .clone()
366 });
367
368 let store = self.store.clone();
369 let executor = cx.background_executor().clone();
370
371 prompt_editor.next_title_and_body_to_save = Some((title, body));
372 if prompt_editor.pending_save.is_none() {
373 prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
374 async move {
375 loop {
376 let title_and_body = this.update(&mut cx, |this, _| {
377 this.prompt_editors
378 .get_mut(&prompt_id)?
379 .next_title_and_body_to_save
380 .take()
381 })?;
382
383 if let Some((title, body)) = title_and_body {
384 let title = if title.trim().is_empty() {
385 None
386 } else {
387 Some(SharedString::from(title))
388 };
389 store
390 .save(prompt_id, title, prompt_metadata.default, body)
391 .await
392 .log_err();
393 this.update(&mut cx, |this, cx| {
394 this.picker.update(cx, |picker, cx| picker.refresh(cx));
395 cx.notify();
396 })?;
397
398 executor.timer(SAVE_THROTTLE).await;
399 } else {
400 break;
401 }
402 }
403
404 this.update(&mut cx, |this, _cx| {
405 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
406 prompt_editor.pending_save = None;
407 }
408 })
409 }
410 .log_err()
411 }));
412 }
413 }
414
415 pub fn delete_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
416 if let Some(active_prompt_id) = self.active_prompt_id {
417 self.delete_prompt(active_prompt_id, cx);
418 }
419 }
420
421 pub fn duplicate_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
422 if let Some(active_prompt_id) = self.active_prompt_id {
423 self.duplicate_prompt(active_prompt_id, cx);
424 }
425 }
426
427 pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
428 if let Some(active_prompt_id) = self.active_prompt_id {
429 self.toggle_default_for_prompt(active_prompt_id, cx);
430 }
431 }
432
433 pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
434 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
435 self.store
436 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
437 .detach_and_log_err(cx);
438 self.picker.update(cx, |picker, cx| picker.refresh(cx));
439 cx.notify();
440 }
441 }
442
443 pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext<Self>) {
444 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
445 if focus {
446 prompt_editor
447 .body_editor
448 .update(cx, |editor, cx| editor.focus(cx));
449 }
450 self.set_active_prompt(Some(prompt_id), cx);
451 } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
452 let language_registry = self.language_registry.clone();
453 let prompt = self.store.load(prompt_id);
454 self.pending_load = cx.spawn(|this, mut cx| async move {
455 let prompt = prompt.await;
456 let markdown = language_registry.language_for_name("Markdown").await;
457 this.update(&mut cx, |this, cx| match prompt {
458 Ok(prompt) => {
459 let title_editor = cx.new_view(|cx| {
460 let mut editor = Editor::auto_width(cx);
461 editor.set_placeholder_text("Untitled", cx);
462 editor.set_text(prompt_metadata.title.unwrap_or_default(), cx);
463 editor
464 });
465 let body_editor = cx.new_view(|cx| {
466 let buffer = cx.new_model(|cx| {
467 let mut buffer = Buffer::local(prompt, cx);
468 buffer.set_language(markdown.log_err(), cx);
469 buffer.set_language_registry(language_registry);
470 buffer
471 });
472
473 let mut editor = Editor::for_buffer(buffer, None, cx);
474 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
475 editor.set_show_gutter(false, cx);
476 editor.set_show_wrap_guides(false, cx);
477 editor.set_show_indent_guides(false, cx);
478 editor.set_use_modal_editing(false);
479 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
480 editor.set_completion_provider(Box::new(
481 SlashCommandCompletionProvider::new(None, None),
482 ));
483 if focus {
484 editor.focus(cx);
485 }
486 editor
487 });
488 let _subscriptions = vec![
489 cx.subscribe(&title_editor, move |this, editor, event, cx| {
490 this.handle_prompt_title_editor_event(prompt_id, editor, event, cx)
491 }),
492 cx.subscribe(&body_editor, move |this, editor, event, cx| {
493 this.handle_prompt_body_editor_event(prompt_id, editor, event, cx)
494 }),
495 ];
496 this.prompt_editors.insert(
497 prompt_id,
498 PromptEditor {
499 title_editor,
500 body_editor,
501 next_title_and_body_to_save: None,
502 pending_save: None,
503 token_count: None,
504 pending_token_count: Task::ready(None),
505 _subscriptions,
506 },
507 );
508 this.set_active_prompt(Some(prompt_id), cx);
509 this.count_tokens(prompt_id, cx);
510 }
511 Err(error) => {
512 // TODO: we should show the error in the UI.
513 log::error!("error while loading prompt: {:?}", error);
514 }
515 })
516 .ok();
517 });
518 }
519 }
520
521 fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
522 self.active_prompt_id = prompt_id;
523 self.picker.update(cx, |picker, cx| {
524 if let Some(prompt_id) = prompt_id {
525 if picker
526 .delegate
527 .matches
528 .get(picker.delegate.selected_index())
529 .map_or(true, |old_selected_prompt| {
530 old_selected_prompt.id != prompt_id
531 })
532 {
533 if let Some(ix) = picker
534 .delegate
535 .matches
536 .iter()
537 .position(|mat| mat.id == prompt_id)
538 {
539 picker.set_selected_index(ix, true, cx);
540 }
541 }
542 } else {
543 picker.focus(cx);
544 }
545 });
546 cx.notify();
547 }
548
549 pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
550 if let Some(metadata) = self.store.metadata(prompt_id) {
551 let confirmation = cx.prompt(
552 PromptLevel::Warning,
553 &format!(
554 "Are you sure you want to delete {}",
555 metadata.title.unwrap_or("Untitled".into())
556 ),
557 None,
558 &["Delete", "Cancel"],
559 );
560
561 cx.spawn(|this, mut cx| async move {
562 if confirmation.await.ok() == Some(0) {
563 this.update(&mut cx, |this, cx| {
564 if this.active_prompt_id == Some(prompt_id) {
565 this.set_active_prompt(None, cx);
566 }
567 this.prompt_editors.remove(&prompt_id);
568 this.store.delete(prompt_id).detach_and_log_err(cx);
569 this.picker.update(cx, |picker, cx| picker.refresh(cx));
570 cx.notify();
571 })?;
572 }
573 anyhow::Ok(())
574 })
575 .detach_and_log_err(cx);
576 }
577 }
578
579 pub fn duplicate_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
580 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
581 const DUPLICATE_SUFFIX: &str = " copy";
582 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
583 let existing_titles = self
584 .prompt_editors
585 .iter()
586 .filter(|&(&id, _)| id != prompt_id)
587 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
588 .filter(|title| title.starts_with(&title_to_duplicate))
589 .collect::<HashSet<_>>();
590
591 let title = if existing_titles.is_empty() {
592 title_to_duplicate + DUPLICATE_SUFFIX
593 } else {
594 let mut i = 1;
595 loop {
596 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
597 if !existing_titles.contains(&new_title) {
598 break new_title;
599 }
600 i += 1;
601 }
602 };
603
604 let new_id = PromptId::new();
605 let body = prompt.body_editor.read(cx).text(cx);
606 let save = self
607 .store
608 .save(new_id, Some(title.into()), false, body.into());
609 self.picker.update(cx, |picker, cx| picker.refresh(cx));
610 cx.spawn(|this, mut cx| async move {
611 save.await?;
612 this.update(&mut cx, |prompt_library, cx| {
613 prompt_library.load_prompt(new_id, true, cx)
614 })
615 })
616 .detach_and_log_err(cx);
617 }
618 }
619
620 fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
621 if let Some(active_prompt) = self.active_prompt_id {
622 self.prompt_editors[&active_prompt]
623 .body_editor
624 .update(cx, |editor, cx| editor.focus(cx));
625 cx.stop_propagation();
626 }
627 }
628
629 fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
630 self.picker.update(cx, |picker, cx| picker.focus(cx));
631 }
632
633 pub fn inline_assist(&mut self, action: &InlineAssist, cx: &mut ViewContext<Self>) {
634 let Some(active_prompt_id) = self.active_prompt_id else {
635 cx.propagate();
636 return;
637 };
638
639 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
640 let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
641 return;
642 };
643
644 let initial_prompt = action.prompt.clone();
645 if provider.is_authenticated(cx) {
646 InlineAssistant::update_global(cx, |assistant, cx| {
647 assistant.assist(&prompt_editor, None, None, initial_prompt, cx)
648 })
649 } else {
650 for window in cx.windows() {
651 if let Some(workspace) = window.downcast::<Workspace>() {
652 let panel = workspace
653 .update(cx, |workspace, cx| {
654 cx.activate_window();
655 workspace.focus_panel::<AssistantPanel>(cx)
656 })
657 .ok()
658 .flatten();
659 if panel.is_some() {
660 return;
661 }
662 }
663 }
664 }
665 }
666
667 fn move_down_from_title(&mut self, _: &editor::actions::MoveDown, cx: &mut ViewContext<Self>) {
668 if let Some(prompt_id) = self.active_prompt_id {
669 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
670 cx.focus_view(&prompt_editor.body_editor);
671 }
672 }
673 }
674
675 fn move_up_from_body(&mut self, _: &editor::actions::MoveUp, cx: &mut ViewContext<Self>) {
676 if let Some(prompt_id) = self.active_prompt_id {
677 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
678 cx.focus_view(&prompt_editor.title_editor);
679 }
680 }
681 }
682
683 fn handle_prompt_title_editor_event(
684 &mut self,
685 prompt_id: PromptId,
686 title_editor: View<Editor>,
687 event: &EditorEvent,
688 cx: &mut ViewContext<Self>,
689 ) {
690 match event {
691 EditorEvent::BufferEdited => {
692 self.save_prompt(prompt_id, cx);
693 self.count_tokens(prompt_id, cx);
694 }
695 EditorEvent::Blurred => {
696 title_editor.update(cx, |title_editor, cx| {
697 title_editor.change_selections(None, cx, |selections| {
698 let cursor = selections.oldest_anchor().head();
699 selections.select_anchor_ranges([cursor..cursor]);
700 });
701 });
702 }
703 _ => {}
704 }
705 }
706
707 fn handle_prompt_body_editor_event(
708 &mut self,
709 prompt_id: PromptId,
710 body_editor: View<Editor>,
711 event: &EditorEvent,
712 cx: &mut ViewContext<Self>,
713 ) {
714 match event {
715 EditorEvent::BufferEdited => {
716 self.save_prompt(prompt_id, cx);
717 self.count_tokens(prompt_id, cx);
718 }
719 EditorEvent::Blurred => {
720 body_editor.update(cx, |body_editor, cx| {
721 body_editor.change_selections(None, cx, |selections| {
722 let cursor = selections.oldest_anchor().head();
723 selections.select_anchor_ranges([cursor..cursor]);
724 });
725 });
726 }
727 _ => {}
728 }
729 }
730
731 fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
732 let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
733 return;
734 };
735 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
736 let editor = &prompt.body_editor.read(cx);
737 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
738 let body = buffer.as_rope().clone();
739 prompt.pending_token_count = cx.spawn(|this, mut cx| {
740 async move {
741 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
742
743 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
744 let token_count = cx
745 .update(|cx| {
746 model.count_tokens(
747 LanguageModelRequest {
748 messages: vec![LanguageModelRequestMessage {
749 role: Role::System,
750 content: body.to_string(),
751 }],
752 stop: Vec::new(),
753 temperature: 1.,
754 },
755 cx,
756 )
757 })?
758 .await?;
759
760 this.update(&mut cx, |this, cx| {
761 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
762 prompt_editor.token_count = Some(token_count);
763 cx.notify();
764 })
765 }
766 .log_err()
767 });
768 }
769 }
770
771 fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
772 v_flex()
773 .id("prompt-list")
774 .capture_action(cx.listener(Self::focus_active_prompt))
775 .bg(cx.theme().colors().panel_background)
776 .h_full()
777 .px_1()
778 .w_1_3()
779 .overflow_x_hidden()
780 .child(
781 h_flex()
782 .p(Spacing::Small.rems(cx))
783 .h_9()
784 .w_full()
785 .flex_none()
786 .justify_end()
787 .child(
788 IconButton::new("new-prompt", IconName::Plus)
789 .style(ButtonStyle::Transparent)
790 .shape(IconButtonShape::Square)
791 .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx))
792 .on_click(|_, cx| {
793 cx.dispatch_action(Box::new(NewPrompt));
794 }),
795 ),
796 )
797 .child(div().flex_grow().child(self.picker.clone()))
798 }
799
800 fn render_active_prompt(&mut self, cx: &mut ViewContext<PromptLibrary>) -> gpui::Stateful<Div> {
801 div()
802 .w_2_3()
803 .h_full()
804 .id("prompt-editor")
805 .border_l_1()
806 .border_color(cx.theme().colors().border)
807 .bg(cx.theme().colors().editor_background)
808 .flex_none()
809 .min_w_64()
810 .children(self.active_prompt_id.and_then(|prompt_id| {
811 let prompt_metadata = self.store.metadata(prompt_id)?;
812 let prompt_editor = &self.prompt_editors[&prompt_id];
813 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
814 let model = LanguageModelRegistry::read_global(cx).active_model();
815 let settings = ThemeSettings::get_global(cx);
816
817 Some(
818 v_flex()
819 .id("prompt-editor-inner")
820 .size_full()
821 .relative()
822 .overflow_hidden()
823 .pl(Spacing::XXLarge.rems(cx))
824 .pt(Spacing::Large.rems(cx))
825 .on_click(cx.listener(move |_, _, cx| {
826 cx.focus(&focus_handle);
827 }))
828 .child(
829 h_flex()
830 .group("active-editor-header")
831 .pr(Spacing::XXLarge.rems(cx))
832 .pt(Spacing::XSmall.rems(cx))
833 .pb(Spacing::Large.rems(cx))
834 .justify_between()
835 .child(
836 h_flex().gap_1().child(
837 div()
838 .max_w_80()
839 .on_action(cx.listener(Self::move_down_from_title))
840 .border_1()
841 .border_color(transparent_black())
842 .rounded_md()
843 .group_hover("active-editor-header", |this| {
844 this.border_color(
845 cx.theme().colors().border_variant,
846 )
847 })
848 .child(EditorElement::new(
849 &prompt_editor.title_editor,
850 EditorStyle {
851 background: cx.theme().system().transparent,
852 local_player: cx.theme().players().local(),
853 text: TextStyle {
854 color: cx
855 .theme()
856 .colors()
857 .editor_foreground,
858 font_family: settings
859 .ui_font
860 .family
861 .clone(),
862 font_features: settings
863 .ui_font
864 .features
865 .clone(),
866 font_size: HeadlineSize::Large
867 .size()
868 .into(),
869 font_weight: settings.ui_font.weight,
870 line_height: relative(
871 settings.buffer_line_height.value(),
872 ),
873 ..Default::default()
874 },
875 scrollbar_width: Pixels::ZERO,
876 syntax: cx.theme().syntax().clone(),
877 status: cx.theme().status().clone(),
878 inlay_hints_style: HighlightStyle {
879 color: Some(cx.theme().status().hint),
880 ..HighlightStyle::default()
881 },
882 suggestions_style: HighlightStyle {
883 color: Some(cx.theme().status().predictive),
884 ..HighlightStyle::default()
885 },
886 },
887 )),
888 ),
889 )
890 .child(
891 h_flex()
892 .h_full()
893 .child(
894 h_flex()
895 .h_full()
896 .gap(Spacing::XXLarge.rems(cx))
897 .child(div()),
898 )
899 .child(
900 h_flex()
901 .h_full()
902 .gap(Spacing::XXLarge.rems(cx))
903 .children(prompt_editor.token_count.map(
904 |token_count| {
905 let token_count: SharedString =
906 token_count.to_string().into();
907 let label_token_count: SharedString =
908 token_count.to_string().into();
909
910 h_flex()
911 .id("token_count")
912 .tooltip(move |cx| {
913 let token_count =
914 token_count.clone();
915
916 Tooltip::with_meta(
917 format!(
918 "{} tokens",
919 token_count.clone()
920 ),
921 None,
922 format!(
923 "Model: {}",
924 model
925 .as_ref()
926 .map(|model| model
927 .name()
928 .0)
929 .unwrap_or_default()
930 ),
931 cx,
932 )
933 })
934 .child(
935 Label::new(format!(
936 "{} tokens",
937 label_token_count.clone()
938 ))
939 .color(Color::Muted),
940 )
941 },
942 ))
943 .child(
944 IconButton::new(
945 "delete-prompt",
946 IconName::Trash,
947 )
948 .size(ButtonSize::Large)
949 .style(ButtonStyle::Transparent)
950 .shape(IconButtonShape::Square)
951 .size(ButtonSize::Large)
952 .tooltip(move |cx| {
953 Tooltip::for_action(
954 "Delete Prompt",
955 &DeletePrompt,
956 cx,
957 )
958 })
959 .on_click(|_, cx| {
960 cx.dispatch_action(Box::new(DeletePrompt));
961 }),
962 )
963 .child(
964 IconButton::new(
965 "duplicate-prompt",
966 IconName::BookCopy,
967 )
968 .size(ButtonSize::Large)
969 .style(ButtonStyle::Transparent)
970 .shape(IconButtonShape::Square)
971 .size(ButtonSize::Large)
972 .tooltip(move |cx| {
973 Tooltip::for_action(
974 "Duplicate Prompt",
975 &DuplicatePrompt,
976 cx,
977 )
978 })
979 .on_click(|_, cx| {
980 cx.dispatch_action(Box::new(
981 DuplicatePrompt,
982 ));
983 }),
984 )
985 .child(
986 IconButton::new(
987 "toggle-default-prompt",
988 IconName::Sparkle,
989 )
990 .style(ButtonStyle::Transparent)
991 .selected(prompt_metadata.default)
992 .selected_icon(IconName::SparkleFilled)
993 .icon_color(if prompt_metadata.default {
994 Color::Accent
995 } else {
996 Color::Muted
997 })
998 .shape(IconButtonShape::Square)
999 .size(ButtonSize::Large)
1000 .tooltip(move |cx| {
1001 Tooltip::text(
1002 if prompt_metadata.default {
1003 "Remove from Default Prompt"
1004 } else {
1005 "Add to Default Prompt"
1006 },
1007 cx,
1008 )
1009 })
1010 .on_click(|_, cx| {
1011 cx.dispatch_action(Box::new(
1012 ToggleDefaultPrompt,
1013 ));
1014 }),
1015 ),
1016 ),
1017 ),
1018 )
1019 .child(
1020 div()
1021 .on_action(cx.listener(Self::focus_picker))
1022 .on_action(cx.listener(Self::inline_assist))
1023 .on_action(cx.listener(Self::move_up_from_body))
1024 .flex_grow()
1025 .h_full()
1026 .child(prompt_editor.body_editor.clone()),
1027 ),
1028 )
1029 }))
1030 }
1031}
1032
1033impl Render for PromptLibrary {
1034 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1035 let ui_font = theme::setup_ui_font(cx);
1036 let theme = cx.theme().clone();
1037
1038 h_flex()
1039 .id("prompt-manager")
1040 .key_context("PromptLibrary")
1041 .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx)))
1042 .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx)))
1043 .on_action(cx.listener(|this, &DuplicatePrompt, cx| this.duplicate_active_prompt(cx)))
1044 .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| {
1045 this.toggle_default_for_active_prompt(cx)
1046 }))
1047 .size_full()
1048 .overflow_hidden()
1049 .font(ui_font)
1050 .text_color(theme.colors().text)
1051 .child(self.render_prompt_list(cx))
1052 .child(self.render_active_prompt(cx))
1053 }
1054}
1055
1056#[derive(Clone, Debug, Serialize, Deserialize)]
1057pub struct PromptMetadata {
1058 pub id: PromptId,
1059 pub title: Option<SharedString>,
1060 pub default: bool,
1061 pub saved_at: DateTime<Utc>,
1062}
1063
1064#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1065pub struct PromptId(Uuid);
1066
1067impl PromptId {
1068 pub fn new() -> PromptId {
1069 PromptId(Uuid::new_v4())
1070 }
1071}
1072
1073pub struct PromptStore {
1074 executor: BackgroundExecutor,
1075 env: heed::Env,
1076 bodies: Database<SerdeBincode<PromptId>, SerdeBincode<String>>,
1077 metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
1078 metadata_cache: RwLock<MetadataCache>,
1079}
1080
1081#[derive(Default)]
1082struct MetadataCache {
1083 metadata: Vec<PromptMetadata>,
1084 metadata_by_id: HashMap<PromptId, PromptMetadata>,
1085}
1086
1087impl MetadataCache {
1088 fn from_db(
1089 db: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
1090 txn: &RoTxn,
1091 ) -> Result<Self> {
1092 let mut cache = MetadataCache::default();
1093 for result in db.iter(txn)? {
1094 let (prompt_id, metadata) = result?;
1095 cache.metadata.push(metadata.clone());
1096 cache.metadata_by_id.insert(prompt_id, metadata);
1097 }
1098 cache.sort();
1099 Ok(cache)
1100 }
1101
1102 fn insert(&mut self, metadata: PromptMetadata) {
1103 self.metadata_by_id.insert(metadata.id, metadata.clone());
1104 if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
1105 *old_metadata = metadata;
1106 } else {
1107 self.metadata.push(metadata);
1108 }
1109 self.sort();
1110 }
1111
1112 fn remove(&mut self, id: PromptId) {
1113 self.metadata.retain(|metadata| metadata.id != id);
1114 self.metadata_by_id.remove(&id);
1115 }
1116
1117 fn sort(&mut self) {
1118 self.metadata.sort_unstable_by(|a, b| {
1119 a.title
1120 .cmp(&b.title)
1121 .then_with(|| b.saved_at.cmp(&a.saved_at))
1122 });
1123 }
1124}
1125
1126impl PromptStore {
1127 pub fn global(cx: &AppContext) -> impl Future<Output = Result<Arc<Self>>> {
1128 let store = GlobalPromptStore::global(cx).0.clone();
1129 async move { store.await.map_err(|err| anyhow!(err)) }
1130 }
1131
1132 pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task<Result<Self>> {
1133 executor.spawn({
1134 let executor = executor.clone();
1135 async move {
1136 std::fs::create_dir_all(&db_path)?;
1137
1138 let db_env = unsafe {
1139 heed::EnvOpenOptions::new()
1140 .map_size(1024 * 1024 * 1024) // 1GB
1141 .max_dbs(2) // bodies and metadata
1142 .open(db_path)?
1143 };
1144
1145 let mut txn = db_env.write_txn()?;
1146 let bodies = db_env.create_database(&mut txn, Some("bodies"))?;
1147 let metadata = db_env.create_database(&mut txn, Some("metadata"))?;
1148 let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
1149 txn.commit()?;
1150
1151 Ok(PromptStore {
1152 executor,
1153 env: db_env,
1154 bodies,
1155 metadata,
1156 metadata_cache: RwLock::new(metadata_cache),
1157 })
1158 }
1159 })
1160 }
1161
1162 pub fn load(&self, id: PromptId) -> Task<Result<String>> {
1163 let env = self.env.clone();
1164 let bodies = self.bodies;
1165 self.executor.spawn(async move {
1166 let txn = env.read_txn()?;
1167 bodies
1168 .get(&txn, &id)?
1169 .ok_or_else(|| anyhow!("prompt not found"))
1170 })
1171 }
1172
1173 pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
1174 return self
1175 .metadata_cache
1176 .read()
1177 .metadata
1178 .iter()
1179 .filter(|metadata| metadata.default)
1180 .cloned()
1181 .collect::<Vec<_>>();
1182 }
1183
1184 pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
1185 self.metadata_cache.write().remove(id);
1186
1187 let db_connection = self.env.clone();
1188 let bodies = self.bodies;
1189 let metadata = self.metadata;
1190
1191 self.executor.spawn(async move {
1192 let mut txn = db_connection.write_txn()?;
1193
1194 metadata.delete(&mut txn, &id)?;
1195 bodies.delete(&mut txn, &id)?;
1196
1197 txn.commit()?;
1198 Ok(())
1199 })
1200 }
1201
1202 fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
1203 self.metadata_cache.read().metadata_by_id.get(&id).cloned()
1204 }
1205
1206 pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
1207 let metadata_cache = self.metadata_cache.read();
1208 let metadata = metadata_cache
1209 .metadata
1210 .iter()
1211 .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
1212 Some(metadata.id)
1213 }
1214
1215 pub fn search(&self, query: String) -> Task<Vec<PromptMetadata>> {
1216 let cached_metadata = self.metadata_cache.read().metadata.clone();
1217 let executor = self.executor.clone();
1218 self.executor.spawn(async move {
1219 let mut matches = if query.is_empty() {
1220 cached_metadata
1221 } else {
1222 let candidates = cached_metadata
1223 .iter()
1224 .enumerate()
1225 .filter_map(|(ix, metadata)| {
1226 Some(StringMatchCandidate::new(
1227 ix,
1228 metadata.title.as_ref()?.to_string(),
1229 ))
1230 })
1231 .collect::<Vec<_>>();
1232 let matches = fuzzy::match_strings(
1233 &candidates,
1234 &query,
1235 false,
1236 100,
1237 &AtomicBool::default(),
1238 executor,
1239 )
1240 .await;
1241 matches
1242 .into_iter()
1243 .map(|mat| cached_metadata[mat.candidate_id].clone())
1244 .collect()
1245 };
1246 matches.sort_by_key(|metadata| Reverse(metadata.default));
1247 matches
1248 })
1249 }
1250
1251 fn save(
1252 &self,
1253 id: PromptId,
1254 title: Option<SharedString>,
1255 default: bool,
1256 body: Rope,
1257 ) -> Task<Result<()>> {
1258 let prompt_metadata = PromptMetadata {
1259 id,
1260 title,
1261 default,
1262 saved_at: Utc::now(),
1263 };
1264 self.metadata_cache.write().insert(prompt_metadata.clone());
1265
1266 let db_connection = self.env.clone();
1267 let bodies = self.bodies;
1268 let metadata = self.metadata;
1269
1270 self.executor.spawn(async move {
1271 let mut txn = db_connection.write_txn()?;
1272
1273 metadata.put(&mut txn, &id, &prompt_metadata)?;
1274 bodies.put(&mut txn, &id, &body.to_string())?;
1275
1276 txn.commit()?;
1277
1278 Ok(())
1279 })
1280 }
1281
1282 fn save_metadata(
1283 &self,
1284 id: PromptId,
1285 title: Option<SharedString>,
1286 default: bool,
1287 ) -> Task<Result<()>> {
1288 let prompt_metadata = PromptMetadata {
1289 id,
1290 title,
1291 default,
1292 saved_at: Utc::now(),
1293 };
1294 self.metadata_cache.write().insert(prompt_metadata.clone());
1295
1296 let db_connection = self.env.clone();
1297 let metadata = self.metadata;
1298
1299 self.executor.spawn(async move {
1300 let mut txn = db_connection.write_txn()?;
1301 metadata.put(&mut txn, &id, &prompt_metadata)?;
1302 txn.commit()?;
1303
1304 Ok(())
1305 })
1306 }
1307
1308 fn first(&self) -> Option<PromptMetadata> {
1309 self.metadata_cache.read().metadata.first().cloned()
1310 }
1311
1312 pub fn operations_prompt(&self) -> String {
1313 String::from_utf8(
1314 Assets
1315 .load("prompts/operations.md")
1316 .unwrap()
1317 .unwrap()
1318 .to_vec(),
1319 )
1320 .unwrap()
1321 }
1322}
1323
1324/// Wraps a shared future to a prompt store so it can be assigned as a context global.
1325pub struct GlobalPromptStore(
1326 Shared<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
1327);
1328
1329impl Global for GlobalPromptStore {}