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