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