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