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