ZIm/crates/prompt_library/src/prompt_library.rs
Agus Zubiaga 43cb925a59
ai: Separate model settings for each feature (#28088)
Closes: https://github.com/zed-industries/zed/issues/20582

Allows users to select a specific model for each AI-powered feature:
- Agent panel
- Inline assistant
- Thread summarization
- Commit message generation

If unspecified for a given feature, it will use the `default_model`
setting.

Release Notes:

- Added support for configuring a specific model for each AI-powered
feature

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-04 11:40:55 -03:00

1293 lines
55 KiB
Rust

use anyhow::Result;
use collections::{HashMap, HashSet};
use editor::CompletionProvider;
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
use gpui::{
Action, App, Bounds, 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::sync::Arc;
use std::time::Duration;
use theme::ThemeSettings;
use ui::{
Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
SharedString, Styled, Tooltip, Window, div, prelude::*,
};
use util::{ResultExt, TryFutureExt};
use workspace::Workspace;
use zed_actions::assistant::InlineAssist;
use prompt_store::*;
pub fn init(cx: &mut App) {
prompt_store::init(cx);
}
actions!(
prompt_library,
[
NewPrompt,
DeletePrompt,
DuplicatePrompt,
ToggleDefaultPrompt
]
);
const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
"This prompt supports special functionality.\n",
"It's read-only, but you can remove it from your default prompt."
);
pub trait InlineAssistDelegate {
fn assist(
&self,
prompt_editor: &Entity<Editor>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut Context<PromptLibrary>,
);
/// Returns whether the Assistant panel was focused.
fn focus_assistant_panel(
&self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool;
}
/// This function opens a new prompt 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 prompt library.
pub fn open_prompt_library(
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
cx: &mut App,
) -> Task<Result<WindowHandle<PromptLibrary>>> {
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::<PromptLibrary>());
if let Some(existing_window) = existing_window {
existing_window
.update(cx, |_, window, _| 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);
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some("Prompt Library".into()),
appears_transparent: cfg!(target_os = "macos"),
traffic_light_position: Some(point(px(9.0), px(9.0))),
}),
app_id: Some(app_id.to_owned()),
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
cx.new(|cx| {
PromptLibrary::new(
store,
language_registry,
inline_assist_delegate,
make_completion_provider,
window,
cx,
)
})
},
)
})?
})
}
pub struct PromptLibrary {
store: Arc<PromptStore>,
language_registry: Arc<LanguageRegistry>,
prompt_editors: HashMap<PromptId, PromptEditor>,
active_prompt_id: Option<PromptId>,
picker: Entity<Picker<PromptPickerDelegate>>,
pending_load: Task<()>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
_subscriptions: Vec<Subscription>,
}
struct PromptEditor {
title_editor: Entity<Editor>,
body_editor: Entity<Editor>,
token_count: Option<usize>,
pending_token_count: Task<Option<()>>,
next_title_and_body_to_save: Option<(String, Rope)>,
pending_save: Option<Task<Option<()>>>,
_subscriptions: Vec<Subscription>,
}
struct PromptPickerDelegate {
store: Arc<PromptStore>,
selected_index: usize,
matches: Vec<PromptMetadata>,
}
enum PromptPickerEvent {
Selected { prompt_id: PromptId },
Confirmed { prompt_id: PromptId },
Deleted { prompt_id: PromptId },
ToggledDefault { prompt_id: PromptId },
}
impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
impl PickerDelegate for PromptPickerDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.matches.len()
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
let text = if self.store.prompt_count() == 0 {
"No prompts.".into()
} else {
"No prompts 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;
if let Some(prompt) = self.matches.get(self.selected_index) {
cx.emit(PromptPickerEvent::Selected {
prompt_id: prompt.id,
});
}
}
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 search = self.store.search(query);
let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
cx.spawn_in(window, async move |this, cx| {
let (matches, selected_index) = cx
.background_spawn(async move {
let matches = search.await;
let selected_index = prev_prompt_id
.and_then(|prev_prompt_id| {
matches.iter().position(|entry| entry.id == prev_prompt_id)
})
.unwrap_or(0);
(matches, selected_index)
})
.await;
this.update_in(cx, |this, window, cx| {
this.delegate.matches = matches;
this.delegate.set_selected_index(selected_index, window, cx);
cx.notify();
})
.ok();
})
}
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(PromptPickerEvent::Confirmed {
prompt_id: prompt.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> {
let prompt = self.matches.get(ix)?;
let default = prompt.default;
let prompt_id = prompt.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(
prompt.title.clone().unwrap_or("Untitled".into()),
)))
.end_slot::<IconButton>(default.then(|| {
IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
.toggle_state(true)
.icon_color(Color::Accent)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Remove from Default Prompt"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
}))
}))
.end_hover_slot(
h_flex()
.gap_2()
.child(if prompt_id.is_built_in() {
div()
.id("built-in-prompt")
.child(Icon::new(IconName::FileLock).color(Color::Muted))
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Built-in prompt",
None,
BUILT_IN_TOOLTIP_TEXT,
window,
cx,
)
})
.into_any()
} else {
IconButton::new("delete-prompt", IconName::Trash)
.icon_color(Color::Muted)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Delete Prompt"))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptPickerEvent::Deleted { prompt_id })
}))
.into_any_element()
})
.child(
IconButton::new("toggle-default-prompt", IconName::Sparkle)
.toggle_state(default)
.selected_icon(IconName::SparkleFilled)
.icon_color(if default { Color::Accent } else { Color::Muted })
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text(if default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
}))
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
})),
),
);
Some(element)
}
fn render_editor(
&self,
editor: &Entity<Editor>,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
h_flex()
.bg(cx.theme().colors().editor_background)
.rounded_sm()
.overflow_hidden()
.flex_none()
.py_1()
.px_2()
.mx_1()
.child(editor.clone())
}
}
impl PromptLibrary {
fn new(
store: Arc<PromptStore>,
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let delegate = PromptPickerDelegate {
store: store.clone(),
selected_index: 0,
matches: Vec::new(),
};
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx)
.modal(false)
.max_height(None);
picker.focus(window, cx);
picker
});
Self {
store: store.clone(),
language_registry,
prompt_editors: HashMap::default(),
active_prompt_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<PromptPickerDelegate>>,
event: &PromptPickerEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
PromptPickerEvent::Selected { prompt_id } => {
self.load_prompt(*prompt_id, false, window, cx);
}
PromptPickerEvent::Confirmed { prompt_id } => {
self.load_prompt(*prompt_id, true, window, cx);
}
PromptPickerEvent::ToggledDefault { prompt_id } => {
self.toggle_default_for_prompt(*prompt_id, window, cx);
}
PromptPickerEvent::Deleted { prompt_id } => {
self.delete_prompt(*prompt_id, window, cx);
}
}
}
pub fn new_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// If we already have an untitled prompt, use that instead
// of creating a new one.
if let Some(metadata) = self.store.first() {
if metadata.title.is_none() {
self.load_prompt(metadata.id, true, window, cx);
return;
}
}
let prompt_id = PromptId::new();
let save = self.store.save(prompt_id, None, false, "".into());
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_prompt(prompt_id, true, window, cx)
})
})
.detach_and_log_err(cx);
}
pub fn save_prompt(
&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 prompt_metadata = self.store.metadata(prompt_id).unwrap();
let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
let title = prompt_editor.title_editor.read(cx).text(cx);
let body = prompt_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();
prompt_editor.next_title_and_body_to_save = Some((title, body));
if prompt_editor.pending_save.is_none() {
prompt_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
async move {
loop {
let title_and_body = this.update(cx, |this, _| {
this.prompt_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))
};
store
.save(prompt_id, title, prompt_metadata.default, body)
.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(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
prompt_editor.pending_save = None;
}
})
}
.log_err()
.await
}));
}
}
pub fn delete_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_prompt_id) = self.active_prompt_id {
self.delete_prompt(active_prompt_id, window, cx);
}
}
pub fn duplicate_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_prompt_id) = self.active_prompt_id {
self.duplicate_prompt(active_prompt_id, window, cx);
}
}
pub fn toggle_default_for_active_prompt(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(active_prompt_id) = self.active_prompt_id {
self.toggle_default_for_prompt(active_prompt_id, window, cx);
}
}
pub fn toggle_default_for_prompt(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
self.store
.save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
.detach_and_log_err(cx);
self.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.notify();
}
}
pub fn load_prompt(
&mut self,
prompt_id: PromptId,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
if focus {
prompt_editor
.body_editor
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
}
self.set_active_prompt(Some(prompt_id), window, cx);
} else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
let language_registry = self.language_registry.clone();
let prompt = self.store.load(prompt_id);
let make_completion_provider = self.make_completion_provider.clone();
self.pending_load = cx.spawn_in(window, async move |this, cx| {
let prompt = prompt.await;
let markdown = language_registry.language_for_name("Markdown").await;
this.update_in(cx, |this, window, cx| match prompt {
Ok(prompt) => {
let title_editor = cx.new(|cx| {
let mut editor = Editor::auto_width(window, cx);
editor.set_placeholder_text("Untitled", cx);
editor.set_text(prompt_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(prompt, 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(false);
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_prompt_title_editor_event(
prompt_id, editor, event, window, cx,
)
},
),
cx.subscribe_in(
&body_editor,
window,
move |this, editor, event, window, cx| {
this.handle_prompt_body_editor_event(
prompt_id, editor, event, window, cx,
)
},
),
];
this.prompt_editors.insert(
prompt_id,
PromptEditor {
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_prompt(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 prompt: {:?}", error);
}
})
.ok();
});
}
}
fn set_active_prompt(
&mut self,
prompt_id: Option<PromptId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_prompt_id = prompt_id;
self.picker.update(cx, |picker, cx| {
if let Some(prompt_id) = prompt_id {
if picker
.delegate
.matches
.get(picker.delegate.selected_index())
.map_or(true, |old_selected_prompt| {
old_selected_prompt.id != prompt_id
})
{
if let Some(ix) = picker
.delegate
.matches
.iter()
.position(|mat| mat.id == prompt_id)
{
picker.set_selected_index(ix, true, window, cx);
}
}
} else {
picker.focus(window, cx);
}
});
cx.notify();
}
pub fn delete_prompt(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(metadata) = self.store.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_prompt_id == Some(prompt_id) {
this.set_active_prompt(None, window, cx);
}
this.prompt_editors.remove(&prompt_id);
this.store.delete(prompt_id).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_prompt(
&mut self,
prompt_id: PromptId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
const DUPLICATE_SUFFIX: &str = " copy";
let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
let existing_titles = self
.prompt_editors
.iter()
.filter(|&(&id, _)| id != prompt_id)
.map(|(_, prompt_editor)| prompt_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 = prompt.body_editor.read(cx).text(cx);
let save = self
.store
.save(new_id, Some(title.into()), false, body.into());
self.picker
.update(cx, |picker, cx| picker.refresh(window, cx));
cx.spawn_in(window, async move |this, cx| {
save.await?;
this.update_in(cx, |prompt_library, window, cx| {
prompt_library.load_prompt(new_id, true, window, cx)
})
})
.detach_and_log_err(cx);
}
}
fn focus_active_prompt(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_prompt) = self.active_prompt_id {
self.prompt_editors[&active_prompt]
.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_prompt_id) = self.active_prompt_id else {
cx.propagate();
return;
};
let prompt_editor = &self.prompt_editors[&active_prompt_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(prompt_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_assistant_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(prompt_id) = self.active_prompt_id {
if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
window.focus(&prompt_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(prompt_id) = self.active_prompt_id {
if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
window.focus(&prompt_editor.title_editor.focus_handle(cx));
}
}
}
fn handle_prompt_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_prompt(prompt_id, window, cx);
self.count_tokens(prompt_id, window, cx);
}
EditorEvent::Blurred => {
title_editor.update(cx, |title_editor, cx| {
title_editor.change_selections(None, window, cx, |selections| {
let cursor = selections.oldest_anchor().head();
selections.select_anchor_ranges([cursor..cursor]);
});
});
}
_ => {}
}
}
fn handle_prompt_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_prompt(prompt_id, window, cx);
self.count_tokens(prompt_id, window, cx);
}
EditorEvent::Blurred => {
body_editor.update(cx, |body_editor, cx| {
body_editor.change_selections(None, 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(prompt) = self.prompt_editors.get_mut(&prompt_id) {
let editor = &prompt.body_editor.read(cx);
let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
let body = buffer.as_rope().clone();
prompt.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 {
messages: vec![LanguageModelRequestMessage {
role: Role::System,
content: vec![body.to_string().into()],
cache: false,
}],
tools: Vec::new(),
stop: Vec::new(),
temperature: None,
},
cx,
)
})?
.await?;
this.update(cx, |this, cx| {
let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
prompt_editor.token_count = Some(token_count);
cx.notify();
})
}
.log_err()
.await
});
}
}
fn render_prompt_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("prompt-list")
.capture_action(cx.listener(Self::focus_active_prompt))
.bg(cx.theme().colors().panel_background)
.h_full()
.px_1()
.w_1_3()
.overflow_x_hidden()
.child(
h_flex()
.p(DynamicSpacing::Base04.rems(cx))
.h_9()
.w_full()
.flex_none()
.justify_end()
.child(
IconButton::new("new-prompt", IconName::Plus)
.style(ButtonStyle::Transparent)
.shape(IconButtonShape::Square)
.tooltip(move |window, cx| {
Tooltip::for_action("New Prompt", &NewPrompt, window, cx)
})
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(NewPrompt), cx);
}),
),
)
.child(div().flex_grow().child(self.picker.clone()))
}
fn render_active_prompt(&mut self, cx: &mut Context<PromptLibrary>) -> gpui::Stateful<Div> {
div()
.w_2_3()
.h_full()
.id("prompt-editor")
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.flex_none()
.min_w_64()
.children(self.active_prompt_id.and_then(|prompt_id| {
let prompt_metadata = self.store.metadata(prompt_id)?;
let prompt_editor = &self.prompt_editors[&prompt_id];
let focus_handle = prompt_editor.body_editor.focus_handle(cx);
let model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
let settings = ThemeSettings::get_global(cx);
Some(
v_flex()
.id("prompt-editor-inner")
.size_full()
.relative()
.overflow_hidden()
.pl(DynamicSpacing::Base16.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.on_click(cx.listener(move |_, _, window, _| {
window.focus(&focus_handle);
}))
.child(
h_flex()
.group("active-editor-header")
.pr(DynamicSpacing::Base16.rems(cx))
.pt(DynamicSpacing::Base02.rems(cx))
.pb(DynamicSpacing::Base08.rems(cx))
.justify_between()
.child(
h_flex().gap_1().child(
div()
.max_w_80()
.on_action(cx.listener(Self::move_down_from_title))
.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(
&prompt_editor.title_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),
inline_completion_styles:
editor::make_suggestion_styles(cx),
..EditorStyle::default()
},
)),
),
)
.child(
h_flex()
.h_full()
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base16.rems(cx))
.child(div()),
)
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base16.rems(cx))
.children(prompt_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();
h_flex()
.id("token_count")
.tooltip(move |window, cx| {
let token_count =
token_count.clone();
Tooltip::with_meta(
format!(
"{} tokens",
token_count.clone()
),
None,
format!(
"Model: {}",
model
.as_ref()
.map(|model| model
.name()
.0)
.unwrap_or_default()
),
window,
cx,
)
})
.child(
Label::new(format!(
"{} tokens",
label_token_count.clone()
))
.color(Color::Muted),
)
},
))
.child(if prompt_id.is_built_in() {
div()
.id("built-in-prompt")
.child(
Icon::new(IconName::FileLock)
.color(Color::Muted),
)
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Built-in prompt",
None,
BUILT_IN_TOOLTIP_TEXT,
window,
cx,
)
})
.into_any()
} else {
IconButton::new(
"delete-prompt",
IconName::Trash,
)
.size(ButtonSize::Large)
.style(ButtonStyle::Transparent)
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Prompt",
&DeletePrompt,
window,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DeletePrompt),
cx,
);
})
.into_any_element()
})
.child(
IconButton::new(
"duplicate-prompt",
IconName::BookCopy,
)
.size(ButtonSize::Large)
.style(ButtonStyle::Transparent)
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Duplicate Prompt",
&DuplicatePrompt,
window,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(DuplicatePrompt),
cx,
);
}),
)
.child(
IconButton::new(
"toggle-default-prompt",
IconName::Sparkle,
)
.style(ButtonStyle::Transparent)
.toggle_state(prompt_metadata.default)
.selected_icon(IconName::SparkleFilled)
.icon_color(if prompt_metadata.default {
Color::Accent
} else {
Color::Muted
})
.shape(IconButtonShape::Square)
.size(ButtonSize::Large)
.tooltip(Tooltip::text(
if prompt_metadata.default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
},
))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(ToggleDefaultPrompt),
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))
.flex_grow()
.h_full()
.child(prompt_editor.body_editor.clone()),
),
)
}))
}
}
impl Render for PromptLibrary {
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();
h_flex()
.id("prompt-manager")
.key_context("PromptLibrary")
.on_action(cx.listener(|this, &NewPrompt, window, cx| this.new_prompt(window, cx)))
.on_action(
cx.listener(|this, &DeletePrompt, window, cx| {
this.delete_active_prompt(window, cx)
}),
)
.on_action(cx.listener(|this, &DuplicatePrompt, window, cx| {
this.duplicate_active_prompt(window, cx)
}))
.on_action(cx.listener(|this, &ToggleDefaultPrompt, window, cx| {
this.toggle_default_for_active_prompt(window, cx)
}))
.size_full()
.overflow_hidden()
.font(ui_font)
.text_color(theme.colors().text)
.child(self.render_prompt_list(cx))
.map(|el| {
if self.store.prompt_count() == 0 {
el.child(
v_flex()
.w_2_3()
.h_full()
.items_center()
.justify_center()
.gap_4()
.bg(cx.theme().colors().editor_background)
.child(
h_flex()
.gap_2()
.child(
Icon::new(IconName::Book)
.size(IconSize::Medium)
.color(Color::Muted),
)
.child(
Label::new("No prompts yet")
.size(LabelSize::Large)
.color(Color::Muted),
),
)
.child(
h_flex()
.child(h_flex())
.child(
v_flex()
.gap_1()
.child(Label::new("Create your first prompt:"))
.child(
Button::new("create-prompt", "New Prompt")
.full_width()
.key_binding(KeyBinding::for_action(
&NewPrompt, window, cx,
))
.on_click(|_, window, cx| {
window.dispatch_action(
NewPrompt.boxed_clone(),
cx,
)
}),
),
)
.child(h_flex()),
),
)
} else {
el.child(self.render_active_prompt(cx))
}
})
}
}