ZIm/crates/agent/src/agent_panel.rs
Marshall Bowers a23ee61a4b
Pass up intent with completion requests (#31710)
This PR adds a new `intent` field to completion requests to assist in
categorizing them correctly.

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-05-29 20:43:12 +00:00

3312 lines
130 KiB
Rust

use std::ops::Range;
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView};
use anyhow::{Result, anyhow};
use assistant_context_editor::{
AgentPanelDelegate, AssistantContext, ConfigurationError, ContextEditor, ContextEvent,
ContextSummary, SlashCommandCompletionProvider, humanize_token_count,
make_lsp_adapter_delegate, render_remaining_tokens,
};
use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet;
use assistant_context_editor::language_model_selector::ToggleModelSelector;
use client::{UserStore, zed_urls};
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, FontWeight,
KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop,
linear_gradient, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{
LanguageModelProviderTosView, LanguageModelRegistry, RequestUsage, ZED_CLOUD_PROVIDER_ID,
};
use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
use proto::Plan;
use rules_library::{RulesLibrary, open_rules_library};
use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
use theme::ThemeSettings;
use time::UtcOffset;
use ui::utils::WithRemSize;
use ui::{
Banner, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu,
PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
};
use util::{ResultExt as _, maybe};
use workspace::dock::{DockPosition, Panel, PanelEvent};
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
};
use zed_actions::agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding};
use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
use zed_llm_client::{CompletionIntent, UsageLimit};
use crate::active_thread::{self, ActiveThread, ActiveThreadEvent};
use crate::agent_configuration::{AgentConfiguration, AssistantConfigurationEvent};
use crate::agent_diff::AgentDiff;
use crate::history_store::{HistoryStore, RecentEntry};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::thread::{Thread, ThreadError, ThreadId, ThreadSummary, TokenUsageRatio};
use crate::thread_history::{HistoryEntryElement, ThreadHistory};
use crate::thread_store::ThreadStore;
use crate::ui::AgentOnboardingModal;
use crate::{
AddContextServer, AgentDiffPane, ContextStore, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleBurnMode, ToggleContextPicker,
ToggleNavigationMenu, ToggleOptionsMenu,
};
const AGENT_PANEL_KEY: &str = "agent_panel";
#[derive(Serialize, Deserialize)]
struct SerializedAgentPanel {
width: Option<Pixels>,
}
pub fn init(cx: &mut App) {
cx.observe_new(
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
workspace
.register_action(|workspace, action: &NewThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
workspace.focus_panel::<AgentPanel>(window, cx);
}
})
.register_action(|workspace, _: &OpenHistory, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_history(window, cx));
}
})
.register_action(|workspace, _: &OpenConfiguration, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
}
})
.register_action(|workspace, _: &NewTextThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
}
})
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_rules_library(action, window, cx)
});
}
})
.register_action(|workspace, _: &OpenAgentDiff, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
let thread = panel.read(cx).thread.read(cx).thread().clone();
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
}
})
.register_action(|workspace, _: &Follow, window, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
.register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.message_editor.update(cx, |editor, cx| {
editor.expand_message_editor(&ExpandMessageEditor, window, cx);
});
});
}
})
.register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
});
}
})
.register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
});
}
})
.register_action(|workspace, _: &OpenOnboardingModal, window, cx| {
AgentOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
})
.register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| {
Upsell::set_dismissed(false, cx);
})
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx);
});
},
)
.detach();
}
enum ActiveView {
Thread {
change_title_editor: Entity<Editor>,
thread: WeakEntity<Thread>,
_subscriptions: Vec<gpui::Subscription>,
},
TextThread {
context_editor: Entity<ContextEditor>,
title_editor: Entity<Editor>,
buffer_search_bar: Entity<BufferSearchBar>,
_subscriptions: Vec<gpui::Subscription>,
},
History,
Configuration,
}
enum WhichFontSize {
AgentFont,
BufferFont,
None,
}
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont,
ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
ActiveView::Configuration => WhichFontSize::None,
}
}
pub fn thread(thread: Entity<Thread>, window: &mut Window, cx: &mut App) -> Self {
let summary = thread.read(cx).summary().or_default();
let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(summary.clone(), window, cx);
editor
});
let subscriptions = vec![
window.subscribe(&editor, cx, {
{
let thread = thread.clone();
move |editor, event, window, cx| match event {
EditorEvent::BufferEdited => {
let new_summary = editor.read(cx).text(cx);
thread.update(cx, |thread, cx| {
thread.set_summary(new_summary, cx);
})
}
EditorEvent::Blurred => {
if editor.read(cx).text(cx).is_empty() {
let summary = thread.read(cx).summary().or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
});
}
}
_ => {}
}
}
}),
window.subscribe(&thread, cx, {
let editor = editor.clone();
move |thread, event, window, cx| match event {
ThreadEvent::SummaryGenerated => {
let summary = thread.read(cx).summary().or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
})
}
_ => {}
}
}),
];
Self::Thread {
change_title_editor: editor,
thread: thread.downgrade(),
_subscriptions: subscriptions,
}
}
pub fn prompt_editor(
context_editor: Entity<ContextEditor>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut App,
) -> Self {
let title = context_editor.read(cx).title(cx).to_string();
let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(title, window, cx);
editor
});
// This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
// cause a custom summary to be set. The presence of this custom summary would cause
// summarization to not happen.
let mut suppress_first_edit = true;
let subscriptions = vec![
window.subscribe(&editor, cx, {
{
let context_editor = context_editor.clone();
move |editor, event, window, cx| match event {
EditorEvent::BufferEdited => {
if suppress_first_edit {
suppress_first_edit = false;
return;
}
let new_summary = editor.read(cx).text(cx);
context_editor.update(cx, |context_editor, cx| {
context_editor
.context()
.update(cx, |assistant_context, cx| {
assistant_context.set_custom_summary(new_summary, cx);
})
})
}
EditorEvent::Blurred => {
if editor.read(cx).text(cx).is_empty() {
let summary = context_editor
.read(cx)
.context()
.read(cx)
.summary()
.or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
});
}
}
_ => {}
}
}
}),
window.subscribe(&context_editor.read(cx).context().clone(), cx, {
let editor = editor.clone();
move |assistant_context, event, window, cx| match event {
ContextEvent::SummaryGenerated => {
let summary = assistant_context.read(cx).summary().or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
})
}
_ => {}
}
}),
];
let buffer_search_bar =
cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx));
buffer_search_bar.update(cx, |buffer_search_bar, cx| {
buffer_search_bar.set_active_pane_item(Some(&context_editor), window, cx)
});
Self::TextThread {
context_editor,
title_editor: editor,
buffer_search_bar,
_subscriptions: subscriptions,
}
}
}
pub struct AgentPanel {
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
project: Entity<Project>,
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
thread_store: Entity<ThreadStore>,
thread: Entity<ActiveThread>,
message_editor: Entity<MessageEditor>,
_active_thread_subscriptions: Vec<Subscription>,
_default_model_subscription: Subscription,
context_store: Entity<TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
inline_assist_context_store: Entity<crate::context_store::ContextStore>,
configuration: Option<Entity<AgentConfiguration>>,
configuration_subscription: Option<Subscription>,
local_timezone: UtcOffset,
active_view: ActiveView,
previous_view: Option<ActiveView>,
history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>,
hovered_recent_history_item: Option<usize>,
assistant_dropdown_menu_handle: PopoverMenuHandle<ContextMenu>,
assistant_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
assistant_navigation_menu: Option<Entity<ContextMenu>>,
width: Option<Pixels>,
height: Option<Pixels>,
zoomed: bool,
pending_serialization: Option<Task<Result<()>>>,
hide_upsell: bool,
}
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context<Self>) {
let width = self.width;
self.pending_serialization = Some(cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(
AGENT_PANEL_KEY.into(),
serde_json::to_string(&SerializedAgentPanel { width })?,
)
.await?;
anyhow::Ok(())
}));
}
pub fn load(
workspace: WeakEntity<Workspace>,
prompt_builder: Arc<PromptBuilder>,
mut cx: AsyncWindowContext,
) -> Task<Result<Entity<Self>>> {
let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
cx.spawn(async move |cx| {
let prompt_store = match prompt_store {
Ok(prompt_store) => prompt_store.await.ok(),
Err(_) => None,
};
let tools = cx.new(|_| ToolWorkingSet::default())?;
let thread_store = workspace
.update(cx, |workspace, cx| {
let project = workspace.project().clone();
ThreadStore::load(
project,
tools.clone(),
prompt_store.clone(),
prompt_builder.clone(),
cx,
)
})?
.await?;
let slash_commands = Arc::new(SlashCommandWorkingSet::default());
let context_store = workspace
.update(cx, |workspace, cx| {
let project = workspace.project().clone();
assistant_context_editor::ContextStore::new(
project,
prompt_builder.clone(),
slash_commands,
cx,
)
})?
.await?;
let serialized_panel = if let Some(panel) = cx
.background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) })
.await
.log_err()
.flatten()
{
Some(serde_json::from_str::<SerializedAgentPanel>(&panel)?)
} else {
None
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| {
Self::new(
workspace,
thread_store,
context_store,
prompt_store,
window,
cx,
)
});
if let Some(serialized_panel) = serialized_panel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round());
cx.notify();
});
}
panel
})?;
Ok(panel)
})
}
fn new(
workspace: &Workspace,
thread_store: Entity<ThreadStore>,
context_store: Entity<TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let thread = thread_store.update(cx, |this, cx| this.create_thread(cx));
let fs = workspace.app_state().fs.clone();
let user_store = workspace.app_state().user_store.clone();
let project = workspace.project();
let language_registry = project.read(cx).languages().clone();
let workspace = workspace.weak_handle();
let weak_self = cx.entity().downgrade();
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
project.downgrade(),
Some(thread_store.downgrade()),
)
});
let inline_assist_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
project.downgrade(),
Some(thread_store.downgrade()),
)
});
let message_editor = cx.new(|cx| {
MessageEditor::new(
fs.clone(),
workspace.clone(),
user_store.clone(),
message_editor_context_store.clone(),
prompt_store.clone(),
thread_store.downgrade(),
context_store.downgrade(),
thread.clone(),
window,
cx,
)
});
let message_editor_subscription =
cx.subscribe(&message_editor, |_, _, event, cx| match event {
MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
cx.notify();
}
});
let thread_id = thread.read(cx).id().clone();
let history_store = cx.new(|cx| {
HistoryStore::new(
thread_store.clone(),
context_store.clone(),
[RecentEntry::Thread(thread_id, thread.clone())],
window,
cx,
)
});
cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
let panel_type = AgentSettings::get_global(cx).default_view;
let active_view = match panel_type {
DefaultView::Thread => ActiveView::thread(thread.clone(), window, cx),
DefaultView::TextThread => {
let context =
context_store.update(cx, |context_store, cx| context_store.create(cx));
let lsp_adapter_delegate = make_lsp_adapter_delegate(&project.clone(), cx).unwrap();
let context_editor = cx.new(|cx| {
let mut editor = ContextEditor::for_context(
context,
fs.clone(),
workspace.clone(),
project.clone(),
lsp_adapter_delegate,
window,
cx,
);
editor.insert_default_prompt(window, cx);
editor
});
ActiveView::prompt_editor(context_editor, language_registry.clone(), window, cx)
}
};
let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
if let ThreadEvent::MessageAdded(_) = &event {
// needed to leave empty state
cx.notify();
}
});
let active_thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
thread_store.clone(),
context_store.clone(),
message_editor_context_store.clone(),
language_registry.clone(),
workspace.clone(),
window,
cx,
)
});
AgentDiff::set_active_thread(&workspace, &thread, window, cx);
let active_thread_subscription =
cx.subscribe(&active_thread, |_, _, event, cx| match &event {
ActiveThreadEvent::EditingMessageTokenCountChanged => {
cx.notify();
}
});
let weak_panel = weak_self.clone();
window.defer(cx, move |window, cx| {
let panel = weak_panel.clone();
let assistant_navigation_menu =
ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
let recently_opened = panel
.update(cx, |this, cx| {
this.history_store.update(cx, |history_store, cx| {
history_store.recently_opened_entries(cx)
})
})
.unwrap_or_default();
if !recently_opened.is_empty() {
menu = menu.header("Recently Opened");
for entry in recently_opened.iter() {
if let RecentEntry::Context(context) = entry {
if context.read(cx).path().is_none() {
log::error!(
"bug: text thread in recent history list was never saved"
);
continue;
}
}
let summary = entry.summary(cx);
menu = menu.entry_with_end_slot_on_hover(
summary,
None,
{
let panel = panel.clone();
let entry = entry.clone();
move |window, cx| {
panel
.update(cx, {
let entry = entry.clone();
move |this, cx| match entry {
RecentEntry::Thread(_, thread) => {
this.open_thread(thread, window, cx)
}
RecentEntry::Context(context) => {
let Some(path) = context.read(cx).path()
else {
return;
};
this.open_saved_prompt_editor(
path.clone(),
window,
cx,
)
.detach_and_log_err(cx)
}
}
})
.ok();
}
},
IconName::Close,
"Close Entry".into(),
{
let panel = panel.clone();
let entry = entry.clone();
move |_window, cx| {
panel
.update(cx, |this, cx| {
this.history_store.update(
cx,
|history_store, cx| {
history_store.remove_recently_opened_entry(
&entry, cx,
);
},
);
})
.ok();
}
},
);
}
menu = menu.separator();
}
menu.action("View All", Box::new(OpenHistory))
.end_slot_action(DeleteRecentlyOpenThread.boxed_clone())
.fixed_width(px(320.).into())
.keep_open_on_confirm(false)
.key_context("NavigationMenu")
});
weak_panel
.update(cx, |panel, cx| {
cx.subscribe_in(
&assistant_navigation_menu,
window,
|_, menu, _: &DismissEvent, window, cx| {
menu.update(cx, |menu, _| {
menu.clear_selected();
});
cx.focus_self(window);
},
)
.detach();
panel.assistant_navigation_menu = Some(assistant_navigation_menu);
})
.ok();
});
let _default_model_subscription = cx.subscribe(
&LanguageModelRegistry::global(cx),
|this, _, event: &language_model::Event, cx| match event {
language_model::Event::DefaultModelChanged => {
this.thread
.read(cx)
.thread()
.clone()
.update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
}
_ => {}
},
);
Self {
active_view,
workspace,
user_store,
project: project.clone(),
fs: fs.clone(),
language_registry,
thread_store: thread_store.clone(),
thread: active_thread,
message_editor,
_active_thread_subscriptions: vec![
thread_subscription,
active_thread_subscription,
message_editor_subscription,
],
_default_model_subscription,
context_store,
prompt_store,
configuration: None,
configuration_subscription: None,
local_timezone: UtcOffset::from_whole_seconds(
chrono::Local::now().offset().local_minus_utc(),
)
.unwrap(),
inline_assist_context_store,
previous_view: None,
history_store: history_store.clone(),
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
hovered_recent_history_item: None,
assistant_dropdown_menu_handle: PopoverMenuHandle::default(),
assistant_navigation_menu_handle: PopoverMenuHandle::default(),
assistant_navigation_menu: None,
width: None,
height: None,
zoomed: false,
pending_serialization: None,
hide_upsell: false,
}
}
pub fn toggle_focus(
workspace: &mut Workspace,
_: &ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
if workspace
.panel::<Self>(cx)
.is_some_and(|panel| panel.read(cx).enabled(cx))
{
workspace.toggle_panel_focus::<Self>(window, cx);
}
}
pub(crate) fn local_timezone(&self) -> UtcOffset {
self.local_timezone
}
pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
&self.prompt_store
}
pub(crate) fn inline_assist_context_store(
&self,
) -> &Entity<crate::context_store::ContextStore> {
&self.inline_assist_context_store
}
pub(crate) fn thread_store(&self) -> &Entity<ThreadStore> {
&self.thread_store
}
pub(crate) fn text_thread_store(&self) -> &Entity<TextThreadStore> {
&self.context_store
}
fn cancel(&mut self, _: &editor::actions::Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.thread
.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
}
fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
let thread = self
.thread_store
.update(cx, |this, cx| this.create_thread(cx));
let thread_view = ActiveView::thread(thread.clone(), window, cx);
self.set_active_view(thread_view, window, cx);
let context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
self.project.downgrade(),
Some(self.thread_store.downgrade()),
)
});
if let Some(other_thread_id) = action.from_thread_id.clone() {
let other_thread_task = self.thread_store.update(cx, |this, cx| {
this.open_thread(&other_thread_id, window, cx)
});
cx.spawn({
let context_store = context_store.clone();
async move |_panel, cx| {
let other_thread = other_thread_task.await?;
context_store.update(cx, |this, cx| {
this.add_thread(other_thread, false, cx);
})?;
anyhow::Ok(())
}
})
.detach_and_log_err(cx);
}
let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
if let ThreadEvent::MessageAdded(_) = &event {
// needed to leave empty state
cx.notify();
}
});
self.thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
self.thread_store.clone(),
self.context_store.clone(),
context_store.clone(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
)
});
AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event {
ActiveThreadEvent::EditingMessageTokenCountChanged => {
cx.notify();
}
});
self.message_editor = cx.new(|cx| {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
self.user_store.clone(),
context_store,
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
thread,
window,
cx,
)
});
self.message_editor.focus_handle(cx).focus(window);
let message_editor_subscription =
cx.subscribe(&self.message_editor, |_, _, event, cx| match event {
MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
cx.notify();
}
});
self._active_thread_subscriptions = vec![
thread_subscription,
active_thread_subscription,
message_editor_subscription,
];
}
fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let context = self
.context_store
.update(cx, |context_store, cx| context_store.create(cx));
let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx)
.log_err()
.flatten();
let context_editor = cx.new(|cx| {
let mut editor = ContextEditor::for_context(
context,
self.fs.clone(),
self.workspace.clone(),
self.project.clone(),
lsp_adapter_delegate,
window,
cx,
);
editor.insert_default_prompt(window, cx);
editor
});
self.set_active_view(
ActiveView::prompt_editor(
context_editor.clone(),
self.language_registry.clone(),
window,
cx,
),
window,
cx,
);
context_editor.focus_handle(cx).focus(window);
}
fn deploy_rules_library(
&mut self,
action: &OpenRulesLibrary,
_window: &mut Window,
cx: &mut Context<Self>,
) {
open_rules_library(
self.language_registry.clone(),
Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
Rc::new(|| {
Rc::new(SlashCommandCompletionProvider::new(
Arc::new(SlashCommandWorkingSet::default()),
None,
None,
))
}),
action
.prompt_to_select
.map(|uuid| UserPromptId(uuid).into()),
cx,
)
.detach_and_log_err(cx);
}
fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if matches!(self.active_view, ActiveView::History) {
if let Some(previous_view) = self.previous_view.take() {
self.set_active_view(previous_view, window, cx);
}
} else {
self.thread_store
.update(cx, |thread_store, cx| thread_store.reload(cx))
.detach_and_log_err(cx);
self.set_active_view(ActiveView::History, window, cx);
}
cx.notify();
}
pub(crate) fn open_saved_prompt_editor(
&mut self,
path: Arc<Path>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let context = self
.context_store
.update(cx, |store, cx| store.open_local_context(path, cx));
cx.spawn_in(window, async move |this, cx| {
let context = context.await?;
this.update_in(cx, |this, window, cx| {
this.open_prompt_editor(context, window, cx);
})
})
}
pub(crate) fn open_prompt_editor(
&mut self,
context: Entity<AssistantContext>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project.clone(), cx)
.log_err()
.flatten();
let editor = cx.new(|cx| {
ContextEditor::for_context(
context,
self.fs.clone(),
self.workspace.clone(),
self.project.clone(),
lsp_adapter_delegate,
window,
cx,
)
});
self.set_active_view(
ActiveView::prompt_editor(editor.clone(), self.language_registry.clone(), window, cx),
window,
cx,
);
}
pub(crate) fn open_thread_by_id(
&mut self,
thread_id: &ThreadId,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let open_thread_task = self
.thread_store
.update(cx, |this, cx| this.open_thread(thread_id, window, cx));
cx.spawn_in(window, async move |this, cx| {
let thread = open_thread_task.await?;
this.update_in(cx, |this, window, cx| {
this.open_thread(thread, window, cx);
anyhow::Ok(())
})??;
Ok(())
})
}
pub(crate) fn open_thread(
&mut self,
thread: Entity<Thread>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let thread_view = ActiveView::thread(thread.clone(), window, cx);
self.set_active_view(thread_view, window, cx);
let context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
self.project.downgrade(),
Some(self.thread_store.downgrade()),
)
});
let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
if let ThreadEvent::MessageAdded(_) = &event {
// needed to leave empty state
cx.notify();
}
});
self.thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
self.thread_store.clone(),
self.context_store.clone(),
context_store.clone(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
)
});
AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event {
ActiveThreadEvent::EditingMessageTokenCountChanged => {
cx.notify();
}
});
self.message_editor = cx.new(|cx| {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
self.user_store.clone(),
context_store,
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
thread,
window,
cx,
)
});
self.message_editor.focus_handle(cx).focus(window);
let message_editor_subscription =
cx.subscribe(&self.message_editor, |_, _, event, cx| match event {
MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
cx.notify();
}
});
self._active_thread_subscriptions = vec![
thread_subscription,
active_thread_subscription,
message_editor_subscription,
];
}
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
match self.active_view {
ActiveView::Configuration | ActiveView::History => {
if let Some(previous_view) = self.previous_view.take() {
self.active_view = previous_view;
match &self.active_view {
ActiveView::Thread { .. } => {
self.message_editor.focus_handle(cx).focus(window);
}
ActiveView::TextThread { context_editor, .. } => {
context_editor.focus_handle(cx).focus(window);
}
_ => {}
}
} else {
self.active_view =
ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx);
self.message_editor.focus_handle(cx).focus(window);
}
cx.notify();
}
_ => {}
}
}
pub fn toggle_navigation_menu(
&mut self,
_: &ToggleNavigationMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.assistant_navigation_menu_handle.toggle(window, cx);
}
pub fn toggle_options_menu(
&mut self,
_: &ToggleOptionsMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.assistant_dropdown_menu_handle.toggle(window, cx);
}
pub fn increase_font_size(
&mut self,
action: &IncreaseBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_font_size_action(action.persist, px(1.0), cx);
}
pub fn decrease_font_size(
&mut self,
action: &DecreaseBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_font_size_action(action.persist, px(-1.0), cx);
}
fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
if persist {
update_settings_file::<ThemeSettings>(
self.fs.clone(),
cx,
move |settings, cx| {
let agent_font_size =
ThemeSettings::get_global(cx).agent_font_size(cx) + delta;
let _ = settings
.agent_font_size
.insert(theme::clamp_font_size(agent_font_size).0);
},
);
} else {
theme::adjust_agent_font_size(cx, |size| {
*size += delta;
});
}
}
WhichFontSize::BufferFont => {
// Prompt editor uses the buffer font size, so allow the action to propagate to the
// default handler that changes that font size.
cx.propagate();
}
WhichFontSize::None => {}
}
}
pub fn reset_font_size(
&mut self,
action: &ResetBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
if action.persist {
update_settings_file::<ThemeSettings>(self.fs.clone(), cx, move |settings, _| {
settings.agent_font_size = None;
});
} else {
theme::reset_agent_font_size(cx);
}
}
pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
if self.zoomed {
cx.emit(PanelEvent::ZoomOut);
} else {
if !self.focus_handle(cx).contains_focused(window, cx) {
cx.focus_self(window);
}
cx.emit(PanelEvent::ZoomIn);
}
}
pub fn open_agent_diff(
&mut self,
_: &OpenAgentDiff,
window: &mut Window,
cx: &mut Context<Self>,
) {
let thread = self.thread.read(cx).thread().clone();
self.workspace
.update(cx, |workspace, cx| {
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx)
})
.log_err();
}
pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let context_server_store = self.project.read(cx).context_server_store();
let tools = self.thread_store.read(cx).tools();
let fs = self.fs.clone();
self.set_active_view(ActiveView::Configuration, window, cx);
self.configuration =
Some(cx.new(|cx| AgentConfiguration::new(fs, context_server_store, tools, window, cx)));
if let Some(configuration) = self.configuration.as_ref() {
self.configuration_subscription = Some(cx.subscribe_in(
configuration,
window,
Self::handle_agent_configuration_event,
));
configuration.focus_handle(cx).focus(window);
}
}
pub(crate) fn open_active_thread_as_markdown(
&mut self,
_: &OpenActiveThreadAsMarkdown,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(thread) = self.active_thread() else {
return;
};
active_thread::open_active_thread_as_markdown(thread, workspace, window, cx)
.detach_and_log_err(cx);
}
fn handle_agent_configuration_event(
&mut self,
_entity: &Entity<AgentConfiguration>,
event: &AssistantConfigurationEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
AssistantConfigurationEvent::NewThread(provider) => {
if LanguageModelRegistry::read_global(cx)
.default_model()
.map_or(true, |model| model.provider.id() != provider.id())
{
if let Some(model) = provider.default_model(cx) {
update_settings_file::<AgentSettings>(
self.fs.clone(),
cx,
move |settings, _| settings.set_model(model),
);
}
}
self.new_thread(&NewThread::default(), window, cx);
}
}
}
pub(crate) fn active_thread(&self) -> Option<Entity<Thread>> {
match &self.active_view {
ActiveView::Thread { thread, .. } => thread.upgrade(),
_ => None,
}
}
pub(crate) fn delete_thread(
&mut self,
thread_id: &ThreadId,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.thread_store
.update(cx, |this, cx| this.delete_thread(thread_id, cx))
}
pub(crate) fn has_active_thread(&self) -> bool {
matches!(self.active_view, ActiveView::Thread { .. })
}
fn continue_conversation(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let thread_state = self.thread.read(cx).thread().read(cx);
if !thread_state.tool_use_limit_reached() {
return;
}
let model = thread_state.configured_model().map(|cm| cm.model.clone());
if let Some(model) = model {
self.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, cx| {
thread.insert_invisible_continue_message(cx);
thread.advance_prompt_id();
thread.send_to_model(
model,
CompletionIntent::UserPrompt,
Some(window.window_handle()),
cx,
);
});
});
} else {
log::warn!("No configured model available for continuation");
}
}
fn toggle_burn_mode(
&mut self,
_: &ToggleBurnMode,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
let current_mode = thread.completion_mode();
thread.set_completion_mode(match current_mode {
CompletionMode::Burn => CompletionMode::Normal,
CompletionMode::Normal => CompletionMode::Burn,
});
});
});
}
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
match &self.active_view {
ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()),
_ => None,
}
}
pub(crate) fn delete_context(
&mut self,
path: Arc<Path>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.context_store
.update(cx, |this, cx| this.delete_local_context(path, cx))
}
fn set_active_view(
&mut self,
new_view: ActiveView,
window: &mut Window,
cx: &mut Context<Self>,
) {
let current_is_history = matches!(self.active_view, ActiveView::History);
let new_is_history = matches!(new_view, ActiveView::History);
let current_is_config = matches!(self.active_view, ActiveView::Configuration);
let new_is_config = matches!(new_view, ActiveView::Configuration);
let current_is_special = current_is_history || current_is_config;
let new_is_special = new_is_history || new_is_config;
match &self.active_view {
ActiveView::Thread { thread, .. } => {
if let Some(thread) = thread.upgrade() {
if thread.read(cx).is_empty() {
let id = thread.read(cx).id().clone();
self.history_store.update(cx, |store, cx| {
store.remove_recently_opened_thread(id, cx);
});
}
}
}
ActiveView::TextThread { context_editor, .. } => {
let context = context_editor.read(cx).context();
// When switching away from an unsaved text thread, delete its entry.
if context.read(cx).path().is_none() {
let context = context.clone();
self.history_store.update(cx, |store, cx| {
store.remove_recently_opened_entry(&RecentEntry::Context(context), cx);
});
}
}
_ => {}
}
match &new_view {
ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| {
if let Some(thread) = thread.upgrade() {
let id = thread.read(cx).id().clone();
store.push_recently_opened_entry(RecentEntry::Thread(id, thread), cx);
}
}),
ActiveView::TextThread { context_editor, .. } => {
self.history_store.update(cx, |store, cx| {
let context = context_editor.read(cx).context().clone();
store.push_recently_opened_entry(RecentEntry::Context(context), cx)
})
}
_ => {}
}
if current_is_special && !new_is_special {
self.active_view = new_view;
} else if !current_is_special && new_is_special {
self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
} else {
if !new_is_special {
self.previous_view = None;
}
self.active_view = new_view;
}
self.focus_handle(cx).focus(window);
}
}
impl Focusable for AgentPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match &self.active_view {
ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
ActiveView::History => self.history.focus_handle(cx),
ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
ActiveView::Configuration => {
if let Some(configuration) = self.configuration.as_ref() {
configuration.focus_handle(cx)
} else {
cx.focus_handle()
}
}
}
}
}
fn agent_panel_dock_position(cx: &App) -> DockPosition {
match AgentSettings::get_global(cx).dock {
AgentDockPosition::Left => DockPosition::Left,
AgentDockPosition::Bottom => DockPosition::Bottom,
AgentDockPosition::Right => DockPosition::Right,
}
}
impl EventEmitter<PanelEvent> for AgentPanel {}
impl Panel for AgentPanel {
fn persistent_name() -> &'static str {
"AgentPanel"
}
fn position(&self, _window: &Window, cx: &App) -> DockPosition {
agent_panel_dock_position(cx)
}
fn position_is_valid(&self, position: DockPosition) -> bool {
position != DockPosition::Bottom
}
fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
settings::update_settings_file::<AgentSettings>(self.fs.clone(), cx, move |settings, _| {
let dock = match position {
DockPosition::Left => AgentDockPosition::Left,
DockPosition::Bottom => AgentDockPosition::Bottom,
DockPosition::Right => AgentDockPosition::Right,
};
settings.set_dock(dock);
});
}
fn size(&self, window: &Window, cx: &App) -> Pixels {
let settings = AgentSettings::get_global(cx);
match self.position(window, cx) {
DockPosition::Left | DockPosition::Right => {
self.width.unwrap_or(settings.default_width)
}
DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
}
}
fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
match self.position(window, cx) {
DockPosition::Left | DockPosition::Right => self.width = size,
DockPosition::Bottom => self.height = size,
}
self.serialize(cx);
cx.notify();
}
fn set_active(&mut self, _active: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
fn remote_id() -> Option<proto::PanelId> {
Some(proto::PanelId::AssistantPanel)
}
fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
(self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
}
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
Some("Agent Panel")
}
fn toggle_action(&self) -> Box<dyn Action> {
Box::new(ToggleFocus)
}
fn activation_priority(&self) -> u32 {
3
}
fn enabled(&self, cx: &App) -> bool {
AgentSettings::get_global(cx).enabled
}
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
self.zoomed
}
fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.zoomed = zoomed;
cx.notify();
}
}
impl AgentPanel {
fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
let content = match &self.active_view {
ActiveView::Thread {
change_title_editor,
..
} => {
let active_thread = self.thread.read(cx);
let state = if active_thread.is_empty() {
&ThreadSummary::Pending
} else {
active_thread.summary(cx)
};
match state {
ThreadSummary::Pending => Label::new(ThreadSummary::DEFAULT.clone())
.truncate()
.into_any_element(),
ThreadSummary::Generating => Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
.into_any_element(),
ThreadSummary::Ready(_) => div()
.w_full()
.child(change_title_editor.clone())
.into_any_element(),
ThreadSummary::Error => h_flex()
.w_full()
.child(change_title_editor.clone())
.child(
ui::IconButton::new("retry-summary-generation", IconName::RotateCcw)
.on_click({
let active_thread = self.thread.clone();
move |_, _window, cx| {
active_thread.update(cx, |thread, cx| {
thread.regenerate_summary(cx);
});
}
})
.tooltip(move |_window, cx| {
cx.new(|_| {
Tooltip::new("Failed to generate title")
.meta("Click to try again")
})
.into()
}),
)
.into_any_element(),
}
}
ActiveView::TextThread {
title_editor,
context_editor,
..
} => {
let summary = context_editor.read(cx).context().read(cx).summary();
match summary {
ContextSummary::Pending => Label::new(ContextSummary::DEFAULT)
.truncate()
.into_any_element(),
ContextSummary::Content(summary) => {
if summary.done {
div()
.w_full()
.child(title_editor.clone())
.into_any_element()
} else {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
.into_any_element()
}
}
ContextSummary::Error => h_flex()
.w_full()
.child(title_editor.clone())
.child(
ui::IconButton::new("retry-summary-generation", IconName::RotateCcw)
.on_click({
let context_editor = context_editor.clone();
move |_, _window, cx| {
context_editor.update(cx, |context_editor, cx| {
context_editor.regenerate_summary(cx);
});
}
})
.tooltip(move |_window, cx| {
cx.new(|_| {
Tooltip::new("Failed to generate title")
.meta("Click to try again")
})
.into()
}),
)
.into_any_element(),
}
}
ActiveView::History => Label::new("History").truncate().into_any_element(),
ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
};
h_flex()
.key_context("TitleEditor")
.id("TitleEditor")
.flex_grow()
.w_full()
.max_w_full()
.overflow_x_scroll()
.child(content)
.into_any()
}
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let active_thread = self.thread.read(cx);
let user_store = self.user_store.read(cx);
let thread = active_thread.thread().read(cx);
let thread_id = thread.id().clone();
let is_empty = active_thread.is_empty();
let editor_empty = self.message_editor.read(cx).is_editor_fully_empty(cx);
let last_usage = active_thread.thread().read(cx).last_usage().or_else(|| {
maybe!({
let amount = user_store.model_request_usage_amount()?;
let limit = user_store.model_request_usage_limit()?.variant?;
Some(RequestUsage {
amount: amount as i32,
limit: match limit {
proto::usage_limit::Variant::Limited(limited) => {
zed_llm_client::UsageLimit::Limited(limited.limit as i32)
}
proto::usage_limit::Variant::Unlimited(_) => {
zed_llm_client::UsageLimit::Unlimited
}
},
})
})
});
let account_url = zed_urls::account_url(cx);
let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty || !editor_empty,
ActiveView::TextThread { .. } => true,
_ => false,
};
let focus_handle = self.focus_handle(cx);
let go_back_button = div().child(
IconButton::new("go-back", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.go_back(&workspace::GoBack, window, cx);
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go Back",
&workspace::GoBack,
&focus_handle,
window,
cx,
)
}
}),
);
let recent_entries_menu = div().child(
PopoverMenu::new("agent-nav-menu")
.trigger_with_tooltip(
IconButton::new("agent-nav-menu", IconName::MenuAlt)
.icon_size(IconSize::Small)
.style(ui::ButtonStyle::Subtle),
{
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Panel Menu",
&ToggleNavigationMenu,
&focus_handle,
window,
cx,
)
}
},
)
.anchor(Corner::TopLeft)
.with_handle(self.assistant_navigation_menu_handle.clone())
.menu({
let menu = self.assistant_navigation_menu.clone();
move |window, cx| {
if let Some(menu) = menu.as_ref() {
menu.update(cx, |_, cx| {
cx.defer_in(window, |menu, window, cx| {
menu.rebuild(window, cx);
});
})
}
menu.clone()
}
}),
);
let zoom_in_label = if self.is_zoomed(window, cx) {
"Zoom Out"
} else {
"Zoom In"
};
let agent_extra_menu = PopoverMenu::new("agent-options-menu")
.trigger_with_tooltip(
IconButton::new("agent-options-menu", IconName::Ellipsis)
.icon_size(IconSize::Small),
{
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Agent Menu",
&ToggleOptionsMenu,
&focus_handle,
window,
cx,
)
}
},
)
.anchor(Corner::TopRight)
.with_handle(self.assistant_dropdown_menu_handle.clone())
.menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
menu = menu
.action("New Thread", NewThread::default().boxed_clone())
.action("New Text Thread", NewTextThread.boxed_clone())
.when(!is_empty, |menu| {
menu.action(
"New From Summary",
Box::new(NewThread {
from_thread_id: Some(thread_id.clone()),
}),
)
})
.separator();
menu = menu
.header("MCP Servers")
.action(
"View Server Extensions",
Box::new(zed_actions::Extensions {
category_filter: Some(
zed_actions::ExtensionCategoryFilter::ContextServers,
),
}),
)
.action("Add Custom Server…", Box::new(AddContextServer))
.separator();
if let Some(usage) = last_usage {
menu = menu
.header_with_link("Prompt Usage", "Manage", account_url.clone())
.custom_entry(
move |_window, cx| {
let used_percentage = match usage.limit {
UsageLimit::Limited(limit) => {
Some((usage.amount as f32 / limit as f32) * 100.)
}
UsageLimit::Unlimited => None,
};
h_flex()
.flex_1()
.gap_1p5()
.children(used_percentage.map(|percent| {
ProgressBar::new("usage", percent, 100., cx)
}))
.child(
Label::new(match usage.limit {
UsageLimit::Limited(limit) => {
format!("{} / {limit}", usage.amount)
}
UsageLimit::Unlimited => {
format!("{} / ∞", usage.amount)
}
})
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
},
move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
)
.separator()
}
menu = menu
.action("Rules…", Box::new(OpenRulesLibrary::default()))
.action("Settings", Box::new(OpenConfiguration))
.action(zoom_in_label, Box::new(ToggleZoom));
menu
}))
});
h_flex()
.id("assistant-toolbar")
.h(Tab::container_height(cx))
.max_w_full()
.flex_none()
.justify_between()
.gap_2()
.bg(cx.theme().colors().tab_bar_background)
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
h_flex()
.size_full()
.pl_1()
.gap_1()
.child(match &self.active_view {
ActiveView::History | ActiveView::Configuration => go_back_button,
_ => recent_entries_menu,
})
.child(self.render_title_view(window, cx)),
)
.child(
h_flex()
.h_full()
.gap_2()
.when(show_token_count, |parent| {
parent.children(self.render_token_count(&thread, cx))
})
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base02.rems(cx))
.px(DynamicSpacing::Base08.rems(cx))
.border_l_1()
.border_color(cx.theme().colors().border)
.child(
IconButton::new("new", IconName::Plus)
.icon_size(IconSize::Small)
.style(ButtonStyle::Subtle)
.tooltip(move |window, cx| {
Tooltip::for_action_in(
"New Thread",
&NewThread::default(),
&focus_handle,
window,
cx,
)
})
.on_click(move |_event, window, cx| {
window.dispatch_action(
NewThread::default().boxed_clone(),
cx,
);
}),
)
.child(agent_extra_menu),
),
)
}
fn render_token_count(&self, thread: &Thread, cx: &App) -> Option<AnyElement> {
let is_generating = thread.is_generating();
let message_editor = self.message_editor.read(cx);
let conversation_token_usage = thread.total_token_usage()?;
let (total_token_usage, is_estimating) = if let Some((editing_message_id, unsent_tokens)) =
self.thread.read(cx).editing_message_id()
{
let combined = thread
.token_usage_up_to_message(editing_message_id)
.add(unsent_tokens);
(combined, unsent_tokens > 0)
} else {
let unsent_tokens = message_editor.last_estimated_token_count().unwrap_or(0);
let combined = conversation_token_usage.add(unsent_tokens);
(combined, unsent_tokens > 0)
};
let is_waiting_to_update_token_count = message_editor.is_waiting_to_update_token_count();
match &self.active_view {
ActiveView::Thread { .. } => {
if total_token_usage.total == 0 {
return None;
}
let token_color = match total_token_usage.ratio() {
TokenUsageRatio::Normal if is_estimating => Color::Default,
TokenUsageRatio::Normal => Color::Muted,
TokenUsageRatio::Warning => Color::Warning,
TokenUsageRatio::Exceeded => Color::Error,
};
let token_count = h_flex()
.id("token-count")
.flex_shrink_0()
.gap_0p5()
.when(!is_generating && is_estimating, |parent| {
parent
.child(
h_flex()
.mr_1()
.size_2p5()
.justify_center()
.rounded_full()
.bg(cx.theme().colors().text.opacity(0.1))
.child(
div().size_1().rounded_full().bg(cx.theme().colors().text),
),
)
.tooltip(move |window, cx| {
Tooltip::with_meta(
"Estimated New Token Count",
None,
format!(
"Current Conversation Tokens: {}",
humanize_token_count(conversation_token_usage.total)
),
window,
cx,
)
})
})
.child(
Label::new(humanize_token_count(total_token_usage.total))
.size(LabelSize::Small)
.color(token_color)
.map(|label| {
if is_generating || is_waiting_to_update_token_count {
label
.with_animation(
"used-tokens-label",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.6, 1.)),
|label, delta| label.alpha(delta),
)
.into_any()
} else {
label.into_any_element()
}
}),
)
.child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
.child(
Label::new(humanize_token_count(total_token_usage.max))
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any();
Some(token_count)
}
ActiveView::TextThread { context_editor, .. } => {
let element = render_remaining_tokens(context_editor, cx)?;
Some(element.into_any_element())
}
_ => None,
}
}
fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
if TrialEndUpsell::dismissed() {
return false;
}
let plan = self.user_store.read(cx).current_plan();
let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
matches!(plan, Some(Plan::Free)) && has_previous_trial
}
fn should_render_upsell(&self, cx: &mut Context<Self>) -> bool {
if !matches!(self.active_view, ActiveView::Thread { .. }) {
return false;
}
if self.hide_upsell || Upsell::dismissed() {
return false;
}
let is_using_zed_provider = self
.thread
.read(cx)
.thread()
.read(cx)
.configured_model()
.map_or(false, |model| {
model.provider.id().0 == ZED_CLOUD_PROVIDER_ID
});
if !is_using_zed_provider {
return false;
}
let plan = self.user_store.read(cx).current_plan();
if matches!(plan, Some(Plan::ZedPro | Plan::ZedProTrial)) {
return false;
}
let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
if has_previous_trial {
return false;
}
true
}
fn render_upsell(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !self.should_render_upsell(cx) {
return None;
}
if self.user_store.read(cx).account_too_young() {
Some(self.render_young_account_upsell(cx).into_any_element())
} else {
Some(self.render_trial_upsell(cx).into_any_element())
}
}
fn render_young_account_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
ToggleState::Unselected,
move |toggle_state, _window, cx| {
let toggle_state_bool = toggle_state.selected();
Upsell::set_dismissed(toggle_state_bool, cx);
},
);
let contents = div()
.size_full()
.gap_2()
.flex()
.flex_col()
.child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small))
.child(
Label::new("Your GitHub account was created less than 30 days ago, so we can't offer you a free trial.")
.size(LabelSize::Small),
)
.child(
Label::new(
"Use your own API keys, upgrade to Zed Pro or send an email to billing-support@zed.dev.",
)
.color(Color::Muted),
)
.child(
h_flex()
.w_full()
.px_neg_1()
.justify_between()
.items_center()
.child(h_flex().items_center().gap_1().child(checkbox))
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "Not Now")
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click({
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |this, cx| {
this.hide_upsell = true;
cx.notify();
});
}
}),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.style(ButtonStyle::Transparent)
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
),
),
);
self.render_upsell_container(cx, contents)
}
fn render_trial_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
ToggleState::Unselected,
move |toggle_state, _window, cx| {
let toggle_state_bool = toggle_state.selected();
Upsell::set_dismissed(toggle_state_bool, cx);
},
);
let contents = div()
.size_full()
.gap_2()
.flex()
.flex_col()
.child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small))
.child(
Label::new("Try Zed Pro for free for 14 days - no credit card required.")
.size(LabelSize::Small),
)
.child(
Label::new(
"Use your own API keys or enable usage-based billing once you hit the cap.",
)
.color(Color::Muted),
)
.child(
h_flex()
.w_full()
.px_neg_1()
.justify_between()
.items_center()
.child(h_flex().items_center().gap_1().child(checkbox))
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "Not Now")
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click({
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |this, cx| {
this.hide_upsell = true;
cx.notify();
});
}
}),
)
.child(
Button::new("cta-button", "Start Trial")
.style(ButtonStyle::Transparent)
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
),
),
);
self.render_upsell_container(cx, contents)
}
fn render_trial_end_upsell(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !self.should_render_trial_end_upsell(cx) {
return None;
}
Some(
self.render_upsell_container(
cx,
div()
.size_full()
.gap_2()
.flex()
.flex_col()
.child(
Headline::new("Your Zed Pro trial has expired.").size(HeadlineSize::Small),
)
.child(
Label::new("You've been automatically reset to the free plan.")
.size(LabelSize::Small),
)
.child(
h_flex()
.w_full()
.px_neg_1()
.justify_between()
.items_center()
.child(div())
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "Stay on Free")
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click({
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |_this, cx| {
TrialEndUpsell::set_dismissed(true, cx);
cx.notify();
});
}
}),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.style(ButtonStyle::Transparent)
.on_click(|_, _, cx| {
cx.open_url(&zed_urls::account_url(cx))
}),
),
),
),
),
)
}
fn render_upsell_container(&self, cx: &mut Context<Self>, content: Div) -> Div {
div().p_2().child(
v_flex()
.w_full()
.elevation_2(cx)
.rounded(px(8.))
.bg(cx.theme().colors().background.alpha(0.5))
.p(px(3.))
.child(
div()
.gap_2()
.flex()
.flex_col()
.size_full()
.border_1()
.rounded(px(5.))
.border_color(cx.theme().colors().text.alpha(0.1))
.overflow_hidden()
.relative()
.bg(cx.theme().colors().panel_background)
.px_4()
.py_3()
.child(
div()
.absolute()
.top_0()
.right(px(-1.0))
.w(px(441.))
.h(px(167.))
.child(
Vector::new(
VectorName::Grid,
rems_from_px(441.),
rems_from_px(167.),
)
.color(ui::Color::Custom(cx.theme().colors().text.alpha(0.1))),
),
)
.child(
div()
.absolute()
.top(px(-8.0))
.right_0()
.w(px(400.))
.h(px(92.))
.child(
Vector::new(
VectorName::AiGrid,
rems_from_px(400.),
rems_from_px(92.),
)
.color(ui::Color::Custom(cx.theme().colors().text.alpha(0.32))),
),
)
// .child(
// div()
// .absolute()
// .top_0()
// .right(px(360.))
// .size(px(401.))
// .overflow_hidden()
// .bg(cx.theme().colors().panel_background)
// )
.child(
div()
.absolute()
.top_0()
.right_0()
.w(px(660.))
.h(px(401.))
.overflow_hidden()
.bg(linear_gradient(
75.,
linear_color_stop(
cx.theme().colors().panel_background.alpha(0.01),
1.0,
),
linear_color_stop(cx.theme().colors().panel_background, 0.45),
)),
)
.child(content),
),
)
}
fn render_active_thread_or_empty_state(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
if self.thread.read(cx).is_empty() {
return self
.render_thread_empty_state(window, cx)
.into_any_element();
}
self.thread.clone().into_any_element()
}
fn configuration_error(&self, cx: &App) -> Option<ConfigurationError> {
let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
return Some(ConfigurationError::NoProvider);
};
if !model.provider.is_authenticated(cx) {
return Some(ConfigurationError::ProviderNotAuthenticated);
}
if model.provider.must_accept_terms(cx) {
return Some(ConfigurationError::ProviderPendingTermsAcceptance(
model.provider,
));
}
None
}
fn render_thread_empty_state(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let recent_history = self
.history_store
.update(cx, |this, cx| this.recent_entries(6, cx));
let configuration_error = self.configuration_error(cx);
let no_error = configuration_error.is_none();
let focus_handle = self.focus_handle(cx);
v_flex()
.size_full()
.bg(cx.theme().colors().panel_background)
.when(recent_history.is_empty(), |this| {
let configuration_error_ref = &configuration_error;
this.child(
v_flex()
.size_full()
.max_w_80()
.mx_auto()
.justify_center()
.items_center()
.gap_1()
.child(
h_flex().child(
Headline::new("Welcome to the Agent Panel")
),
)
.when(no_error, |parent| {
parent
.child(
h_flex().child(
Label::new("Ask and build anything.")
.color(Color::Muted)
.mb_2p5(),
),
)
.child(
Button::new("new-thread", "Start New Thread")
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&NewThread::default(),
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(NewThread::default().boxed_clone(), cx)
}),
)
.child(
Button::new("context", "Add Context")
.icon(IconName::FileCode)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&ToggleContextPicker,
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(ToggleContextPicker.boxed_clone(), cx)
}),
)
.child(
Button::new("mode", "Switch Model")
.icon(IconName::DatabaseZap)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&ToggleModelSelector,
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(ToggleModelSelector.boxed_clone(), cx)
}),
)
.child(
Button::new("settings", "View Settings")
.icon(IconName::Settings)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&OpenConfiguration,
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(OpenConfiguration.boxed_clone(), cx)
}),
)
})
.map(|parent| {
match configuration_error_ref {
Some(ConfigurationError::ProviderNotAuthenticated)
| Some(ConfigurationError::NoProvider) => {
parent
.child(
h_flex().child(
Label::new("To start using the agent, configure at least one LLM provider.")
.color(Color::Muted)
.mb_2p5()
)
)
.child(
Button::new("settings", "Configure a Provider")
.icon(IconName::Settings)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&OpenConfiguration,
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(OpenConfiguration.boxed_clone(), cx)
}),
)
}
Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => {
parent.children(
provider.render_accept_terms(
LanguageModelProviderTosView::ThreadFreshStart,
cx,
),
)
}
None => parent,
}
})
)
})
.when(!recent_history.is_empty(), |parent| {
let focus_handle = focus_handle.clone();
let configuration_error_ref = &configuration_error;
parent
.overflow_hidden()
.p_1p5()
.justify_end()
.gap_1()
.child(
h_flex()
.pl_1p5()
.pb_1()
.w_full()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(
Label::new("Recent")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Button::new("view-history", "View All")
.style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&OpenHistory,
&self.focus_handle(cx),
window,
cx,
).map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(move |_event, window, cx| {
window.dispatch_action(OpenHistory.boxed_clone(), cx);
}),
),
)
.child(
v_flex()
.gap_1()
.children(
recent_history.into_iter().enumerate().map(|(index, entry)| {
// TODO: Add keyboard navigation.
let is_hovered = self.hovered_recent_history_item == Some(index);
HistoryEntryElement::new(entry.clone(), cx.entity().downgrade())
.hovered(is_hovered)
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_recent_history_item = Some(index);
} else if this.hovered_recent_history_item == Some(index) {
this.hovered_recent_history_item = None;
}
cx.notify();
}))
.into_any_element()
}),
)
)
.map(|parent| {
match configuration_error_ref {
Some(ConfigurationError::ProviderNotAuthenticated)
| Some(ConfigurationError::NoProvider) => {
parent
.child(
Banner::new()
.severity(ui::Severity::Warning)
.child(
Label::new(
"Configure at least one LLM provider to start using the panel.",
)
.size(LabelSize::Small),
)
.action_slot(
Button::new("settings", "Configure Provider")
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&OpenConfiguration,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_event, window, cx| {
window.dispatch_action(
OpenConfiguration.boxed_clone(),
cx,
)
}),
),
)
}
Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => {
parent
.child(
Banner::new()
.severity(ui::Severity::Warning)
.child(
h_flex()
.w_full()
.children(
provider.render_accept_terms(
LanguageModelProviderTosView::ThreadtEmptyState,
cx,
),
),
),
)
}
None => parent,
}
})
})
}
fn render_tool_use_limit_reached(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let tool_use_limit_reached = self
.thread
.read(cx)
.thread()
.read(cx)
.tool_use_limit_reached();
if !tool_use_limit_reached {
return None;
}
let model = self
.thread
.read(cx)
.thread()
.read(cx)
.configured_model()?
.model;
let focus_handle = self.focus_handle(cx);
let banner = Banner::new()
.severity(ui::Severity::Info)
.child(Label::new("Consecutive tool use limit reached.").size(LabelSize::Small))
.action_slot(
h_flex()
.gap_1()
.child(
Button::new("continue-conversation", "Continue")
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&ContinueThread,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.continue_conversation(window, cx);
})),
)
.when(model.supports_max_mode(), |this| {
this.child(
Button::new("continue-burn-mode", "Continue with Burn Mode")
.style(ButtonStyle::Filled)
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&ContinueWithBurnMode,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use."))
.on_click(cx.listener(|this, _, window, cx| {
this.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Burn);
});
});
this.continue_conversation(window, cx);
})),
)
}),
);
Some(div().px_2().pb_2().child(banner).into_any_element())
}
fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let last_error = self.thread.read(cx).last_error()?;
Some(
div()
.absolute()
.right_3()
.bottom_12()
.max_w_96()
.py_2()
.px_3()
.elevation_2(cx)
.occlude()
.child(match last_error {
ThreadError::PaymentRequired => self.render_payment_required_error(cx),
ThreadError::ModelRequestLimitReached { plan } => {
self.render_model_request_limit_reached_error(plan, cx)
}
ThreadError::Message { header, message } => {
self.render_error_message(header, message, cx)
}
})
.into_any(),
)
}
fn render_payment_required_error(&self, cx: &mut Context<Self>) -> AnyElement {
const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used.";
v_flex()
.gap_0p5()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(Icon::new(IconName::XCircle).color(Color::Error))
.child(Label::new("Free Usage Exceeded").weight(FontWeight::MEDIUM)),
)
.child(
div()
.id("error-message")
.max_h_24()
.overflow_y_scroll()
.child(Label::new(ERROR_MESSAGE)),
)
.child(
h_flex()
.justify_end()
.mt_1()
.gap_1()
.child(self.create_copy_button(ERROR_MESSAGE))
.child(Button::new("subscribe", "Subscribe").on_click(cx.listener(
|this, _, _, cx| {
this.thread.update(cx, |this, _cx| {
this.clear_last_error();
});
cx.open_url(&zed_urls::account_url(cx));
cx.notify();
},
)))
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
|this, _, _, cx| {
this.thread.update(cx, |this, _cx| {
this.clear_last_error();
});
cx.notify();
},
))),
)
.into_any()
}
fn render_model_request_limit_reached_error(
&self,
plan: Plan,
cx: &mut Context<Self>,
) -> AnyElement {
let error_message = match plan {
Plan::ZedPro => {
"Model request limit reached. Upgrade to usage-based billing for more requests."
}
Plan::ZedProTrial => {
"Model request limit reached. Upgrade to Zed Pro for more requests."
}
Plan::Free => "Model request limit reached. Upgrade to Zed Pro for more requests.",
};
let call_to_action = match plan {
Plan::ZedPro => "Upgrade to usage-based billing",
Plan::ZedProTrial => "Upgrade to Zed Pro",
Plan::Free => "Upgrade to Zed Pro",
};
v_flex()
.gap_0p5()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(Icon::new(IconName::XCircle).color(Color::Error))
.child(Label::new("Model Request Limit Reached").weight(FontWeight::MEDIUM)),
)
.child(
div()
.id("error-message")
.max_h_24()
.overflow_y_scroll()
.child(Label::new(error_message)),
)
.child(
h_flex()
.justify_end()
.mt_1()
.gap_1()
.child(self.create_copy_button(error_message))
.child(
Button::new("subscribe", call_to_action).on_click(cx.listener(
|this, _, _, cx| {
this.thread.update(cx, |this, _cx| {
this.clear_last_error();
});
cx.open_url(&zed_urls::account_url(cx));
cx.notify();
},
)),
)
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
|this, _, _, cx| {
this.thread.update(cx, |this, _cx| {
this.clear_last_error();
});
cx.notify();
},
))),
)
.into_any()
}
fn render_error_message(
&self,
header: SharedString,
message: SharedString,
cx: &mut Context<Self>,
) -> AnyElement {
let message_with_header = format!("{}\n{}", header, message);
v_flex()
.gap_0p5()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(Icon::new(IconName::XCircle).color(Color::Error))
.child(Label::new(header).weight(FontWeight::MEDIUM)),
)
.child(
div()
.id("error-message")
.max_h_32()
.overflow_y_scroll()
.child(Label::new(message.clone())),
)
.child(
h_flex()
.justify_end()
.mt_1()
.gap_1()
.child(self.create_copy_button(message_with_header))
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
|this, _, _, cx| {
this.thread.update(cx, |this, _cx| {
this.clear_last_error();
});
cx.notify();
},
))),
)
.into_any()
}
fn render_prompt_editor(
&self,
context_editor: &Entity<ContextEditor>,
buffer_search_bar: &Entity<BufferSearchBar>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Div {
let mut registrar = buffer_search::DivRegistrar::new(
|this, _, _cx| match &this.active_view {
ActiveView::TextThread {
buffer_search_bar, ..
} => Some(buffer_search_bar.clone()),
_ => None,
},
cx,
);
BufferSearchBar::register(&mut registrar);
registrar
.into_div()
.size_full()
.relative()
.map(|parent| {
buffer_search_bar.update(cx, |buffer_search_bar, cx| {
if buffer_search_bar.is_dismissed() {
return parent;
}
parent.child(
div()
.p(DynamicSpacing::Base08.rems(cx))
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().editor_background)
.child(buffer_search_bar.render(window, cx)),
)
})
})
.child(context_editor.clone())
.child(self.render_drag_target(cx))
}
fn render_drag_target(&self, cx: &Context<Self>) -> Div {
let is_local = self.project.read(cx).is_local();
div()
.invisible()
.absolute()
.top_0()
.right_0()
.bottom_0()
.left_0()
.bg(cx.theme().colors().drop_target_background)
.drag_over::<DraggedTab>(|this, _, _, _| this.visible())
.drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
.when(is_local, |this| {
this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
})
.on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
let item = tab.pane.read(cx).item_for_index(tab.ix);
let project_paths = item
.and_then(|item| item.project_path(cx))
.into_iter()
.collect::<Vec<_>>();
this.handle_drop(project_paths, vec![], window, cx);
}))
.on_drop(
cx.listener(move |this, selection: &DraggedSelection, window, cx| {
let project_paths = selection
.items()
.filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
.collect::<Vec<_>>();
this.handle_drop(project_paths, vec![], window, cx);
}),
)
.on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
let tasks = paths
.paths()
.into_iter()
.map(|path| {
Workspace::project_path_for_path(this.project.clone(), &path, false, cx)
})
.collect::<Vec<_>>();
cx.spawn_in(window, async move |this, cx| {
let mut paths = vec![];
let mut added_worktrees = vec![];
let opened_paths = futures::future::join_all(tasks).await;
for entry in opened_paths {
if let Some((worktree, project_path)) = entry.log_err() {
added_worktrees.push(worktree);
paths.push(project_path);
}
}
this.update_in(cx, |this, window, cx| {
this.handle_drop(paths, added_worktrees, window, cx);
})
.ok();
})
.detach();
}))
}
fn handle_drop(
&mut self,
paths: Vec<ProjectPath>,
added_worktrees: Vec<Entity<Worktree>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match &self.active_view {
ActiveView::Thread { .. } => {
let context_store = self.thread.read(cx).context_store().clone();
context_store.update(cx, move |context_store, cx| {
let mut tasks = Vec::new();
for project_path in &paths {
tasks.push(context_store.add_file_from_path(
project_path.clone(),
false,
cx,
));
}
cx.background_spawn(async move {
futures::future::join_all(tasks).await;
// Need to hold onto the worktrees until they have already been used when
// opening the buffers.
drop(added_worktrees);
})
.detach();
});
}
ActiveView::TextThread { context_editor, .. } => {
context_editor.update(cx, |context_editor, cx| {
ContextEditor::insert_dragged_files(
context_editor,
paths,
added_worktrees,
window,
cx,
);
});
}
ActiveView::History | ActiveView::Configuration => {}
}
}
fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
let message = message.into();
IconButton::new("copy", IconName::Copy)
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
})
.tooltip(Tooltip::text("Copy Error Message"))
}
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
if matches!(self.active_view, ActiveView::TextThread { .. }) {
key_context.add("prompt_editor");
}
key_context
}
}
impl Render for AgentPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// WARNING: Changes to this element hierarchy can have
// non-obvious implications to the layout of children.
//
// If you need to change it, please confirm:
// - The message editor expands (cmd-option-esc) correctly
// - When expanded, the buttons at the bottom of the panel are displayed correctly
// - Font size works as expected and can be changed with cmd-+/cmd-
// - Scrolling in all views works as expected
// - Files can be dropped into the panel
let content = v_flex()
.key_context(self.key_context())
.justify_between()
.size_full()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
}))
.on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
this.open_history(window, cx);
}))
.on_action(cx.listener(|this, _: &OpenConfiguration, window, cx| {
this.open_configuration(window, cx);
}))
.on_action(cx.listener(Self::open_active_thread_as_markdown))
.on_action(cx.listener(Self::deploy_rules_library))
.on_action(cx.listener(Self::open_agent_diff))
.on_action(cx.listener(Self::go_back))
.on_action(cx.listener(Self::toggle_navigation_menu))
.on_action(cx.listener(Self::toggle_options_menu))
.on_action(cx.listener(Self::increase_font_size))
.on_action(cx.listener(Self::decrease_font_size))
.on_action(cx.listener(Self::reset_font_size))
.on_action(cx.listener(Self::toggle_zoom))
.on_action(cx.listener(|this, _: &ContinueThread, window, cx| {
this.continue_conversation(window, cx);
}))
.on_action(cx.listener(|this, _: &ContinueWithBurnMode, window, cx| {
this.thread.update(cx, |active_thread, cx| {
active_thread.thread().update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Burn);
});
});
this.continue_conversation(window, cx);
}))
.on_action(cx.listener(Self::toggle_burn_mode))
.child(self.render_toolbar(window, cx))
.children(self.render_upsell(window, cx))
.children(self.render_trial_end_upsell(window, cx))
.map(|parent| match &self.active_view {
ActiveView::Thread { .. } => parent
.relative()
.child(self.render_active_thread_or_empty_state(window, cx))
.children(self.render_tool_use_limit_reached(window, cx))
.child(h_flex().child(self.message_editor.clone()))
.children(self.render_last_error(cx))
.child(self.render_drag_target(cx)),
ActiveView::History => parent.child(self.history.clone()),
ActiveView::TextThread {
context_editor,
buffer_search_bar,
..
} => parent.child(self.render_prompt_editor(
context_editor,
buffer_search_bar,
window,
cx,
)),
ActiveView::Configuration => parent.children(self.configuration.clone()),
});
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
WithRemSize::new(ThemeSettings::get_global(cx).agent_font_size(cx))
.size_full()
.child(content)
.into_any()
}
_ => content.into_any(),
}
}
}
struct PromptLibraryInlineAssist {
workspace: WeakEntity<Workspace>,
}
impl PromptLibraryInlineAssist {
pub fn new(workspace: WeakEntity<Workspace>) -> Self {
Self { workspace }
}
}
impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
fn assist(
&self,
prompt_editor: &Entity<Editor>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut Context<RulesLibrary>,
) {
InlineAssistant::update_global(cx, |assistant, cx| {
let Some(project) = self
.workspace
.upgrade()
.map(|workspace| workspace.read(cx).project().downgrade())
else {
return;
};
let prompt_store = None;
let thread_store = None;
let text_thread_store = None;
let context_store = cx.new(|_| ContextStore::new(project.clone(), None));
assistant.assist(
&prompt_editor,
self.workspace.clone(),
context_store,
project,
prompt_store,
thread_store,
text_thread_store,
initial_prompt,
window,
cx,
)
})
}
fn focus_agent_panel(
&self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
workspace.focus_panel::<AgentPanel>(window, cx).is_some()
}
}
pub struct ConcreteAssistantPanelDelegate;
impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
fn active_context_editor(
&self,
workspace: &mut Workspace,
_window: &mut Window,
cx: &mut Context<Workspace>,
) -> Option<Entity<ContextEditor>> {
let panel = workspace.panel::<AgentPanel>(cx)?;
panel.read(cx).active_context_editor()
}
fn open_saved_context(
&self,
workspace: &mut Workspace,
path: Arc<Path>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<Result<()>> {
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return Task::ready(Err(anyhow!("Agent panel not found")));
};
panel.update(cx, |panel, cx| {
panel.open_saved_prompt_editor(path, window, cx)
})
}
fn open_remote_context(
&self,
_workspace: &mut Workspace,
_context_id: assistant_context_editor::ContextId,
_window: &mut Window,
_cx: &mut Context<Workspace>,
) -> Task<Result<Entity<ContextEditor>>> {
Task::ready(Err(anyhow!("opening remote context not implemented")))
}
fn quote_selection(
&self,
workspace: &mut Workspace,
selection_ranges: Vec<Range<Anchor>>,
buffer: Entity<MultiBuffer>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return;
};
if !panel.focus_handle(cx).contains_focused(window, cx) {
workspace.toggle_panel_focus::<AgentPanel>(window, cx);
}
panel.update(cx, |_, cx| {
// Wait to create a new context until the workspace is no longer
// being updated.
cx.defer_in(window, move |panel, window, cx| {
if panel.has_active_thread() {
panel.message_editor.update(cx, |message_editor, cx| {
message_editor.context_store().update(cx, |store, cx| {
let buffer = buffer.read(cx);
let selection_ranges = selection_ranges
.into_iter()
.flat_map(|range| {
let (start_buffer, start) =
buffer.text_anchor_for_position(range.start, cx)?;
let (end_buffer, end) =
buffer.text_anchor_for_position(range.end, cx)?;
if start_buffer != end_buffer {
return None;
}
Some((start_buffer, start..end))
})
.collect::<Vec<_>>();
for (buffer, range) in selection_ranges {
store.add_selection(buffer, range, cx);
}
})
})
} else if let Some(context_editor) = panel.active_context_editor() {
let snapshot = buffer.read(cx).snapshot(cx);
let selection_ranges = selection_ranges
.into_iter()
.map(|range| range.to_point(&snapshot))
.collect::<Vec<_>>();
context_editor.update(cx, |context_editor, cx| {
context_editor.quote_ranges(selection_ranges, snapshot, window, cx)
});
}
});
});
}
}
struct Upsell;
impl Dismissable for Upsell {
const KEY: &'static str = "dismissed-trial-upsell";
}
struct TrialEndUpsell;
impl Dismissable for TrialEndUpsell {
const KEY: &'static str = "dismissed-trial-end-upsell";
}