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