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