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<u64>,
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
265 let element = ListItem::new(ix)
266 .inset(true)
267 .spacing(ListItemSpacing::Sparse)
268 .toggle_state(selected)
269 .child(
270 h_flex()
271 .h_5()
272 .line_height(relative(1.))
273 .child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
274 )
275 .end_slot::<IconButton>(default.then(|| {
276 IconButton::new("toggle-default-rule", IconName::StarFilled)
277 .toggle_state(true)
278 .icon_color(Color::Accent)
279 .icon_size(IconSize::Small)
280 .shape(IconButtonShape::Square)
281 .tooltip(Tooltip::text("Remove from Default Rules"))
282 .on_click(cx.listener(move |_, _, _, cx| {
283 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
284 }))
285 }))
286 .end_hover_slot(
287 h_flex()
288 .gap_1()
289 .child(if prompt_id.is_built_in() {
290 div()
291 .id("built-in-rule")
292 .child(Icon::new(IconName::FileLock).color(Color::Muted))
293 .tooltip(move |window, cx| {
294 Tooltip::with_meta(
295 "Built-in rule",
296 None,
297 BUILT_IN_TOOLTIP_TEXT,
298 window,
299 cx,
300 )
301 })
302 .into_any()
303 } else {
304 IconButton::new("delete-rule", IconName::TrashAlt)
305 .icon_color(Color::Muted)
306 .icon_size(IconSize::Small)
307 .shape(IconButtonShape::Square)
308 .tooltip(Tooltip::text("Delete Rule"))
309 .on_click(cx.listener(move |_, _, _, cx| {
310 cx.emit(RulePickerEvent::Deleted { prompt_id })
311 }))
312 .into_any_element()
313 })
314 .child(
315 IconButton::new("toggle-default-rule", IconName::Star)
316 .toggle_state(default)
317 .selected_icon(IconName::StarFilled)
318 .icon_color(if default { Color::Accent } else { Color::Muted })
319 .icon_size(IconSize::Small)
320 .shape(IconButtonShape::Square)
321 .map(|this| {
322 if default {
323 this.tooltip(Tooltip::text("Remove from Default Rules"))
324 } else {
325 this.tooltip(move |window, cx| {
326 Tooltip::with_meta(
327 "Add to Default Rules",
328 None,
329 "Always included in every thread.",
330 window,
331 cx,
332 )
333 })
334 }
335 })
336 .on_click(cx.listener(move |_, _, _, cx| {
337 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
338 })),
339 ),
340 );
341 Some(element)
342 }
343
344 fn render_editor(
345 &self,
346 editor: &Entity<Editor>,
347 _: &mut Window,
348 cx: &mut Context<Picker<Self>>,
349 ) -> Div {
350 h_flex()
351 .bg(cx.theme().colors().editor_background)
352 .rounded_sm()
353 .overflow_hidden()
354 .flex_none()
355 .py_1()
356 .px_2()
357 .mx_1()
358 .child(editor.clone())
359 }
360}
361
362impl RulesLibrary {
363 fn new(
364 store: Entity<PromptStore>,
365 language_registry: Arc<LanguageRegistry>,
366 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
367 make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
368 rule_to_select: Option<PromptId>,
369 window: &mut Window,
370 cx: &mut Context<Self>,
371 ) -> Self {
372 let (selected_index, matches) = if let Some(rule_to_select) = rule_to_select {
373 let matches = store.read(cx).all_prompt_metadata();
374 let selected_index = matches
375 .iter()
376 .enumerate()
377 .find(|(_, metadata)| metadata.id == rule_to_select)
378 .map_or(0, |(ix, _)| ix);
379 (selected_index, matches)
380 } else {
381 (0, vec![])
382 };
383
384 let delegate = RulePickerDelegate {
385 store: store.clone(),
386 selected_index,
387 matches,
388 };
389
390 let picker = cx.new(|cx| {
391 let picker = Picker::uniform_list(delegate, window, cx)
392 .modal(false)
393 .max_height(None);
394 picker.focus(window, cx);
395 picker
396 });
397 Self {
398 store: store.clone(),
399 language_registry,
400 rule_editors: HashMap::default(),
401 active_rule_id: None,
402 pending_load: Task::ready(()),
403 inline_assist_delegate,
404 make_completion_provider,
405 _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
406 picker,
407 }
408 }
409
410 fn handle_picker_event(
411 &mut self,
412 _: &Entity<Picker<RulePickerDelegate>>,
413 event: &RulePickerEvent,
414 window: &mut Window,
415 cx: &mut Context<Self>,
416 ) {
417 match event {
418 RulePickerEvent::Selected { prompt_id } => {
419 self.load_rule(*prompt_id, false, window, cx);
420 }
421 RulePickerEvent::Confirmed { prompt_id } => {
422 self.load_rule(*prompt_id, true, window, cx);
423 }
424 RulePickerEvent::ToggledDefault { prompt_id } => {
425 self.toggle_default_for_rule(*prompt_id, window, cx);
426 }
427 RulePickerEvent::Deleted { prompt_id } => {
428 self.delete_rule(*prompt_id, window, cx);
429 }
430 }
431 }
432
433 pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
434 // If we already have an untitled rule, use that instead
435 // of creating a new one.
436 if let Some(metadata) = self.store.read(cx).first() {
437 if metadata.title.is_none() {
438 self.load_rule(metadata.id, true, window, cx);
439 return;
440 }
441 }
442
443 let prompt_id = PromptId::new();
444 let save = self.store.update(cx, |store, cx| {
445 store.save(prompt_id, None, false, "".into(), cx)
446 });
447 self.picker
448 .update(cx, |picker, cx| picker.refresh(window, cx));
449 cx.spawn_in(window, async move |this, cx| {
450 save.await?;
451 this.update_in(cx, |this, window, cx| {
452 this.load_rule(prompt_id, true, window, cx)
453 })
454 })
455 .detach_and_log_err(cx);
456 }
457
458 pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
459 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
460
461 if prompt_id.is_built_in() {
462 return;
463 }
464
465 let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
466 let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap();
467 let title = rule_editor.title_editor.read(cx).text(cx);
468 let body = rule_editor.body_editor.update(cx, |editor, cx| {
469 editor
470 .buffer()
471 .read(cx)
472 .as_singleton()
473 .unwrap()
474 .read(cx)
475 .as_rope()
476 .clone()
477 });
478
479 let store = self.store.clone();
480 let executor = cx.background_executor().clone();
481
482 rule_editor.next_title_and_body_to_save = Some((title, body));
483 if rule_editor.pending_save.is_none() {
484 rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
485 async move {
486 loop {
487 let title_and_body = this.update(cx, |this, _| {
488 this.rule_editors
489 .get_mut(&prompt_id)?
490 .next_title_and_body_to_save
491 .take()
492 })?;
493
494 if let Some((title, body)) = title_and_body {
495 let title = if title.trim().is_empty() {
496 None
497 } else {
498 Some(SharedString::from(title))
499 };
500 cx.update(|_window, cx| {
501 store.update(cx, |store, cx| {
502 store.save(prompt_id, title, rule_metadata.default, body, cx)
503 })
504 })?
505 .await
506 .log_err();
507 this.update_in(cx, |this, window, cx| {
508 this.picker
509 .update(cx, |picker, cx| picker.refresh(window, cx));
510 cx.notify();
511 })?;
512
513 executor.timer(SAVE_THROTTLE).await;
514 } else {
515 break;
516 }
517 }
518
519 this.update(cx, |this, _cx| {
520 if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) {
521 rule_editor.pending_save = None;
522 }
523 })
524 }
525 .log_err()
526 .await
527 }));
528 }
529 }
530
531 pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
532 if let Some(active_rule_id) = self.active_rule_id {
533 self.delete_rule(active_rule_id, window, cx);
534 }
535 }
536
537 pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
538 if let Some(active_rule_id) = self.active_rule_id {
539 self.duplicate_rule(active_rule_id, window, cx);
540 }
541 }
542
543 pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
544 if let Some(active_rule_id) = self.active_rule_id {
545 self.toggle_default_for_rule(active_rule_id, window, cx);
546 }
547 }
548
549 pub fn toggle_default_for_rule(
550 &mut self,
551 prompt_id: PromptId,
552 window: &mut Window,
553 cx: &mut Context<Self>,
554 ) {
555 self.store.update(cx, move |store, cx| {
556 if let Some(rule_metadata) = store.metadata(prompt_id) {
557 store
558 .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx)
559 .detach_and_log_err(cx);
560 }
561 });
562 self.picker
563 .update(cx, |picker, cx| picker.refresh(window, cx));
564 cx.notify();
565 }
566
567 pub fn load_rule(
568 &mut self,
569 prompt_id: PromptId,
570 focus: bool,
571 window: &mut Window,
572 cx: &mut Context<Self>,
573 ) {
574 if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
575 if focus {
576 rule_editor
577 .body_editor
578 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
579 }
580 self.set_active_rule(Some(prompt_id), window, cx);
581 } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
582 let language_registry = self.language_registry.clone();
583 let rule = self.store.read(cx).load(prompt_id, cx);
584 let make_completion_provider = self.make_completion_provider.clone();
585 self.pending_load = cx.spawn_in(window, async move |this, cx| {
586 let rule = rule.await;
587 let markdown = language_registry.language_for_name("Markdown").await;
588 this.update_in(cx, |this, window, cx| match rule {
589 Ok(rule) => {
590 let title_editor = cx.new(|cx| {
591 let mut editor = Editor::auto_width(window, cx);
592 editor.set_placeholder_text("Untitled", cx);
593 editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx);
594 if prompt_id.is_built_in() {
595 editor.set_read_only(true);
596 editor.set_show_edit_predictions(Some(false), window, cx);
597 }
598 editor
599 });
600 let body_editor = cx.new(|cx| {
601 let buffer = cx.new(|cx| {
602 let mut buffer = Buffer::local(rule, cx);
603 buffer.set_language(markdown.log_err(), cx);
604 buffer.set_language_registry(language_registry);
605 buffer
606 });
607
608 let mut editor = Editor::for_buffer(buffer, None, window, cx);
609 if prompt_id.is_built_in() {
610 editor.set_read_only(true);
611 editor.set_show_edit_predictions(Some(false), window, cx);
612 }
613 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
614 editor.set_show_gutter(false, cx);
615 editor.set_show_wrap_guides(false, cx);
616 editor.set_show_indent_guides(false, cx);
617 editor.set_use_modal_editing(false);
618 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
619 editor.set_completion_provider(Some(make_completion_provider()));
620 if focus {
621 window.focus(&editor.focus_handle(cx));
622 }
623 editor
624 });
625 let _subscriptions = vec![
626 cx.subscribe_in(
627 &title_editor,
628 window,
629 move |this, editor, event, window, cx| {
630 this.handle_rule_title_editor_event(
631 prompt_id, editor, event, window, cx,
632 )
633 },
634 ),
635 cx.subscribe_in(
636 &body_editor,
637 window,
638 move |this, editor, event, window, cx| {
639 this.handle_rule_body_editor_event(
640 prompt_id, editor, event, window, cx,
641 )
642 },
643 ),
644 ];
645 this.rule_editors.insert(
646 prompt_id,
647 RuleEditor {
648 title_editor,
649 body_editor,
650 next_title_and_body_to_save: None,
651 pending_save: None,
652 token_count: None,
653 pending_token_count: Task::ready(None),
654 _subscriptions,
655 },
656 );
657 this.set_active_rule(Some(prompt_id), window, cx);
658 this.count_tokens(prompt_id, window, cx);
659 }
660 Err(error) => {
661 // TODO: we should show the error in the UI.
662 log::error!("error while loading rule: {:?}", error);
663 }
664 })
665 .ok();
666 });
667 }
668 }
669
670 fn set_active_rule(
671 &mut self,
672 prompt_id: Option<PromptId>,
673 window: &mut Window,
674 cx: &mut Context<Self>,
675 ) {
676 self.active_rule_id = prompt_id;
677 self.picker.update(cx, |picker, cx| {
678 if let Some(prompt_id) = prompt_id {
679 if picker
680 .delegate
681 .matches
682 .get(picker.delegate.selected_index())
683 .map_or(true, |old_selected_prompt| {
684 old_selected_prompt.id != prompt_id
685 })
686 {
687 if let Some(ix) = picker
688 .delegate
689 .matches
690 .iter()
691 .position(|mat| mat.id == prompt_id)
692 {
693 picker.set_selected_index(ix, None, true, window, cx);
694 }
695 }
696 } else {
697 picker.focus(window, cx);
698 }
699 });
700 cx.notify();
701 }
702
703 pub fn delete_rule(
704 &mut self,
705 prompt_id: PromptId,
706 window: &mut Window,
707 cx: &mut Context<Self>,
708 ) {
709 if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
710 let confirmation = window.prompt(
711 PromptLevel::Warning,
712 &format!(
713 "Are you sure you want to delete {}",
714 metadata.title.unwrap_or("Untitled".into())
715 ),
716 None,
717 &["Delete", "Cancel"],
718 cx,
719 );
720
721 cx.spawn_in(window, async move |this, cx| {
722 if confirmation.await.ok() == Some(0) {
723 this.update_in(cx, |this, window, cx| {
724 if this.active_rule_id == Some(prompt_id) {
725 this.set_active_rule(None, window, cx);
726 }
727 this.rule_editors.remove(&prompt_id);
728 this.store
729 .update(cx, |store, cx| store.delete(prompt_id, cx))
730 .detach_and_log_err(cx);
731 this.picker
732 .update(cx, |picker, cx| picker.refresh(window, cx));
733 cx.notify();
734 })?;
735 }
736 anyhow::Ok(())
737 })
738 .detach_and_log_err(cx);
739 }
740 }
741
742 pub fn duplicate_rule(
743 &mut self,
744 prompt_id: PromptId,
745 window: &mut Window,
746 cx: &mut Context<Self>,
747 ) {
748 if let Some(rule) = self.rule_editors.get(&prompt_id) {
749 const DUPLICATE_SUFFIX: &str = " copy";
750 let title_to_duplicate = rule.title_editor.read(cx).text(cx);
751 let existing_titles = self
752 .rule_editors
753 .iter()
754 .filter(|&(&id, _)| id != prompt_id)
755 .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx))
756 .filter(|title| title.starts_with(&title_to_duplicate))
757 .collect::<HashSet<_>>();
758
759 let title = if existing_titles.is_empty() {
760 title_to_duplicate + DUPLICATE_SUFFIX
761 } else {
762 let mut i = 1;
763 loop {
764 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
765 if !existing_titles.contains(&new_title) {
766 break new_title;
767 }
768 i += 1;
769 }
770 };
771
772 let new_id = PromptId::new();
773 let body = rule.body_editor.read(cx).text(cx);
774 let save = self.store.update(cx, |store, cx| {
775 store.save(new_id, Some(title.into()), false, body.into(), cx)
776 });
777 self.picker
778 .update(cx, |picker, cx| picker.refresh(window, cx));
779 cx.spawn_in(window, async move |this, cx| {
780 save.await?;
781 this.update_in(cx, |rules_library, window, cx| {
782 rules_library.load_rule(new_id, true, window, cx)
783 })
784 })
785 .detach_and_log_err(cx);
786 }
787 }
788
789 fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
790 if let Some(active_rule) = self.active_rule_id {
791 self.rule_editors[&active_rule]
792 .body_editor
793 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
794 cx.stop_propagation();
795 }
796 }
797
798 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
799 self.picker
800 .update(cx, |picker, cx| picker.focus(window, cx));
801 }
802
803 pub fn inline_assist(
804 &mut self,
805 action: &InlineAssist,
806 window: &mut Window,
807 cx: &mut Context<Self>,
808 ) {
809 let Some(active_rule_id) = self.active_rule_id else {
810 cx.propagate();
811 return;
812 };
813
814 let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
815 let Some(ConfiguredModel { provider, .. }) =
816 LanguageModelRegistry::read_global(cx).inline_assistant_model()
817 else {
818 return;
819 };
820
821 let initial_prompt = action.prompt.clone();
822 if provider.is_authenticated(cx) {
823 self.inline_assist_delegate
824 .assist(rule_editor, initial_prompt, window, cx);
825 } else {
826 for window in cx.windows() {
827 if let Some(workspace) = window.downcast::<Workspace>() {
828 let panel = workspace
829 .update(cx, |workspace, window, cx| {
830 window.activate_window();
831 self.inline_assist_delegate
832 .focus_agent_panel(workspace, window, cx)
833 })
834 .ok();
835 if panel == Some(true) {
836 return;
837 }
838 }
839 }
840 }
841 }
842
843 fn move_down_from_title(
844 &mut self,
845 _: &editor::actions::MoveDown,
846 window: &mut Window,
847 cx: &mut Context<Self>,
848 ) {
849 if let Some(rule_id) = self.active_rule_id {
850 if let Some(rule_editor) = self.rule_editors.get(&rule_id) {
851 window.focus(&rule_editor.body_editor.focus_handle(cx));
852 }
853 }
854 }
855
856 fn move_up_from_body(
857 &mut self,
858 _: &editor::actions::MoveUp,
859 window: &mut Window,
860 cx: &mut Context<Self>,
861 ) {
862 if let Some(rule_id) = self.active_rule_id {
863 if let Some(rule_editor) = self.rule_editors.get(&rule_id) {
864 window.focus(&rule_editor.title_editor.focus_handle(cx));
865 }
866 }
867 }
868
869 fn handle_rule_title_editor_event(
870 &mut self,
871 prompt_id: PromptId,
872 title_editor: &Entity<Editor>,
873 event: &EditorEvent,
874 window: &mut Window,
875 cx: &mut Context<Self>,
876 ) {
877 match event {
878 EditorEvent::BufferEdited => {
879 self.save_rule(prompt_id, window, cx);
880 self.count_tokens(prompt_id, window, cx);
881 }
882 EditorEvent::Blurred => {
883 title_editor.update(cx, |title_editor, cx| {
884 title_editor.change_selections(None, window, cx, |selections| {
885 let cursor = selections.oldest_anchor().head();
886 selections.select_anchor_ranges([cursor..cursor]);
887 });
888 });
889 }
890 _ => {}
891 }
892 }
893
894 fn handle_rule_body_editor_event(
895 &mut self,
896 prompt_id: PromptId,
897 body_editor: &Entity<Editor>,
898 event: &EditorEvent,
899 window: &mut Window,
900 cx: &mut Context<Self>,
901 ) {
902 match event {
903 EditorEvent::BufferEdited => {
904 self.save_rule(prompt_id, window, cx);
905 self.count_tokens(prompt_id, window, cx);
906 }
907 EditorEvent::Blurred => {
908 body_editor.update(cx, |body_editor, cx| {
909 body_editor.change_selections(None, window, cx, |selections| {
910 let cursor = selections.oldest_anchor().head();
911 selections.select_anchor_ranges([cursor..cursor]);
912 });
913 });
914 }
915 _ => {}
916 }
917 }
918
919 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
920 let Some(ConfiguredModel { model, .. }) =
921 LanguageModelRegistry::read_global(cx).default_model()
922 else {
923 return;
924 };
925 if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
926 let editor = &rule.body_editor.read(cx);
927 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
928 let body = buffer.as_rope().clone();
929 rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
930 async move {
931 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
932
933 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
934 let token_count = cx
935 .update(|_, cx| {
936 model.count_tokens(
937 LanguageModelRequest {
938 thread_id: None,
939 prompt_id: None,
940 intent: None,
941 mode: None,
942 messages: vec![LanguageModelRequestMessage {
943 role: Role::System,
944 content: vec![body.to_string().into()],
945 cache: false,
946 }],
947 tools: Vec::new(),
948 tool_choice: None,
949 stop: Vec::new(),
950 temperature: None,
951 },
952 cx,
953 )
954 })?
955 .await?;
956
957 this.update(cx, |this, cx| {
958 let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
959 rule_editor.token_count = Some(token_count);
960 cx.notify();
961 })
962 }
963 .log_err()
964 .await
965 });
966 }
967 }
968
969 fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
970 v_flex()
971 .id("rule-list")
972 .capture_action(cx.listener(Self::focus_active_rule))
973 .bg(cx.theme().colors().panel_background)
974 .h_full()
975 .px_1()
976 .w_1_3()
977 .overflow_x_hidden()
978 .child(
979 h_flex()
980 .p(DynamicSpacing::Base04.rems(cx))
981 .h_9()
982 .w_full()
983 .flex_none()
984 .justify_end()
985 .child(
986 IconButton::new("new-rule", IconName::Plus)
987 .style(ButtonStyle::Transparent)
988 .shape(IconButtonShape::Square)
989 .tooltip(move |window, cx| {
990 Tooltip::for_action("New Rule", &NewRule, window, cx)
991 })
992 .on_click(|_, window, cx| {
993 window.dispatch_action(Box::new(NewRule), cx);
994 }),
995 ),
996 )
997 .child(div().flex_grow().child(self.picker.clone()))
998 }
999
1000 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
1001 div()
1002 .w_2_3()
1003 .h_full()
1004 .id("rule-editor")
1005 .border_l_1()
1006 .border_color(cx.theme().colors().border)
1007 .bg(cx.theme().colors().editor_background)
1008 .flex_none()
1009 .min_w_64()
1010 .children(self.active_rule_id.and_then(|prompt_id| {
1011 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
1012 let rule_editor = &self.rule_editors[&prompt_id];
1013 let focus_handle = rule_editor.body_editor.focus_handle(cx);
1014 let model = LanguageModelRegistry::read_global(cx)
1015 .default_model()
1016 .map(|default| default.model);
1017 let settings = ThemeSettings::get_global(cx);
1018
1019 Some(
1020 v_flex()
1021 .id("rule-editor-inner")
1022 .size_full()
1023 .relative()
1024 .overflow_hidden()
1025 .on_click(cx.listener(move |_, _, window, _| {
1026 window.focus(&focus_handle);
1027 }))
1028 .child(
1029 h_flex()
1030 .group("active-editor-header")
1031 .pt_2()
1032 .px_2p5()
1033 .gap_2()
1034 .justify_between()
1035 .child(
1036 div()
1037 .w_full()
1038 .on_action(cx.listener(Self::move_down_from_title))
1039 .border_1()
1040 .border_color(transparent_black())
1041 .rounded_sm()
1042 .group_hover("active-editor-header", |this| {
1043 this.border_color(cx.theme().colors().border_variant)
1044 })
1045 .child(EditorElement::new(
1046 &rule_editor.title_editor,
1047 EditorStyle {
1048 background: cx.theme().system().transparent,
1049 local_player: cx.theme().players().local(),
1050 text: TextStyle {
1051 color: cx.theme().colors().editor_foreground,
1052 font_family: settings.ui_font.family.clone(),
1053 font_features: settings
1054 .ui_font
1055 .features
1056 .clone(),
1057 font_size: HeadlineSize::Large.rems().into(),
1058 font_weight: settings.ui_font.weight,
1059 line_height: relative(
1060 settings.buffer_line_height.value(),
1061 ),
1062 ..Default::default()
1063 },
1064 scrollbar_width: Pixels::ZERO,
1065 syntax: cx.theme().syntax().clone(),
1066 status: cx.theme().status().clone(),
1067 inlay_hints_style: editor::make_inlay_hints_style(
1068 cx,
1069 ),
1070 inline_completion_styles:
1071 editor::make_suggestion_styles(cx),
1072 ..EditorStyle::default()
1073 },
1074 )),
1075 )
1076 .child(
1077 h_flex()
1078 .h_full()
1079 .flex_shrink_0()
1080 .gap(DynamicSpacing::Base04.rems(cx))
1081 .children(rule_editor.token_count.map(|token_count| {
1082 let token_count: SharedString =
1083 token_count.to_string().into();
1084 let label_token_count: SharedString =
1085 token_count.to_string().into();
1086
1087 div()
1088 .id("token_count")
1089 .mr_1()
1090 .flex_shrink_0()
1091 .tooltip(move |window, cx| {
1092 Tooltip::with_meta(
1093 "Token Estimation",
1094 None,
1095 format!(
1096 "Model: {}",
1097 model
1098 .as_ref()
1099 .map(|model| model.name().0)
1100 .unwrap_or_default()
1101 ),
1102 window,
1103 cx,
1104 )
1105 })
1106 .child(
1107 Label::new(format!(
1108 "{} tokens",
1109 label_token_count.clone()
1110 ))
1111 .color(Color::Muted),
1112 )
1113 }))
1114 .child(if prompt_id.is_built_in() {
1115 div()
1116 .id("built-in-rule")
1117 .child(
1118 Icon::new(IconName::FileLock)
1119 .color(Color::Muted),
1120 )
1121 .tooltip(move |window, cx| {
1122 Tooltip::with_meta(
1123 "Built-in rule",
1124 None,
1125 BUILT_IN_TOOLTIP_TEXT,
1126 window,
1127 cx,
1128 )
1129 })
1130 .into_any()
1131 } else {
1132 IconButton::new("delete-rule", IconName::TrashAlt)
1133 .icon_size(IconSize::Small)
1134 .tooltip(move |window, cx| {
1135 Tooltip::for_action(
1136 "Delete Rule",
1137 &DeleteRule,
1138 window,
1139 cx,
1140 )
1141 })
1142 .on_click(|_, window, cx| {
1143 window
1144 .dispatch_action(Box::new(DeleteRule), cx);
1145 })
1146 .into_any_element()
1147 })
1148 .child(
1149 IconButton::new("duplicate-rule", IconName::BookCopy)
1150 .icon_size(IconSize::Small)
1151 .tooltip(move |window, cx| {
1152 Tooltip::for_action(
1153 "Duplicate Rule",
1154 &DuplicateRule,
1155 window,
1156 cx,
1157 )
1158 })
1159 .on_click(|_, window, cx| {
1160 window.dispatch_action(
1161 Box::new(DuplicateRule),
1162 cx,
1163 );
1164 }),
1165 )
1166 .child(
1167 IconButton::new("toggle-default-rule", IconName::Star)
1168 .icon_size(IconSize::Small)
1169 .toggle_state(rule_metadata.default)
1170 .selected_icon(IconName::StarFilled)
1171 .icon_color(if rule_metadata.default {
1172 Color::Accent
1173 } else {
1174 Color::Muted
1175 })
1176 .map(|this| {
1177 if rule_metadata.default {
1178 this.tooltip(Tooltip::text(
1179 "Remove from Default Rules",
1180 ))
1181 } else {
1182 this.tooltip(move |window, cx| {
1183 Tooltip::with_meta(
1184 "Add to Default Rules",
1185 None,
1186 "Always included in every thread.",
1187 window,
1188 cx,
1189 )
1190 })
1191 }
1192 })
1193 .on_click(|_, window, cx| {
1194 window.dispatch_action(
1195 Box::new(ToggleDefaultRule),
1196 cx,
1197 );
1198 }),
1199 ),
1200 ),
1201 )
1202 .child(
1203 div()
1204 .on_action(cx.listener(Self::focus_picker))
1205 .on_action(cx.listener(Self::inline_assist))
1206 .on_action(cx.listener(Self::move_up_from_body))
1207 .flex_grow()
1208 .h_full()
1209 .child(
1210 h_flex()
1211 .py_2()
1212 .pl_2p5()
1213 .h_full()
1214 .flex_1()
1215 .child(rule_editor.body_editor.clone()),
1216 ),
1217 ),
1218 )
1219 }))
1220 }
1221}
1222
1223impl Render for RulesLibrary {
1224 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1225 let ui_font = theme::setup_ui_font(window, cx);
1226 let theme = cx.theme().clone();
1227
1228 h_flex()
1229 .id("rules-library")
1230 .key_context("PromptLibrary")
1231 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1232 .on_action(
1233 cx.listener(|this, &DeleteRule, window, cx| this.delete_active_rule(window, cx)),
1234 )
1235 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1236 this.duplicate_active_rule(window, cx)
1237 }))
1238 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1239 this.toggle_default_for_active_rule(window, cx)
1240 }))
1241 .size_full()
1242 .overflow_hidden()
1243 .font(ui_font)
1244 .text_color(theme.colors().text)
1245 .child(self.render_rule_list(cx))
1246 .map(|el| {
1247 if self.store.read(cx).prompt_count() == 0 {
1248 el.child(
1249 v_flex()
1250 .w_2_3()
1251 .h_full()
1252 .items_center()
1253 .justify_center()
1254 .gap_4()
1255 .bg(cx.theme().colors().editor_background)
1256 .child(
1257 h_flex()
1258 .gap_2()
1259 .child(
1260 Icon::new(IconName::Book)
1261 .size(IconSize::Medium)
1262 .color(Color::Muted),
1263 )
1264 .child(
1265 Label::new("No rules yet")
1266 .size(LabelSize::Large)
1267 .color(Color::Muted),
1268 ),
1269 )
1270 .child(
1271 h_flex()
1272 .child(h_flex())
1273 .child(
1274 v_flex()
1275 .gap_1()
1276 .child(Label::new("Create your first rule:"))
1277 .child(
1278 Button::new("create-rule", "New Rule")
1279 .full_width()
1280 .key_binding(KeyBinding::for_action(
1281 &NewRule, window, cx,
1282 ))
1283 .on_click(|_, window, cx| {
1284 window.dispatch_action(
1285 NewRule.boxed_clone(),
1286 cx,
1287 )
1288 }),
1289 ),
1290 )
1291 .child(h_flex()),
1292 ),
1293 )
1294 } else {
1295 el.child(self.render_active_rule(cx))
1296 }
1297 })
1298 }
1299}