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