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