rules library: Improve delineation of default and non-default rules (#39209)

Danilo Leal created

Closes https://github.com/zed-industries/zed/issues/39183

This PR adds UI improvements to clarify the concept of "default rules"
and how they separate from regular rules. This is mostly motivated by
the issue linked above, where it clarified that the star icon was
communicating a "favoriting" affordance, which is not correct with how
rules work in Zed. When you tag/attach a rule as default, it will always
be included in every prompt, together with the agent's system prompt and
project rules (if they exist).

Hopefully, this will make understanding better. Here's how it looks like
now?


https://github.com/user-attachments/assets/435d3af7-e8a6-4646-8f00-94a409bd5f42

Release Notes:

- Improve rules library UI to better communicate the concept of default
rules vs. regular rules.

Change summary

assets/icons/paperclip.svg                |   3 
crates/icons/src/icons.rs                 |   1 
crates/rules_library/src/rules_library.rs | 391 +++++++++++++++---------
3 files changed, 248 insertions(+), 147 deletions(-)

Detailed changes

assets/icons/paperclip.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.1645 4.45825L5.20344 9.52074C4.98225 9.74193 4.85798 10.0419 4.85798 10.3548C4.85798 10.6676 4.98225 10.9676 5.20344 11.1888C5.42464 11.41 5.72464 11.5342 6.03746 11.5342C6.35028 11.5342 6.65028 11.41 6.87148 11.1888L11.8326 6.12629C12.2749 5.68397 12.5234 5.08407 12.5234 4.45854C12.5234 3.83302 12.2749 3.23311 11.8326 2.7908C11.3902 2.34849 10.7903 2.1 10.1648 2.1C9.53928 2.1 8.93938 2.34849 8.49707 2.7908L3.55663 7.83265C3.22373 8.16017 2.95897 8.55037 2.77762 8.98072C2.59628 9.41108 2.50193 9.87308 2.50003 10.3401C2.49813 10.8071 2.58871 11.2698 2.76654 11.7017C2.94438 12.1335 3.20595 12.5258 3.53618 12.856C3.8664 13.1863 4.25873 13.4478 4.69055 13.6257C5.12237 13.8035 5.58513 13.8941 6.05213 13.8922C6.51913 13.8903 6.98114 13.7959 7.41149 13.6146C7.84185 13.4332 8.23204 13.1685 8.55957 12.8356L13.5 7.79373" stroke="#C4CAD4" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/rules_library/src/rules_library.rs πŸ”—

@@ -22,8 +22,7 @@ use std::time::Duration;
 use theme::ThemeSettings;
 use title_bar::platform_title_bar::PlatformTitleBar;
 use ui::{
-    Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
-    SharedString, Styled, Tooltip, Window, div, prelude::*,
+    Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
 };
 use util::{ResultExt, TryFutureExt};
 use workspace::{Workspace, client_side_decorations};
@@ -136,6 +135,7 @@ pub fn open_rules_library(
                     window_bounds: Some(WindowBounds::Windowed(bounds)),
                     window_background: cx.theme().window_background_appearance(),
                     window_decorations: Some(window_decorations),
+                    window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
                     ..Default::default()
                 },
                 |window, cx| {
@@ -179,10 +179,16 @@ struct RuleEditor {
     _subscriptions: Vec<Subscription>,
 }
 
+enum RulePickerEntry {
+    Header(SharedString),
+    Rule(PromptMetadata),
+    Separator,
+}
+
 struct RulePickerDelegate {
     store: Entity<PromptStore>,
     selected_index: usize,
-    matches: Vec<PromptMetadata>,
+    filtered_entries: Vec<RulePickerEntry>,
 }
 
 enum RulePickerEvent {
@@ -195,10 +201,10 @@ enum RulePickerEvent {
 impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
 
 impl PickerDelegate for RulePickerDelegate {
-    type ListItem = ListItem;
+    type ListItem = AnyElement;
 
     fn match_count(&self) -> usize {
-        self.matches.len()
+        self.filtered_entries.len()
     }
 
     fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
@@ -215,16 +221,24 @@ impl PickerDelegate for RulePickerDelegate {
     }
 
     fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
-        self.selected_index = ix;
-        if let Some(prompt) = self.matches.get(self.selected_index) {
-            cx.emit(RulePickerEvent::Selected {
-                prompt_id: prompt.id,
-            });
+        self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
+
+        if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
+            cx.emit(RulePickerEvent::Selected { prompt_id: rule.id });
+        }
+
+        cx.notify();
+    }
+
+    fn can_select(&mut self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) -> bool {
+        match self.filtered_entries.get(ix) {
+            Some(RulePickerEntry::Rule(_)) => true,
+            Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false,
         }
     }
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        "Search...".into()
+        "Search…".into()
     }
 
     fn update_matches(
@@ -235,24 +249,72 @@ impl PickerDelegate for RulePickerDelegate {
     ) -> Task<()> {
         let cancellation_flag = Arc::new(AtomicBool::default());
         let search = self.store.read(cx).search(query, cancellation_flag, cx);
-        let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
+
+        let prev_prompt_id = self
+            .filtered_entries
+            .get(self.selected_index)
+            .and_then(|entry| {
+                if let RulePickerEntry::Rule(rule) = entry {
+                    Some(rule.id)
+                } else {
+                    None
+                }
+            });
+
         cx.spawn_in(window, async move |this, cx| {
-            let (matches, selected_index) = cx
+            let (filtered_entries, selected_index) = cx
                 .background_spawn(async move {
                     let matches = search.await;
 
+                    let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
+                        matches.iter().partition(|rule| rule.default);
+
+                    let mut filtered_entries = Vec::new();
+
+                    if !default_rules.is_empty() {
+                        filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
+
+                        for rule in default_rules {
+                            filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+                        }
+
+                        filtered_entries.push(RulePickerEntry::Separator);
+                    }
+
+                    for rule in non_default_rules {
+                        filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+                    }
+
                     let selected_index = prev_prompt_id
                         .and_then(|prev_prompt_id| {
-                            matches.iter().position(|entry| entry.id == prev_prompt_id)
+                            filtered_entries.iter().position(|entry| {
+                                if let RulePickerEntry::Rule(rule) = entry {
+                                    rule.id == prev_prompt_id
+                                } else {
+                                    false
+                                }
+                            })
                         })
-                        .unwrap_or(0);
-                    (matches, selected_index)
+                        .unwrap_or_else(|| {
+                            filtered_entries
+                                .iter()
+                                .position(|entry| matches!(entry, RulePickerEntry::Rule(_)))
+                                .unwrap_or(0)
+                        });
+
+                    (filtered_entries, selected_index)
                 })
                 .await;
 
             this.update_in(cx, |this, window, cx| {
-                this.delegate.matches = matches;
-                this.delegate.set_selected_index(selected_index, window, cx);
+                this.delegate.filtered_entries = filtered_entries;
+                this.set_selected_index(
+                    selected_index,
+                    Some(picker::Direction::Down),
+                    true,
+                    window,
+                    cx,
+                );
                 cx.notify();
             })
             .ok();
@@ -260,10 +322,8 @@ impl PickerDelegate for RulePickerDelegate {
     }
 
     fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
-        if let Some(prompt) = self.matches.get(self.selected_index) {
-            cx.emit(RulePickerEvent::Confirmed {
-                prompt_id: prompt.id,
-            });
+        if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
+            cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id });
         }
     }
 
@@ -276,87 +336,115 @@ impl PickerDelegate for RulePickerDelegate {
         _: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let rule = self.matches.get(ix)?;
-        let default = rule.default;
-        let prompt_id = rule.id;
-
-        let element = ListItem::new(ix)
-            .inset(true)
-            .spacing(ListItemSpacing::Sparse)
-            .toggle_state(selected)
-            .child(
-                h_flex()
-                    .h_5()
-                    .line_height(relative(1.))
-                    .child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
-            )
-            .end_slot::<IconButton>(default.then(|| {
-                IconButton::new("toggle-default-rule", IconName::StarFilled)
-                    .toggle_state(true)
-                    .icon_color(Color::Accent)
-                    .icon_size(IconSize::Small)
-                    .shape(IconButtonShape::Square)
-                    .tooltip(Tooltip::text("Remove from Default Rules"))
-                    .on_click(cx.listener(move |_, _, _, cx| {
-                        cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
-                    }))
-            }))
-            .end_hover_slot(
-                h_flex()
-                    .gap_1()
-                    .child(if prompt_id.is_built_in() {
-                        div()
-                            .id("built-in-rule")
-                            .child(Icon::new(IconName::FileLock).color(Color::Muted))
-                            .tooltip(move |window, cx| {
-                                Tooltip::with_meta(
-                                    "Built-in rule",
-                                    None,
-                                    BUILT_IN_TOOLTIP_TEXT,
-                                    window,
-                                    cx,
-                                )
-                            })
-                            .into_any()
-                    } else {
-                        IconButton::new("delete-rule", IconName::Trash)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
-                            .shape(IconButtonShape::Square)
-                            .tooltip(Tooltip::text("Delete Rule"))
-                            .on_click(cx.listener(move |_, _, _, cx| {
-                                cx.emit(RulePickerEvent::Deleted { prompt_id })
-                            }))
-                            .into_any_element()
-                    })
-                    .child(
-                        IconButton::new("toggle-default-rule", IconName::Star)
-                            .toggle_state(default)
-                            .selected_icon(IconName::StarFilled)
-                            .icon_color(if default { Color::Accent } else { Color::Muted })
+        match self.filtered_entries.get(ix)? {
+            RulePickerEntry::Header(title) => Some(
+                ListSubHeader::new(title.clone())
+                    .end_slot(
+                        IconButton::new("info", IconName::Info)
+                            .style(ButtonStyle::Transparent)
                             .icon_size(IconSize::Small)
-                            .shape(IconButtonShape::Square)
-                            .map(|this| {
-                                if default {
-                                    this.tooltip(Tooltip::text("Remove from Default Rules"))
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text(
+                                "Default Rules are attached by default with every new thread.",
+                            ))
+                            .into_any_element(),
+                    )
+                    .inset(true)
+                    .into_any_element(),
+            ),
+            RulePickerEntry::Separator => Some(
+                h_flex()
+                    .py_1()
+                    .child(Divider::horizontal())
+                    .into_any_element(),
+            ),
+            RulePickerEntry::Rule(rule) => {
+                let default = rule.default;
+                let prompt_id = rule.id;
+
+                Some(
+                    ListItem::new(ix)
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
+                        .toggle_state(selected)
+                        .child(
+                            h_flex()
+                                .h_5()
+                                .line_height(relative(1.))
+                                .child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
+                        )
+                        .end_slot::<IconButton>(default.then(|| {
+                            IconButton::new("toggle-default-rule", IconName::Paperclip)
+                                .toggle_state(true)
+                                .icon_color(Color::Accent)
+                                .icon_size(IconSize::Small)
+                                .tooltip(Tooltip::text("Remove from Default Rules"))
+                                .on_click(cx.listener(move |_, _, _, cx| {
+                                    cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
+                                }))
+                        }))
+                        .end_hover_slot(
+                            h_flex()
+                                .child(if prompt_id.is_built_in() {
+                                    div()
+                                        .id("built-in-rule")
+                                        .child(Icon::new(IconName::FileLock).color(Color::Muted))
+                                        .tooltip(move |window, cx| {
+                                            Tooltip::with_meta(
+                                                "Built-in rule",
+                                                None,
+                                                BUILT_IN_TOOLTIP_TEXT,
+                                                window,
+                                                cx,
+                                            )
+                                        })
+                                        .into_any()
                                 } else {
-                                    this.tooltip(move |window, cx| {
-                                        Tooltip::with_meta(
-                                            "Add to Default Rules",
-                                            None,
-                                            "Always included in every thread.",
-                                            window,
-                                            cx,
-                                        )
-                                    })
-                                }
-                            })
-                            .on_click(cx.listener(move |_, _, _, cx| {
-                                cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
-                            })),
-                    ),
-            );
-        Some(element)
+                                    IconButton::new("delete-rule", IconName::Trash)
+                                        .icon_color(Color::Muted)
+                                        .icon_size(IconSize::Small)
+                                        .tooltip(Tooltip::text("Delete Rule"))
+                                        .on_click(cx.listener(move |_, _, _, cx| {
+                                            cx.emit(RulePickerEvent::Deleted { prompt_id })
+                                        }))
+                                        .into_any_element()
+                                })
+                                .child(
+                                    IconButton::new("toggle-default-rule", IconName::Plus)
+                                        .selected_icon(IconName::Dash)
+                                        .toggle_state(default)
+                                        .icon_size(IconSize::Small)
+                                        .icon_color(if default {
+                                            Color::Accent
+                                        } else {
+                                            Color::Muted
+                                        })
+                                        .map(|this| {
+                                            if default {
+                                                this.tooltip(Tooltip::text(
+                                                    "Remove from Default Rules",
+                                                ))
+                                            } else {
+                                                this.tooltip(move |window, cx| {
+                                                    Tooltip::with_meta(
+                                                        "Add to Default Rules",
+                                                        None,
+                                                        "Always included in every thread.",
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                            }
+                                        })
+                                        .on_click(cx.listener(move |_, _, _, cx| {
+                                            cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
+                                        })),
+                                ),
+                        )
+                        .into_any_element(),
+                )
+            }
+        }
     }
 
     fn render_editor(
@@ -387,7 +475,7 @@ impl RulesLibrary {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let (selected_index, matches) = if let Some(rule_to_select) = rule_to_select {
+        let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select {
             let matches = store.read(cx).all_prompt_metadata();
             let selected_index = matches
                 .iter()
@@ -399,19 +487,20 @@ impl RulesLibrary {
             (0, vec![])
         };
 
-        let delegate = RulePickerDelegate {
+        let picker_delegate = RulePickerDelegate {
             store: store.clone(),
-            selected_index,
-            matches,
+            selected_index: 0,
+            filtered_entries: Vec::new(),
         };
 
         let picker = cx.new(|cx| {
-            let picker = Picker::uniform_list(delegate, window, cx)
+            let picker = Picker::list(picker_delegate, window, cx)
                 .modal(false)
                 .max_height(None);
             picker.focus(window, cx);
             picker
         });
+
         Self {
             title_bar: if !cfg!(target_os = "macos") {
                 Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
@@ -701,14 +790,22 @@ impl RulesLibrary {
             if let Some(prompt_id) = prompt_id {
                 if picker
                     .delegate
-                    .matches
+                    .filtered_entries
                     .get(picker.delegate.selected_index())
-                    .is_none_or(|old_selected_prompt| old_selected_prompt.id != prompt_id)
-                    && let Some(ix) = picker
-                        .delegate
-                        .matches
-                        .iter()
-                        .position(|mat| mat.id == prompt_id)
+                    .is_none_or(|old_selected_prompt| {
+                        if let RulePickerEntry::Rule(rule) = old_selected_prompt {
+                            rule.id != prompt_id
+                        } else {
+                            true
+                        }
+                    })
+                    && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| {
+                        if let RulePickerEntry::Rule(rule) = mat {
+                            rule.id == prompt_id
+                        } else {
+                            false
+                        }
+                    })
                 {
                     picker.set_selected_index(ix, None, true, window, cx);
                 }
@@ -1014,8 +1111,6 @@ impl RulesLibrary {
                     .justify_end()
                     .child(
                         IconButton::new("new-rule", IconName::Plus)
-                            .style(ButtonStyle::Transparent)
-                            .shape(IconButtonShape::Square)
                             .tooltip(move |window, cx| {
                                 Tooltip::for_action("New Rule", &NewRule, window, cx)
                             })
@@ -1059,13 +1154,15 @@ impl RulesLibrary {
                             h_flex()
                                 .group("active-editor-header")
                                 .pt_2()
-                                .px_2p5()
+                                .pl_1p5()
+                                .pr_2p5()
                                 .gap_2()
                                 .justify_between()
                                 .child(
                                     div()
                                         .w_full()
                                         .on_action(cx.listener(Self::move_down_from_title))
+                                        .pl_1()
                                         .border_1()
                                         .border_color(transparent_black())
                                         .rounded_sm()
@@ -1107,7 +1204,6 @@ impl RulesLibrary {
                                     h_flex()
                                         .h_full()
                                         .flex_shrink_0()
-                                        .gap(DynamicSpacing::Base04.rems(cx))
                                         .children(rule_editor.token_count.map(|token_count| {
                                             let token_count: SharedString =
                                                 token_count.to_string().into();
@@ -1160,7 +1256,6 @@ impl RulesLibrary {
                                                 .into_any()
                                         } else {
                                             IconButton::new("delete-rule", IconName::Trash)
-                                                .icon_size(IconSize::Small)
                                                 .tooltip(move |window, cx| {
                                                     Tooltip::for_action(
                                                         "Delete Rule",
@@ -1177,7 +1272,6 @@ impl RulesLibrary {
                                         })
                                         .child(
                                             IconButton::new("duplicate-rule", IconName::BookCopy)
-                                                .icon_size(IconSize::Small)
                                                 .tooltip(move |window, cx| {
                                                     Tooltip::for_action(
                                                         "Duplicate Rule",
@@ -1194,38 +1288,41 @@ impl RulesLibrary {
                                                 }),
                                         )
                                         .child(
-                                            IconButton::new("toggle-default-rule", IconName::Star)
-                                                .icon_size(IconSize::Small)
-                                                .toggle_state(rule_metadata.default)
-                                                .selected_icon(IconName::StarFilled)
-                                                .icon_color(if rule_metadata.default {
-                                                    Color::Accent
+                                            IconButton::new(
+                                                "toggle-default-rule",
+                                                IconName::Paperclip,
+                                            )
+                                            .toggle_state(rule_metadata.default)
+                                            .icon_color(if rule_metadata.default {
+                                                Color::Accent
+                                            } else {
+                                                Color::Muted
+                                            })
+                                            .map(|this| {
+                                                if rule_metadata.default {
+                                                    this.tooltip(Tooltip::text(
+                                                        "Remove from Default Rules",
+                                                    ))
                                                 } else {
-                                                    Color::Muted
-                                                })
-                                                .map(|this| {
-                                                    if rule_metadata.default {
-                                                        this.tooltip(Tooltip::text(
-                                                            "Remove from Default Rules",
-                                                        ))
-                                                    } else {
-                                                        this.tooltip(move |window, cx| {
-                                                            Tooltip::with_meta(
-                                                                "Add to Default Rules",
-                                                                None,
-                                                                "Always included in every thread.",
-                                                                window,
-                                                                cx,
-                                                            )
-                                                        })
-                                                    }
-                                                })
-                                                .on_click(|_, window, cx| {
+                                                    this.tooltip(move |window, cx| {
+                                                        Tooltip::with_meta(
+                                                            "Add to Default Rules",
+                                                            None,
+                                                            "Always included in every thread.",
+                                                            window,
+                                                            cx,
+                                                        )
+                                                    })
+                                                }
+                                            })
+                                            .on_click(
+                                                |_, window, cx| {
                                                     window.dispatch_action(
                                                         Box::new(ToggleDefaultRule),
                                                         cx,
                                                     );
-                                                }),
+                                                },
+                                            ),
                                         ),
                                 ),
                         )
@@ -1234,8 +1331,8 @@ impl RulesLibrary {
                                 .on_action(cx.listener(Self::focus_picker))
                                 .on_action(cx.listener(Self::inline_assist))
                                 .on_action(cx.listener(Self::move_up_from_body))
-                                .flex_grow()
                                 .h_full()
+                                .flex_grow()
                                 .child(
                                     h_flex()
                                         .py_2()