zed/crates/rules_library/src/rules_library.rs
Danilo Leal 5aa82887ec
rules library: Improve empty state & fix quirks on Windows (#41064)
Just got a new Windows machine and realized that the rules library empty
state was completly busted. Ended up also adding some little UI tweaks
to make it better for both Windows and Linux.

Release Notes:

- N/A
2025-10-23 21:45:48 -03:00

1427 lines
58 KiB
Rust

use anyhow::Result;
use collections::{HashMap, HashSet};
use editor::{CompletionProvider, SelectionEffects};
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
use gpui::{
Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
WindowOptions, actions, point, size, transparent_black,
};
use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
use language_model::{
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
};
use picker::{Picker, PickerDelegate};
use release_channel::ReleaseChannel;
use rope::Rope;
use settings::Settings;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{
Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
};
use util::{ResultExt, TryFutureExt};
use workspace::{Workspace, client_side_decorations};
use zed_actions::assistant::InlineAssist;
use prompt_store::*;
pub fn init(cx: &mut App) {
prompt_store::init(cx);
}
actions!(
rules_library,
[
/// Creates a new rule in the rules library.
NewRule,
/// Deletes the selected rule.
DeleteRule,
/// Duplicates the selected rule.
DuplicateRule,
/// Toggles whether the selected rule is a default rule.
ToggleDefaultRule
]
);
const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
"This rule supports special functionality.\n",
"It's read-only, but you can remove it from your default rules."
);
pub trait InlineAssistDelegate {
fn assist(
&self,
prompt_editor: &Entity<Editor>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut Context<RulesLibrary>,
);
/// Returns whether the Agent panel was focused.
fn focus_agent_panel(
&self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool;
}
/// This function opens a new rules library window if one doesn't exist already.
/// If one exists, it brings it to the foreground.
///
/// Note that, when opening a new window, this waits for the PromptStore to be
/// initialized. If it was initialized successfully, it returns a window handle
/// to a rules library.
pub fn open_rules_library(
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
prompt_to_select: Option<PromptId>,
cx: &mut App,
) -> Task<Result<WindowHandle<RulesLibrary>>> {
let store = PromptStore::global(cx);
cx.spawn(async move |cx| {
// We query windows in spawn so that all windows have been returned to GPUI
let existing_window = cx
.update(|cx| {
let existing_window = cx
.windows()
.into_iter()
.find_map(|window| window.downcast::<RulesLibrary>());
if let Some(existing_window) = existing_window {
existing_window
.update(cx, |rules_library, window, cx| {
if let Some(prompt_to_select) = prompt_to_select {
rules_library.load_rule(prompt_to_select, true, window, cx);
}
window.activate_window()
})
.ok();
Some(existing_window)
} else {
None
}
})
.ok()
.flatten();
if let Some(existing_window) = existing_window {
return Ok(existing_window);
}
let store = store.await?;
cx.update(|cx| {
let app_id = ReleaseChannel::global(cx).app_id();
let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
Ok(val) if val == "server" => gpui::WindowDecorations::Server,
Ok(val) if val == "client" => gpui::WindowDecorations::Client,
_ => gpui::WindowDecorations::Client,
};
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some("Rules Library".into()),
appears_transparent: true,
traffic_light_position: Some(point(px(12.0), px(12.0))),
}),
app_id: Some(app_id.to_owned()),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_background: cx.theme().window_background_appearance(),
window_decorations: Some(window_decorations),
window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE),
kind: gpui::WindowKind::Floating,
..Default::default()
},
|window, cx| {
cx.new(|cx| {
RulesLibrary::new(
store,
language_registry,
inline_assist_delegate,
make_completion_provider,
prompt_to_select,
window,
cx,
)
})
},
)
})?
})
}
pub struct RulesLibrary {
title_bar: Option<Entity<PlatformTitleBar>>,
store: Entity<PromptStore>,
language_registry: Arc<LanguageRegistry>,
rule_editors: HashMap<PromptId, RuleEditor>,
active_rule_id: Option<PromptId>,
picker: Entity<Picker<RulePickerDelegate>>,
pending_load: Task<()>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
_subscriptions: Vec<Subscription>,
}
struct RuleEditor {
title_editor: Entity<Editor>,
body_editor: Entity<Editor>,
token_count: Option<u64>,
pending_token_count: Task<Option<()>>,
next_title_and_body_to_save: Option<(String, Rope)>,
pending_save: Option<Task<Option<()>>>,
_subscriptions: Vec<Subscription>,
}
enum RulePickerEntry {
Header(SharedString),
Rule(PromptMetadata),
Separator,
}
struct RulePickerDelegate {
store: Entity<PromptStore>,
selected_index: usize,
filtered_entries: Vec<RulePickerEntry>,
}
enum RulePickerEvent {
Selected { prompt_id: PromptId },
Confirmed { prompt_id: PromptId },
Deleted { prompt_id: PromptId },
ToggledDefault { prompt_id: PromptId },
}
impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
impl PickerDelegate for RulePickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
let text = if self.store.read(cx).prompt_count() == 0 {
"No rules.".into()
} else {
"No rules found matching your search.".into()
};
Some(text)
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
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()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let cancellation_flag = Arc::new(AtomicBool::default());
let search = self.store.read(cx).search(query, cancellation_flag, cx);
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 (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| {
filtered_entries.iter().position(|entry| {
if let RulePickerEntry::Rule(rule) = entry {
rule.id == prev_prompt_id
} else {
false
}
})
})
.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.filtered_entries = filtered_entries;
this.set_selected_index(
selected_index,
Some(picker::Direction::Down),
true,
window,
cx,
);
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id });
}
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
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)
.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(
Label::new(rule.title.clone().unwrap_or("Untitled".into()))
.truncate()
.mr_10(),
)
.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,
cx,
)
})
.into_any()
} else {
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.",
cx,
)
})
}
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
})),
),
)
.into_any_element(),
)
}
}
}
fn render_editor(
&self,
editor: &Entity<Editor>,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
h_flex()
.py_1()
.px_1p5()
.mx_1()
.gap_1p5()
.rounded_sm()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border)
.child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
.child(editor.clone())
}
}
impl RulesLibrary {
fn new(
store: Entity<PromptStore>,
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
rule_to_select: Option<PromptId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
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()
.enumerate()
.find(|(_, metadata)| metadata.id == rule_to_select)
.map_or(0, |(ix, _)| ix);
(selected_index, matches)
} else {
(0, vec![])
};
let picker_delegate = RulePickerDelegate {
store: store.clone(),
selected_index: 0,
filtered_entries: Vec::new(),
};
let picker = cx.new(|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)))
} else {
None
},
store,
language_registry,
rule_editors: HashMap::default(),
active_rule_id: None,
pending_load: Task::ready(()),
inline_assist_delegate,
make_completion_provider,
_subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
picker,
}
}
fn handle_picker_event(
&mut self,
_: &Entity<Picker<RulePickerDelegate>>,
event: &RulePickerEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
RulePickerEvent::Selected { prompt_id } => {
self.load_rule(*prompt_id, false, window, cx);
}
RulePickerEvent::Confirmed { prompt_id } => {
self.load_rule(*prompt_id, true, window, cx);
}
RulePickerEvent::ToggledDefault { prompt_id } => {
self.toggle_default_for_rule(*prompt_id, window, cx);
}
RulePickerEvent::Deleted { prompt_id } => {
self.delete_rule(*prompt_id, window, cx);
}
}
}
pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// If we already have an untitled rule, use that instead
// of creating a new one.
if let Some(metadata) = self.store.read(cx).first()
&& metadata.title.is_none()
{
self.load_rule(metadata.id, true, window, cx);
return;
}
let prompt_id = PromptId::new();
let save = self.store.update(cx, |store, cx| {
store.save(prompt_id, None, false, "".into(), cx)
});
self.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.spawn_in(window, async move |this, cx| {
save.await?;
this.update_in(cx, |this, window, cx| {
this.load_rule(prompt_id, true, window, cx)
})
})
.detach_and_log_err(cx);
}
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
if prompt_id.is_built_in() {
return;
}
let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap();
let title = rule_editor.title_editor.read(cx).text(cx);
let body = rule_editor.body_editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.as_rope()
.clone()
});
let store = self.store.clone();
let executor = cx.background_executor().clone();
rule_editor.next_title_and_body_to_save = Some((title, body));
if rule_editor.pending_save.is_none() {
rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
async move {
loop {
let title_and_body = this.update(cx, |this, _| {
this.rule_editors
.get_mut(&prompt_id)?
.next_title_and_body_to_save
.take()
})?;
if let Some((title, body)) = title_and_body {
let title = if title.trim().is_empty() {
None
} else {
Some(SharedString::from(title))
};
cx.update(|_window, cx| {
store.update(cx, |store, cx| {
store.save(prompt_id, title, rule_metadata.default, body, cx)
})
})?
.await
.log_err();
this.update_in(cx, |this, window, cx| {
this.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.notify();
})?;
executor.timer(SAVE_THROTTLE).await;
} else {
break;
}
}
this.update(cx, |this, _cx| {
if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) {
rule_editor.pending_save = None;
}
})
}
.log_err()
.await
}));
}
}
pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_rule_id) = self.active_rule_id {
self.delete_rule(active_rule_id, window, cx);
}
}
pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_rule_id) = self.active_rule_id {
self.duplicate_rule(active_rule_id, window, cx);
}
}
pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_rule_id) = self.active_rule_id {
self.toggle_default_for_rule(active_rule_id, window, cx);
}
}
pub fn toggle_default_for_rule(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.store.update(cx, move |store, cx| {
if let Some(rule_metadata) = store.metadata(prompt_id) {
store
.save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx)
.detach_and_log_err(cx);
}
});
self.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.notify();
}
pub fn load_rule(
&mut self,
prompt_id: PromptId,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
if focus {
rule_editor
.body_editor
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
}
self.set_active_rule(Some(prompt_id), window, cx);
} else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
let language_registry = self.language_registry.clone();
let rule = self.store.read(cx).load(prompt_id, cx);
let make_completion_provider = self.make_completion_provider.clone();
self.pending_load = cx.spawn_in(window, async move |this, cx| {
let rule = rule.await;
let markdown = language_registry.language_for_name("Markdown").await;
this.update_in(cx, |this, window, cx| match rule {
Ok(rule) => {
let title_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Untitled", window, cx);
editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx);
if prompt_id.is_built_in() {
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
}
editor
});
let body_editor = cx.new(|cx| {
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local(rule, cx);
buffer.set_language(markdown.log_err(), cx);
buffer.set_language_registry(language_registry);
buffer
});
let mut editor = Editor::for_buffer(buffer, None, window, cx);
if prompt_id.is_built_in() {
editor.set_read_only(true);
editor.set_show_edit_predictions(Some(false), window, cx);
}
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor.set_show_gutter(false, cx);
editor.set_show_wrap_guides(false, cx);
editor.set_show_indent_guides(false, cx);
editor.set_use_modal_editing(true);
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
editor.set_completion_provider(Some(make_completion_provider()));
if focus {
window.focus(&editor.focus_handle(cx));
}
editor
});
let _subscriptions = vec![
cx.subscribe_in(
&title_editor,
window,
move |this, editor, event, window, cx| {
this.handle_rule_title_editor_event(
prompt_id, editor, event, window, cx,
)
},
),
cx.subscribe_in(
&body_editor,
window,
move |this, editor, event, window, cx| {
this.handle_rule_body_editor_event(
prompt_id, editor, event, window, cx,
)
},
),
];
this.rule_editors.insert(
prompt_id,
RuleEditor {
title_editor,
body_editor,
next_title_and_body_to_save: None,
pending_save: None,
token_count: None,
pending_token_count: Task::ready(None),
_subscriptions,
},
);
this.set_active_rule(Some(prompt_id), window, cx);
this.count_tokens(prompt_id, window, cx);
}
Err(error) => {
// TODO: we should show the error in the UI.
log::error!("error while loading rule: {:?}", error);
}
})
.ok();
});
}
}
fn set_active_rule(
&mut self,
prompt_id: Option<PromptId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_rule_id = prompt_id;
self.picker.update(cx, |picker, cx| {
if let Some(prompt_id) = prompt_id {
if picker
.delegate
.filtered_entries
.get(picker.delegate.selected_index())
.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);
}
} else {
picker.focus(window, cx);
}
});
cx.notify();
}
pub fn delete_rule(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
let confirmation = window.prompt(
PromptLevel::Warning,
&format!(
"Are you sure you want to delete {}",
metadata.title.unwrap_or("Untitled".into())
),
None,
&["Delete", "Cancel"],
cx,
);
cx.spawn_in(window, async move |this, cx| {
if confirmation.await.ok() == Some(0) {
this.update_in(cx, |this, window, cx| {
if this.active_rule_id == Some(prompt_id) {
this.set_active_rule(None, window, cx);
}
this.rule_editors.remove(&prompt_id);
this.store
.update(cx, |store, cx| store.delete(prompt_id, cx))
.detach_and_log_err(cx);
this.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.notify();
})?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
pub fn duplicate_rule(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(rule) = self.rule_editors.get(&prompt_id) {
const DUPLICATE_SUFFIX: &str = " copy";
let title_to_duplicate = rule.title_editor.read(cx).text(cx);
let existing_titles = self
.rule_editors
.iter()
.filter(|&(&id, _)| id != prompt_id)
.map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx))
.filter(|title| title.starts_with(&title_to_duplicate))
.collect::<HashSet<_>>();
let title = if existing_titles.is_empty() {
title_to_duplicate + DUPLICATE_SUFFIX
} else {
let mut i = 1;
loop {
let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
if !existing_titles.contains(&new_title) {
break new_title;
}
i += 1;
}
};
let new_id = PromptId::new();
let body = rule.body_editor.read(cx).text(cx);
let save = self.store.update(cx, |store, cx| {
store.save(new_id, Some(title.into()), false, body.into(), cx)
});
self.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.spawn_in(window, async move |this, cx| {
save.await?;
this.update_in(cx, |rules_library, window, cx| {
rules_library.load_rule(new_id, true, window, cx)
})
})
.detach_and_log_err(cx);
}
}
fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_rule) = self.active_rule_id {
self.rule_editors[&active_rule]
.body_editor
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
cx.stop_propagation();
}
}
fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.picker
.update(cx, |picker, cx| picker.focus(window, cx));
}
pub fn inline_assist(
&mut self,
action: &InlineAssist,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_rule_id) = self.active_rule_id else {
cx.propagate();
return;
};
let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
let Some(ConfiguredModel { provider, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
else {
return;
};
let initial_prompt = action.prompt.clone();
if provider.is_authenticated(cx) {
self.inline_assist_delegate
.assist(rule_editor, initial_prompt, window, cx);
} else {
for window in cx.windows() {
if let Some(workspace) = window.downcast::<Workspace>() {
let panel = workspace
.update(cx, |workspace, window, cx| {
window.activate_window();
self.inline_assist_delegate
.focus_agent_panel(workspace, window, cx)
})
.ok();
if panel == Some(true) {
return;
}
}
}
}
}
fn move_down_from_title(
&mut self,
_: &editor::actions::MoveDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(rule_id) = self.active_rule_id
&& let Some(rule_editor) = self.rule_editors.get(&rule_id)
{
window.focus(&rule_editor.body_editor.focus_handle(cx));
}
}
fn move_up_from_body(
&mut self,
_: &editor::actions::MoveUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(rule_id) = self.active_rule_id
&& let Some(rule_editor) = self.rule_editors.get(&rule_id)
{
window.focus(&rule_editor.title_editor.focus_handle(cx));
}
}
fn handle_rule_title_editor_event(
&mut self,
prompt_id: PromptId,
title_editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::BufferEdited => {
self.save_rule(prompt_id, window, cx);
self.count_tokens(prompt_id, window, cx);
}
EditorEvent::Blurred => {
title_editor.update(cx, |title_editor, cx| {
title_editor.change_selections(
SelectionEffects::no_scroll(),
window,
cx,
|selections| {
let cursor = selections.oldest_anchor().head();
selections.select_anchor_ranges([cursor..cursor]);
},
);
});
}
_ => {}
}
}
fn handle_rule_body_editor_event(
&mut self,
prompt_id: PromptId,
body_editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::BufferEdited => {
self.save_rule(prompt_id, window, cx);
self.count_tokens(prompt_id, window, cx);
}
EditorEvent::Blurred => {
body_editor.update(cx, |body_editor, cx| {
body_editor.change_selections(
SelectionEffects::no_scroll(),
window,
cx,
|selections| {
let cursor = selections.oldest_anchor().head();
selections.select_anchor_ranges([cursor..cursor]);
},
);
});
}
_ => {}
}
}
fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).default_model()
else {
return;
};
if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
let editor = &rule.body_editor.read(cx);
let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
let body = buffer.as_rope().clone();
rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
async move {
const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
let token_count = cx
.update(|_, cx| {
model.count_tokens(
LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: None,
mode: None,
messages: vec![LanguageModelRequestMessage {
role: Role::System,
content: vec![body.to_string().into()],
cache: false,
}],
tools: Vec::new(),
tool_choice: None,
stop: Vec::new(),
temperature: None,
thinking_allowed: true,
},
cx,
)
})?
.await?;
this.update(cx, |this, cx| {
let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
rule_editor.token_count = Some(token_count);
cx.notify();
})
}
.log_err()
.await
});
}
}
fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("rule-list")
.capture_action(cx.listener(Self::focus_active_rule))
.px_1p5()
.h_full()
.w_64()
.overflow_x_hidden()
.bg(cx.theme().colors().panel_background)
.map(|this| {
if cfg!(target_os = "macos") {
this.child(
h_flex()
.p(DynamicSpacing::Base04.rems(cx))
.h_9()
.w_full()
.flex_none()
.justify_end()
.child(
IconButton::new("new-rule", IconName::Plus)
.tooltip(move |_window, cx| {
Tooltip::for_action("New Rule", &NewRule, cx)
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(NewRule), cx);
}),
),
)
} else {
this.child(
h_flex().p_1().w_full().child(
Button::new("new-rule", "New Rule")
.full_width()
.style(ButtonStyle::Outlined)
.icon(IconName::Plus)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.icon_color(Color::Muted)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(NewRule), cx);
}),
),
)
}
})
.child(div().flex_grow().child(self.picker.clone()))
}
fn render_active_rule_editor(
&self,
editor: &Entity<Editor>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
div()
.w_full()
.on_action(cx.listener(Self::move_down_from_title))
.pl_1()
.border_1()
.border_color(transparent_black())
.rounded_sm()
.group_hover("active-editor-header", |this| {
this.border_color(cx.theme().colors().border_variant)
})
.child(EditorElement::new(
&editor,
EditorStyle {
background: cx.theme().system().transparent,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().editor_foreground,
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features.clone(),
font_size: HeadlineSize::Large.rems().into(),
font_weight: settings.ui_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..Default::default()
},
scrollbar_width: Pixels::ZERO,
syntax: cx.theme().syntax().clone(),
status: cx.theme().status().clone(),
inlay_hints_style: editor::make_inlay_hints_style(cx),
edit_prediction_styles: editor::make_suggestion_styles(cx),
..EditorStyle::default()
},
))
}
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
div()
.id("rule-editor")
.h_full()
.flex_grow()
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.children(self.active_rule_id.and_then(|prompt_id| {
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
let rule_editor = &self.rule_editors[&prompt_id];
let focus_handle = rule_editor.body_editor.focus_handle(cx);
let model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
Some(
v_flex()
.id("rule-editor-inner")
.size_full()
.relative()
.overflow_hidden()
.on_click(cx.listener(move |_, _, window, _| {
window.focus(&focus_handle);
}))
.child(
h_flex()
.group("active-editor-header")
.pt_2()
.pl_1p5()
.pr_2p5()
.gap_2()
.justify_between()
.child(
self.render_active_rule_editor(&rule_editor.title_editor, cx),
)
.child(
h_flex()
.h_full()
.flex_shrink_0()
.children(rule_editor.token_count.map(|token_count| {
let token_count: SharedString =
token_count.to_string().into();
let label_token_count: SharedString =
token_count.to_string().into();
div()
.id("token_count")
.mr_1()
.flex_shrink_0()
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Token Estimation",
None,
format!(
"Model: {}",
model
.as_ref()
.map(|model| model.name().0)
.unwrap_or_default()
),
cx,
)
})
.child(
Label::new(format!(
"{} tokens",
label_token_count
))
.color(Color::Muted),
)
}))
.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,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-rule", IconName::Trash)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Delete Rule",
&DeleteRule,
cx,
)
})
.on_click(|_, window, cx| {
window
.dispatch_action(Box::new(DeleteRule), cx);
})
.into_any_element()
})
.child(
IconButton::new("duplicate-rule", IconName::BookCopy)
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Duplicate Rule",
&DuplicateRule,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicateRule),
cx,
);
}),
)
.child(
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 {
this.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Add to Default Rules",
None,
"Always included in every thread.",
cx,
)
})
}
})
.on_click(
|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultRule),
cx,
);
},
),
),
),
)
.child(
div()
.on_action(cx.listener(Self::focus_picker))
.on_action(cx.listener(Self::inline_assist))
.on_action(cx.listener(Self::move_up_from_body))
.h_full()
.flex_grow()
.child(
h_flex()
.py_2()
.pl_2p5()
.h_full()
.flex_1()
.child(rule_editor.body_editor.clone()),
),
),
)
}))
}
}
impl Render for RulesLibrary {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ui_font = theme::setup_ui_font(window, cx);
let theme = cx.theme().clone();
client_side_decorations(
v_flex()
.id("rules-library")
.key_context("RulesLibrary")
.on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
.on_action(
cx.listener(|this, &DeleteRule, window, cx| {
this.delete_active_rule(window, cx)
}),
)
.on_action(cx.listener(|this, &DuplicateRule, window, cx| {
this.duplicate_active_rule(window, cx)
}))
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
this.toggle_default_for_active_rule(window, cx)
}))
.size_full()
.overflow_hidden()
.font(ui_font)
.text_color(theme.colors().text)
.children(self.title_bar.clone())
.bg(theme.colors().background)
.child(
h_flex()
.flex_1()
.when(!cfg!(target_os = "macos"), |this| {
this.border_t_1().border_color(cx.theme().colors().border)
})
.child(self.render_rule_list(cx))
.map(|el| {
if self.store.read(cx).prompt_count() == 0 {
el.child(
v_flex()
.h_full()
.flex_1()
.items_center()
.justify_center()
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.child(
Button::new("create-rule", "New Rule")
.style(ButtonStyle::Outlined)
.key_binding(KeyBinding::for_action(&NewRule, cx))
.on_click(|_, window, cx| {
window
.dispatch_action(NewRule.boxed_clone(), cx)
}),
),
)
} else {
el.child(self.render_active_rule(cx))
}
}),
),
window,
cx,
)
}
}