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