1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
3 AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent,
4 ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::{ActionLog, ActionLogTelemetry};
8use agent::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
9use agent_client_protocol::{self as acp, PromptCapabilities};
10use agent_servers::{AgentServer, AgentServerDelegate};
11use agent_settings::{AgentProfileId, AgentSettings, CompletionMode};
12use anyhow::{Result, anyhow};
13use arrayvec::ArrayVec;
14use audio::{Audio, Sound};
15use buffer_diff::BufferDiff;
16use client::zed_urls;
17use cloud_llm_client::PlanV1;
18use collections::{HashMap, HashSet};
19use editor::scroll::Autoscroll;
20use editor::{
21 Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
22};
23use file_icons::FileIcons;
24use fs::Fs;
25use futures::FutureExt as _;
26use gpui::{
27 Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, CursorStyle,
28 EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
29 ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task, TextStyle,
30 TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div, ease_in_out,
31 linear_color_stop, linear_gradient, list, point, pulsating_between,
32};
33use language::Buffer;
34
35use language_model::LanguageModelRegistry;
36use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
37use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
38use prompt_store::{PromptId, PromptStore};
39use rope::Point;
40use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
41use std::cell::RefCell;
42use std::path::Path;
43use std::sync::Arc;
44use std::time::Instant;
45use std::{collections::BTreeMap, rc::Rc, time::Duration};
46use terminal_view::terminal_panel::TerminalPanel;
47use text::Anchor;
48use theme::{AgentFontSize, ThemeSettings};
49use ui::{
50 Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, Disclosure, Divider,
51 DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip,
52 WithScrollbar, prelude::*, right_click_menu,
53};
54use util::{ResultExt, size::format_file_size, time::duration_alt_display};
55use workspace::{CollaboratorId, NewTerminal, Workspace};
56use zed_actions::agent::{Chat, ToggleModelSelector};
57use zed_actions::assistant::OpenRulesLibrary;
58
59use super::config_options::ConfigOptionsView;
60use super::entry_view_state::EntryViewState;
61use crate::acp::AcpModelSelectorPopover;
62use crate::acp::ModeSelector;
63use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
64use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
65use crate::agent_diff::AgentDiff;
66use crate::profile_selector::{ProfileProvider, ProfileSelector};
67
68use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
69use crate::{
70 AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ClearMessageQueue, ContinueThread,
71 ContinueWithBurnMode, CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow,
72 KeepAll, NewThread, OpenAgentDiff, OpenHistory, QueueMessage, RejectAll, RejectOnce,
73 SendNextQueuedMessage, ToggleBurnMode, ToggleProfileSelector,
74};
75
76#[derive(Copy, Clone, Debug, PartialEq, Eq)]
77enum ThreadFeedback {
78 Positive,
79 Negative,
80}
81
82#[derive(Debug)]
83enum ThreadError {
84 PaymentRequired,
85 ModelRequestLimitReached(cloud_llm_client::Plan),
86 ToolUseLimitReached,
87 Refusal,
88 AuthenticationRequired(SharedString),
89 Other(SharedString),
90}
91
92impl ThreadError {
93 fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
94 if error.is::<language_model::PaymentRequiredError>() {
95 Self::PaymentRequired
96 } else if error.is::<language_model::ToolUseLimitReachedError>() {
97 Self::ToolUseLimitReached
98 } else if let Some(error) =
99 error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
100 {
101 Self::ModelRequestLimitReached(error.plan)
102 } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
103 && acp_error.code == acp::ErrorCode::AuthRequired
104 {
105 Self::AuthenticationRequired(acp_error.message.clone().into())
106 } else {
107 let string = format!("{:#}", error);
108 // TODO: we should have Gemini return better errors here.
109 if agent.clone().downcast::<agent_servers::Gemini>().is_some()
110 && string.contains("Could not load the default credentials")
111 || string.contains("API key not valid")
112 || string.contains("Request had invalid authentication credentials")
113 {
114 Self::AuthenticationRequired(string.into())
115 } else {
116 Self::Other(string.into())
117 }
118 }
119 }
120}
121
122impl ProfileProvider for Entity<agent::Thread> {
123 fn profile_id(&self, cx: &App) -> AgentProfileId {
124 self.read(cx).profile().clone()
125 }
126
127 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
128 self.update(cx, |thread, cx| {
129 // Apply the profile and let the thread swap to its default model.
130 thread.set_profile(profile_id, cx);
131 });
132 }
133
134 fn profiles_supported(&self, cx: &App) -> bool {
135 self.read(cx)
136 .model()
137 .is_some_and(|model| model.supports_tools())
138 }
139}
140
141#[derive(Default)]
142struct ThreadFeedbackState {
143 feedback: Option<ThreadFeedback>,
144 comments_editor: Option<Entity<Editor>>,
145}
146
147impl ThreadFeedbackState {
148 pub fn submit(
149 &mut self,
150 thread: Entity<AcpThread>,
151 feedback: ThreadFeedback,
152 window: &mut Window,
153 cx: &mut App,
154 ) {
155 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
156 return;
157 };
158
159 if self.feedback == Some(feedback) {
160 return;
161 }
162
163 self.feedback = Some(feedback);
164 match feedback {
165 ThreadFeedback::Positive => {
166 self.comments_editor = None;
167 }
168 ThreadFeedback::Negative => {
169 self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
170 }
171 }
172 let session_id = thread.read(cx).session_id().clone();
173 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
174 let task = telemetry.thread_data(&session_id, cx);
175 let rating = match feedback {
176 ThreadFeedback::Positive => "positive",
177 ThreadFeedback::Negative => "negative",
178 };
179 cx.background_spawn(async move {
180 let thread = task.await?;
181 telemetry::event!(
182 "Agent Thread Rated",
183 agent = agent_telemetry_id,
184 session_id = session_id,
185 rating = rating,
186 thread = thread
187 );
188 anyhow::Ok(())
189 })
190 .detach_and_log_err(cx);
191 }
192
193 pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
194 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
195 return;
196 };
197
198 let Some(comments) = self
199 .comments_editor
200 .as_ref()
201 .map(|editor| editor.read(cx).text(cx))
202 .filter(|text| !text.trim().is_empty())
203 else {
204 return;
205 };
206
207 self.comments_editor.take();
208
209 let session_id = thread.read(cx).session_id().clone();
210 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
211 let task = telemetry.thread_data(&session_id, cx);
212 cx.background_spawn(async move {
213 let thread = task.await?;
214 telemetry::event!(
215 "Agent Thread Feedback Comments",
216 agent = agent_telemetry_id,
217 session_id = session_id,
218 comments = comments,
219 thread = thread
220 );
221 anyhow::Ok(())
222 })
223 .detach_and_log_err(cx);
224 }
225
226 pub fn clear(&mut self) {
227 *self = Self::default()
228 }
229
230 pub fn dismiss_comments(&mut self) {
231 self.comments_editor.take();
232 }
233
234 fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
235 let buffer = cx.new(|cx| {
236 let empty_string = String::new();
237 MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
238 });
239
240 let editor = cx.new(|cx| {
241 let mut editor = Editor::new(
242 editor::EditorMode::AutoHeight {
243 min_lines: 1,
244 max_lines: Some(4),
245 },
246 buffer,
247 None,
248 window,
249 cx,
250 );
251 editor.set_placeholder_text(
252 "What went wrong? Share your feedback so we can improve.",
253 window,
254 cx,
255 );
256 editor
257 });
258
259 editor.read(cx).focus_handle(cx).focus(window, cx);
260 editor
261 }
262}
263
264pub struct AcpThreadView {
265 agent: Rc<dyn AgentServer>,
266 agent_server_store: Entity<AgentServerStore>,
267 workspace: WeakEntity<Workspace>,
268 project: Entity<Project>,
269 thread_state: ThreadState,
270 login: Option<task::SpawnInTerminal>,
271 history_store: Entity<HistoryStore>,
272 hovered_recent_history_item: Option<usize>,
273 entry_view_state: Entity<EntryViewState>,
274 message_editor: Entity<MessageEditor>,
275 focus_handle: FocusHandle,
276 model_selector: Option<Entity<AcpModelSelectorPopover>>,
277 config_options_view: Option<Entity<ConfigOptionsView>>,
278 profile_selector: Option<Entity<ProfileSelector>>,
279 notifications: Vec<WindowHandle<AgentNotification>>,
280 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
281 thread_retry_status: Option<RetryStatus>,
282 thread_error: Option<ThreadError>,
283 thread_error_markdown: Option<Entity<Markdown>>,
284 token_limit_callout_dismissed: bool,
285 thread_feedback: ThreadFeedbackState,
286 list_state: ListState,
287 auth_task: Option<Task<()>>,
288 expanded_tool_calls: HashSet<acp::ToolCallId>,
289 expanded_thinking_blocks: HashSet<(usize, usize)>,
290 edits_expanded: bool,
291 plan_expanded: bool,
292 queue_expanded: bool,
293 editor_expanded: bool,
294 should_be_following: bool,
295 editing_message: Option<usize>,
296 prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
297 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
298 is_loading_contents: bool,
299 new_server_version_available: Option<SharedString>,
300 resume_thread_metadata: Option<DbThreadMetadata>,
301 _cancel_task: Option<Task<()>>,
302 _subscriptions: [Subscription; 5],
303 show_codex_windows_warning: bool,
304 in_flight_prompt: Option<Vec<acp::ContentBlock>>,
305 message_queue: Vec<QueuedMessage>,
306 skip_queue_processing_count: usize,
307 user_interrupted_generation: bool,
308}
309
310struct QueuedMessage {
311 content: Vec<acp::ContentBlock>,
312 tracked_buffers: Vec<Entity<Buffer>>,
313}
314
315enum ThreadState {
316 Loading(Entity<LoadingView>),
317 Ready {
318 thread: Entity<AcpThread>,
319 title_editor: Option<Entity<Editor>>,
320 mode_selector: Option<Entity<ModeSelector>>,
321 _subscriptions: Vec<Subscription>,
322 },
323 LoadError(LoadError),
324 Unauthenticated {
325 connection: Rc<dyn AgentConnection>,
326 description: Option<Entity<Markdown>>,
327 configuration_view: Option<AnyView>,
328 pending_auth_method: Option<acp::AuthMethodId>,
329 _subscription: Option<Subscription>,
330 },
331}
332
333struct LoadingView {
334 title: SharedString,
335 _load_task: Task<()>,
336 _update_title_task: Task<anyhow::Result<()>>,
337}
338
339impl AcpThreadView {
340 pub fn new(
341 agent: Rc<dyn AgentServer>,
342 resume_thread: Option<DbThreadMetadata>,
343 summarize_thread: Option<DbThreadMetadata>,
344 workspace: WeakEntity<Workspace>,
345 project: Entity<Project>,
346 history_store: Entity<HistoryStore>,
347 prompt_store: Option<Entity<PromptStore>>,
348 track_load_event: bool,
349 window: &mut Window,
350 cx: &mut Context<Self>,
351 ) -> Self {
352 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
353 let available_commands = Rc::new(RefCell::new(vec![]));
354
355 let agent_server_store = project.read(cx).agent_server_store().clone();
356 let agent_display_name = agent_server_store
357 .read(cx)
358 .agent_display_name(&ExternalAgentServerName(agent.name()))
359 .unwrap_or_else(|| agent.name());
360
361 let placeholder = placeholder_text(agent_display_name.as_ref(), false);
362
363 let message_editor = cx.new(|cx| {
364 let mut editor = MessageEditor::new(
365 workspace.clone(),
366 project.downgrade(),
367 history_store.clone(),
368 prompt_store.clone(),
369 prompt_capabilities.clone(),
370 available_commands.clone(),
371 agent.name(),
372 &placeholder,
373 editor::EditorMode::AutoHeight {
374 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
375 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
376 },
377 window,
378 cx,
379 );
380 if let Some(entry) = summarize_thread {
381 editor.insert_thread_summary(entry, window, cx);
382 }
383 editor
384 });
385
386 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
387
388 let entry_view_state = cx.new(|_| {
389 EntryViewState::new(
390 workspace.clone(),
391 project.downgrade(),
392 history_store.clone(),
393 prompt_store.clone(),
394 prompt_capabilities.clone(),
395 available_commands.clone(),
396 agent.name(),
397 )
398 });
399
400 let subscriptions = [
401 cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
402 cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
403 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
404 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
405 cx.subscribe_in(
406 &agent_server_store,
407 window,
408 Self::handle_agent_servers_updated,
409 ),
410 ];
411
412 cx.on_release(|this, cx| {
413 for window in this.notifications.drain(..) {
414 window
415 .update(cx, |_, window, _| {
416 window.remove_window();
417 })
418 .ok();
419 }
420 })
421 .detach();
422
423 let show_codex_windows_warning = cfg!(windows)
424 && project.read(cx).is_local()
425 && agent.clone().downcast::<agent_servers::Codex>().is_some();
426
427 Self {
428 agent: agent.clone(),
429 agent_server_store,
430 workspace: workspace.clone(),
431 project: project.clone(),
432 entry_view_state,
433 thread_state: Self::initial_state(
434 agent.clone(),
435 resume_thread.clone(),
436 workspace.clone(),
437 project.clone(),
438 track_load_event,
439 window,
440 cx,
441 ),
442 login: None,
443 message_editor,
444 model_selector: None,
445 config_options_view: None,
446 profile_selector: None,
447 notifications: Vec::new(),
448 notification_subscriptions: HashMap::default(),
449 list_state: list_state,
450 thread_retry_status: None,
451 thread_error: None,
452 thread_error_markdown: None,
453 token_limit_callout_dismissed: false,
454 thread_feedback: Default::default(),
455 auth_task: None,
456 expanded_tool_calls: HashSet::default(),
457 expanded_thinking_blocks: HashSet::default(),
458 editing_message: None,
459 edits_expanded: false,
460 plan_expanded: false,
461 queue_expanded: true,
462 prompt_capabilities,
463 available_commands,
464 editor_expanded: false,
465 should_be_following: false,
466 history_store,
467 hovered_recent_history_item: None,
468 is_loading_contents: false,
469 _subscriptions: subscriptions,
470 _cancel_task: None,
471 focus_handle: cx.focus_handle(),
472 new_server_version_available: None,
473 resume_thread_metadata: resume_thread,
474 show_codex_windows_warning,
475 in_flight_prompt: None,
476 message_queue: Vec::new(),
477 skip_queue_processing_count: 0,
478 user_interrupted_generation: false,
479 }
480 }
481
482 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
483 self.thread_state = Self::initial_state(
484 self.agent.clone(),
485 self.resume_thread_metadata.clone(),
486 self.workspace.clone(),
487 self.project.clone(),
488 true,
489 window,
490 cx,
491 );
492 self.available_commands.replace(vec![]);
493 self.new_server_version_available.take();
494 self.message_queue.clear();
495 cx.notify();
496 }
497
498 fn initial_state(
499 agent: Rc<dyn AgentServer>,
500 resume_thread: Option<DbThreadMetadata>,
501 workspace: WeakEntity<Workspace>,
502 project: Entity<Project>,
503 track_load_event: bool,
504 window: &mut Window,
505 cx: &mut Context<Self>,
506 ) -> ThreadState {
507 if project.read(cx).is_via_collab()
508 && agent.clone().downcast::<NativeAgentServer>().is_none()
509 {
510 return ThreadState::LoadError(LoadError::Other(
511 "External agents are not yet supported in shared projects.".into(),
512 ));
513 }
514 let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
515 // Pick the first non-single-file worktree for the root directory if there are any,
516 // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
517 worktrees.sort_by(|l, r| {
518 l.read(cx)
519 .is_single_file()
520 .cmp(&r.read(cx).is_single_file())
521 });
522 let root_dir = worktrees
523 .into_iter()
524 .filter_map(|worktree| {
525 if worktree.read(cx).is_single_file() {
526 Some(worktree.read(cx).abs_path().parent()?.into())
527 } else {
528 Some(worktree.read(cx).abs_path())
529 }
530 })
531 .next();
532 let (status_tx, mut status_rx) = watch::channel("Loading…".into());
533 let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
534 let delegate = AgentServerDelegate::new(
535 project.read(cx).agent_server_store().clone(),
536 project.clone(),
537 Some(status_tx),
538 Some(new_version_available_tx),
539 );
540
541 let connect_task = agent.connect(root_dir.as_deref(), delegate, cx);
542 let load_task = cx.spawn_in(window, async move |this, cx| {
543 let connection = match connect_task.await {
544 Ok((connection, login)) => {
545 this.update(cx, |this, _| this.login = login).ok();
546 connection
547 }
548 Err(err) => {
549 this.update_in(cx, |this, window, cx| {
550 if err.downcast_ref::<LoadError>().is_some() {
551 this.handle_load_error(err, window, cx);
552 } else {
553 this.handle_thread_error(err, cx);
554 }
555 cx.notify();
556 })
557 .log_err();
558 return;
559 }
560 };
561
562 if track_load_event {
563 telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
564 }
565
566 let result = if let Some(native_agent) = connection
567 .clone()
568 .downcast::<agent::NativeAgentConnection>()
569 && let Some(resume) = resume_thread.clone()
570 {
571 cx.update(|_, cx| {
572 native_agent
573 .0
574 .update(cx, |agent, cx| agent.open_thread(resume.id, cx))
575 })
576 .log_err()
577 } else {
578 let root_dir = root_dir.unwrap_or(paths::home_dir().as_path().into());
579 cx.update(|_, cx| {
580 connection
581 .clone()
582 .new_thread(project.clone(), &root_dir, cx)
583 })
584 .log_err()
585 };
586
587 let Some(result) = result else {
588 return;
589 };
590
591 let result = match result.await {
592 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
593 Ok(err) => {
594 cx.update(|window, cx| {
595 Self::handle_auth_required(this, err, agent, connection, window, cx)
596 })
597 .log_err();
598 return;
599 }
600 Err(err) => Err(err),
601 },
602 Ok(thread) => Ok(thread),
603 };
604
605 this.update_in(cx, |this, window, cx| {
606 match result {
607 Ok(thread) => {
608 let action_log = thread.read(cx).action_log().clone();
609
610 this.prompt_capabilities
611 .replace(thread.read(cx).prompt_capabilities());
612
613 let count = thread.read(cx).entries().len();
614 this.entry_view_state.update(cx, |view_state, cx| {
615 for ix in 0..count {
616 view_state.sync_entry(ix, &thread, window, cx);
617 }
618 this.list_state.splice_focusable(
619 0..0,
620 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
621 );
622 });
623
624 if let Some(resume) = resume_thread {
625 this.history_store.update(cx, |history, cx| {
626 history.push_recently_opened_entry(
627 HistoryEntryId::AcpThread(resume.id),
628 cx,
629 );
630 });
631 }
632
633 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
634
635 // Check for config options first
636 // Config options take precedence over legacy mode/model selectors
637 // (feature flag gating happens at the data layer)
638 let config_options_provider = thread
639 .read(cx)
640 .connection()
641 .session_config_options(thread.read(cx).session_id(), cx);
642
643 let mode_selector;
644 if let Some(config_options) = config_options_provider {
645 // Use config options - don't create mode_selector or model_selector
646 let agent_server = this.agent.clone();
647 let fs = this.project.read(cx).fs().clone();
648 this.config_options_view = Some(cx.new(|cx| {
649 ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
650 }));
651 this.model_selector = None;
652 mode_selector = None;
653 } else {
654 // Fall back to legacy mode/model selectors
655 this.config_options_view = None;
656 this.model_selector = thread
657 .read(cx)
658 .connection()
659 .model_selector(thread.read(cx).session_id())
660 .map(|selector| {
661 let agent_server = this.agent.clone();
662 let fs = this.project.read(cx).fs().clone();
663 cx.new(|cx| {
664 AcpModelSelectorPopover::new(
665 selector,
666 agent_server,
667 fs,
668 PopoverMenuHandle::default(),
669 this.focus_handle(cx),
670 window,
671 cx,
672 )
673 })
674 });
675
676 mode_selector = thread
677 .read(cx)
678 .connection()
679 .session_modes(thread.read(cx).session_id(), cx)
680 .map(|session_modes| {
681 let fs = this.project.read(cx).fs().clone();
682 let focus_handle = this.focus_handle(cx);
683 cx.new(|_cx| {
684 ModeSelector::new(
685 session_modes,
686 this.agent.clone(),
687 fs,
688 focus_handle,
689 )
690 })
691 });
692 }
693
694 let mut subscriptions = vec![
695 cx.subscribe_in(&thread, window, Self::handle_thread_event),
696 cx.observe(&action_log, |_, _, cx| cx.notify()),
697 ];
698
699 let title_editor =
700 if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
701 let editor = cx.new(|cx| {
702 let mut editor = Editor::single_line(window, cx);
703 editor.set_text(thread.read(cx).title(), window, cx);
704 editor
705 });
706 subscriptions.push(cx.subscribe_in(
707 &editor,
708 window,
709 Self::handle_title_editor_event,
710 ));
711 Some(editor)
712 } else {
713 None
714 };
715
716 this.thread_state = ThreadState::Ready {
717 thread,
718 title_editor,
719 mode_selector,
720 _subscriptions: subscriptions,
721 };
722
723 this.profile_selector = this.as_native_thread(cx).map(|thread| {
724 cx.new(|cx| {
725 ProfileSelector::new(
726 <dyn Fs>::global(cx),
727 Arc::new(thread.clone()),
728 this.focus_handle(cx),
729 cx,
730 )
731 })
732 });
733
734 if this.focus_handle(cx).is_focused(window) {
735 this.message_editor.focus_handle(cx).focus(window, cx);
736 }
737
738 cx.notify();
739 }
740 Err(err) => {
741 this.handle_load_error(err, window, cx);
742 }
743 };
744 })
745 .log_err();
746 });
747
748 cx.spawn(async move |this, cx| {
749 while let Ok(new_version) = new_version_available_rx.recv().await {
750 if let Some(new_version) = new_version {
751 this.update(cx, |this, cx| {
752 this.new_server_version_available = Some(new_version.into());
753 cx.notify();
754 })
755 .ok();
756 }
757 }
758 })
759 .detach();
760
761 let loading_view = cx.new(|cx| {
762 let update_title_task = cx.spawn(async move |this, cx| {
763 loop {
764 let status = status_rx.recv().await?;
765 this.update(cx, |this: &mut LoadingView, cx| {
766 this.title = status;
767 cx.notify();
768 })?;
769 }
770 });
771
772 LoadingView {
773 title: "Loading…".into(),
774 _load_task: load_task,
775 _update_title_task: update_title_task,
776 }
777 });
778
779 ThreadState::Loading(loading_view)
780 }
781
782 fn handle_auth_required(
783 this: WeakEntity<Self>,
784 err: AuthRequired,
785 agent: Rc<dyn AgentServer>,
786 connection: Rc<dyn AgentConnection>,
787 window: &mut Window,
788 cx: &mut App,
789 ) {
790 let agent_name = agent.name();
791 let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
792 let registry = LanguageModelRegistry::global(cx);
793
794 let sub = window.subscribe(®istry, cx, {
795 let provider_id = provider_id.clone();
796 let this = this.clone();
797 move |_, ev, window, cx| {
798 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
799 && &provider_id == updated_provider_id
800 && LanguageModelRegistry::global(cx)
801 .read(cx)
802 .provider(&provider_id)
803 .map_or(false, |provider| provider.is_authenticated(cx))
804 {
805 this.update(cx, |this, cx| {
806 this.reset(window, cx);
807 })
808 .ok();
809 }
810 }
811 });
812
813 let view = registry.read(cx).provider(&provider_id).map(|provider| {
814 provider.configuration_view(
815 language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
816 window,
817 cx,
818 )
819 });
820
821 (view, Some(sub))
822 } else {
823 (None, None)
824 };
825
826 this.update(cx, |this, cx| {
827 this.thread_state = ThreadState::Unauthenticated {
828 pending_auth_method: None,
829 connection,
830 configuration_view,
831 description: err
832 .description
833 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
834 _subscription: subscription,
835 };
836 if this.message_editor.focus_handle(cx).is_focused(window) {
837 this.focus_handle.focus(window, cx)
838 }
839 cx.notify();
840 })
841 .ok();
842 }
843
844 fn handle_load_error(
845 &mut self,
846 err: anyhow::Error,
847 window: &mut Window,
848 cx: &mut Context<Self>,
849 ) {
850 if let Some(load_err) = err.downcast_ref::<LoadError>() {
851 self.thread_state = ThreadState::LoadError(load_err.clone());
852 } else {
853 self.thread_state =
854 ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into()))
855 }
856 if self.message_editor.focus_handle(cx).is_focused(window) {
857 self.focus_handle.focus(window, cx)
858 }
859 cx.notify();
860 }
861
862 fn handle_agent_servers_updated(
863 &mut self,
864 _agent_server_store: &Entity<project::AgentServerStore>,
865 _event: &project::AgentServersUpdated,
866 window: &mut Window,
867 cx: &mut Context<Self>,
868 ) {
869 // If we're in a LoadError state OR have a thread_error set (which can happen
870 // when agent.connect() fails during loading), retry loading the thread.
871 // This handles the case where a thread is restored before authentication completes.
872 let should_retry =
873 matches!(&self.thread_state, ThreadState::LoadError(_)) || self.thread_error.is_some();
874
875 if should_retry {
876 self.thread_error = None;
877 self.thread_error_markdown = None;
878 self.reset(window, cx);
879 }
880 }
881
882 pub fn workspace(&self) -> &WeakEntity<Workspace> {
883 &self.workspace
884 }
885
886 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
887 match &self.thread_state {
888 ThreadState::Ready { thread, .. } => Some(thread),
889 ThreadState::Unauthenticated { .. }
890 | ThreadState::Loading { .. }
891 | ThreadState::LoadError { .. } => None,
892 }
893 }
894
895 pub fn mode_selector(&self) -> Option<&Entity<ModeSelector>> {
896 match &self.thread_state {
897 ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(),
898 ThreadState::Unauthenticated { .. }
899 | ThreadState::Loading { .. }
900 | ThreadState::LoadError { .. } => None,
901 }
902 }
903
904 pub fn title(&self, cx: &App) -> SharedString {
905 match &self.thread_state {
906 ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
907 ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
908 ThreadState::LoadError(error) => match error {
909 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
910 LoadError::FailedToInstall(_) => {
911 format!("Failed to Install {}", self.agent.name()).into()
912 }
913 LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
914 LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
915 },
916 }
917 }
918
919 pub fn title_editor(&self) -> Option<Entity<Editor>> {
920 if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
921 title_editor.clone()
922 } else {
923 None
924 }
925 }
926
927 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
928 self.thread_error.take();
929 self.thread_retry_status.take();
930
931 if let Some(thread) = self.thread() {
932 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
933 }
934 }
935
936 pub fn expand_message_editor(
937 &mut self,
938 _: &ExpandMessageEditor,
939 _window: &mut Window,
940 cx: &mut Context<Self>,
941 ) {
942 self.set_editor_is_expanded(!self.editor_expanded, cx);
943 cx.stop_propagation();
944 cx.notify();
945 }
946
947 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
948 self.editor_expanded = is_expanded;
949 self.message_editor.update(cx, |editor, cx| {
950 if is_expanded {
951 editor.set_mode(
952 EditorMode::Full {
953 scale_ui_elements_with_buffer_font_size: false,
954 show_active_line_background: false,
955 sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
956 },
957 cx,
958 )
959 } else {
960 let agent_settings = AgentSettings::get_global(cx);
961 editor.set_mode(
962 EditorMode::AutoHeight {
963 min_lines: agent_settings.message_editor_min_lines,
964 max_lines: Some(agent_settings.set_message_editor_max_lines()),
965 },
966 cx,
967 )
968 }
969 });
970 cx.notify();
971 }
972
973 pub fn handle_title_editor_event(
974 &mut self,
975 title_editor: &Entity<Editor>,
976 event: &EditorEvent,
977 window: &mut Window,
978 cx: &mut Context<Self>,
979 ) {
980 let Some(thread) = self.thread() else { return };
981
982 match event {
983 EditorEvent::BufferEdited => {
984 let new_title = title_editor.read(cx).text(cx);
985 thread.update(cx, |thread, cx| {
986 thread
987 .set_title(new_title.into(), cx)
988 .detach_and_log_err(cx);
989 })
990 }
991 EditorEvent::Blurred => {
992 if title_editor.read(cx).text(cx).is_empty() {
993 title_editor.update(cx, |editor, cx| {
994 editor.set_text("New Thread", window, cx);
995 });
996 }
997 }
998 _ => {}
999 }
1000 }
1001
1002 pub fn handle_message_editor_event(
1003 &mut self,
1004 _: &Entity<MessageEditor>,
1005 event: &MessageEditorEvent,
1006 window: &mut Window,
1007 cx: &mut Context<Self>,
1008 ) {
1009 match event {
1010 MessageEditorEvent::Send => self.send(window, cx),
1011 MessageEditorEvent::Queue => self.queue_message(window, cx),
1012 MessageEditorEvent::Cancel => self.cancel_generation(cx),
1013 MessageEditorEvent::Focus => {
1014 self.cancel_editing(&Default::default(), window, cx);
1015 }
1016 MessageEditorEvent::LostFocus => {}
1017 }
1018 }
1019
1020 pub fn handle_entry_view_event(
1021 &mut self,
1022 _: &Entity<EntryViewState>,
1023 event: &EntryViewEvent,
1024 window: &mut Window,
1025 cx: &mut Context<Self>,
1026 ) {
1027 match &event.view_event {
1028 ViewEvent::NewDiff(tool_call_id) => {
1029 if AgentSettings::get_global(cx).expand_edit_card {
1030 self.expanded_tool_calls.insert(tool_call_id.clone());
1031 }
1032 }
1033 ViewEvent::NewTerminal(tool_call_id) => {
1034 if AgentSettings::get_global(cx).expand_terminal_card {
1035 self.expanded_tool_calls.insert(tool_call_id.clone());
1036 }
1037 }
1038 ViewEvent::TerminalMovedToBackground(tool_call_id) => {
1039 self.expanded_tool_calls.remove(tool_call_id);
1040 }
1041 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
1042 if let Some(thread) = self.thread()
1043 && let Some(AgentThreadEntry::UserMessage(user_message)) =
1044 thread.read(cx).entries().get(event.entry_index)
1045 && user_message.id.is_some()
1046 {
1047 self.editing_message = Some(event.entry_index);
1048 cx.notify();
1049 }
1050 }
1051 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
1052 if let Some(thread) = self.thread()
1053 && let Some(AgentThreadEntry::UserMessage(user_message)) =
1054 thread.read(cx).entries().get(event.entry_index)
1055 && user_message.id.is_some()
1056 {
1057 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
1058 self.editing_message = None;
1059 cx.notify();
1060 }
1061 }
1062 }
1063 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Queue) => {}
1064 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
1065 self.regenerate(event.entry_index, editor.clone(), window, cx);
1066 }
1067 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
1068 self.cancel_editing(&Default::default(), window, cx);
1069 }
1070 }
1071 }
1072
1073 pub fn is_loading(&self) -> bool {
1074 matches!(self.thread_state, ThreadState::Loading { .. })
1075 }
1076
1077 fn resume_chat(&mut self, cx: &mut Context<Self>) {
1078 self.thread_error.take();
1079 let Some(thread) = self.thread() else {
1080 return;
1081 };
1082 if !thread.read(cx).can_resume(cx) {
1083 return;
1084 }
1085
1086 let task = thread.update(cx, |thread, cx| thread.resume(cx));
1087 cx.spawn(async move |this, cx| {
1088 let result = task.await;
1089
1090 this.update(cx, |this, cx| {
1091 if let Err(err) = result {
1092 this.handle_thread_error(err, cx);
1093 }
1094 })
1095 })
1096 .detach();
1097 }
1098
1099 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1100 let Some(thread) = self.thread() else { return };
1101
1102 if self.is_loading_contents {
1103 return;
1104 }
1105
1106 self.history_store.update(cx, |history, cx| {
1107 history.push_recently_opened_entry(
1108 HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
1109 cx,
1110 );
1111 });
1112
1113 if thread.read(cx).status() != ThreadStatus::Idle {
1114 self.stop_current_and_send_new_message(window, cx);
1115 return;
1116 }
1117
1118 let text = self.message_editor.read(cx).text(cx);
1119 let text = text.trim();
1120 if text == "/login" || text == "/logout" {
1121 let ThreadState::Ready { thread, .. } = &self.thread_state else {
1122 return;
1123 };
1124
1125 let connection = thread.read(cx).connection().clone();
1126 let can_login = !connection.auth_methods().is_empty() || self.login.is_some();
1127 // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
1128 let logout_supported = text == "/logout"
1129 && self
1130 .available_commands
1131 .borrow()
1132 .iter()
1133 .any(|command| command.name == "logout");
1134 if can_login && !logout_supported {
1135 self.message_editor
1136 .update(cx, |editor, cx| editor.clear(window, cx));
1137
1138 let this = cx.weak_entity();
1139 let agent = self.agent.clone();
1140 window.defer(cx, |window, cx| {
1141 Self::handle_auth_required(
1142 this,
1143 AuthRequired::new(),
1144 agent,
1145 connection,
1146 window,
1147 cx,
1148 );
1149 });
1150 cx.notify();
1151 return;
1152 }
1153 }
1154
1155 self.send_impl(self.message_editor.clone(), window, cx)
1156 }
1157
1158 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1159 let Some(thread) = self.thread().cloned() else {
1160 return;
1161 };
1162
1163 self.skip_queue_processing_count = 0;
1164 self.user_interrupted_generation = true;
1165
1166 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1167
1168 cx.spawn_in(window, async move |this, cx| {
1169 cancelled.await;
1170
1171 this.update_in(cx, |this, window, cx| {
1172 this.send_impl(this.message_editor.clone(), window, cx);
1173 })
1174 .ok();
1175 })
1176 .detach();
1177 }
1178
1179 fn send_impl(
1180 &mut self,
1181 message_editor: Entity<MessageEditor>,
1182 window: &mut Window,
1183 cx: &mut Context<Self>,
1184 ) {
1185 let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
1186 // Include full contents when using minimal profile
1187 let thread = thread.read(cx);
1188 AgentSettings::get_global(cx)
1189 .profiles
1190 .get(thread.profile())
1191 .is_some_and(|profile| profile.tools.is_empty())
1192 });
1193
1194 let contents = message_editor.update(cx, |message_editor, cx| {
1195 message_editor.contents(full_mention_content, cx)
1196 });
1197
1198 self.thread_error.take();
1199 self.editing_message.take();
1200 self.thread_feedback.clear();
1201
1202 let Some(thread) = self.thread() else {
1203 return;
1204 };
1205 let session_id = thread.read(cx).session_id().clone();
1206 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
1207 let thread = thread.downgrade();
1208 if self.should_be_following {
1209 self.workspace
1210 .update(cx, |workspace, cx| {
1211 workspace.follow(CollaboratorId::Agent, window, cx);
1212 })
1213 .ok();
1214 }
1215
1216 self.is_loading_contents = true;
1217 let model_id = self.current_model_id(cx);
1218 let mode_id = self.current_mode_id(cx);
1219 let guard = cx.new(|_| ());
1220 cx.observe_release(&guard, |this, _guard, cx| {
1221 this.is_loading_contents = false;
1222 cx.notify();
1223 })
1224 .detach();
1225
1226 let task = cx.spawn_in(window, async move |this, cx| {
1227 let (contents, tracked_buffers) = contents.await?;
1228
1229 if contents.is_empty() {
1230 return Ok(());
1231 }
1232
1233 this.update_in(cx, |this, window, cx| {
1234 this.in_flight_prompt = Some(contents.clone());
1235 this.set_editor_is_expanded(false, cx);
1236 this.scroll_to_bottom(cx);
1237 this.message_editor.update(cx, |message_editor, cx| {
1238 message_editor.clear(window, cx);
1239 });
1240 })?;
1241 let turn_start_time = Instant::now();
1242 let send = thread.update(cx, |thread, cx| {
1243 thread.action_log().update(cx, |action_log, cx| {
1244 for buffer in tracked_buffers {
1245 action_log.buffer_read(buffer, cx)
1246 }
1247 });
1248 drop(guard);
1249
1250 telemetry::event!(
1251 "Agent Message Sent",
1252 agent = agent_telemetry_id,
1253 session = session_id,
1254 model = model_id,
1255 mode = mode_id
1256 );
1257
1258 thread.send(contents, cx)
1259 })?;
1260 let res = send.await;
1261 let turn_time_ms = turn_start_time.elapsed().as_millis();
1262 let status = if res.is_ok() {
1263 this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
1264 "success"
1265 } else {
1266 "failure"
1267 };
1268 telemetry::event!(
1269 "Agent Turn Completed",
1270 agent = agent_telemetry_id,
1271 session = session_id,
1272 model = model_id,
1273 mode = mode_id,
1274 status,
1275 turn_time_ms,
1276 );
1277 res
1278 });
1279
1280 cx.spawn(async move |this, cx| {
1281 if let Err(err) = task.await {
1282 this.update(cx, |this, cx| {
1283 this.handle_thread_error(err, cx);
1284 })
1285 .ok();
1286 } else {
1287 this.update(cx, |this, cx| {
1288 this.should_be_following = this
1289 .workspace
1290 .update(cx, |workspace, _| {
1291 workspace.is_being_followed(CollaboratorId::Agent)
1292 })
1293 .unwrap_or_default();
1294 })
1295 .ok();
1296 }
1297 })
1298 .detach();
1299 }
1300
1301 fn queue_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1302 let is_idle = self
1303 .thread()
1304 .map(|t| t.read(cx).status() == acp_thread::ThreadStatus::Idle)
1305 .unwrap_or(true);
1306
1307 if is_idle {
1308 self.send_impl(self.message_editor.clone(), window, cx);
1309 return;
1310 }
1311
1312 let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
1313 let thread = thread.read(cx);
1314 AgentSettings::get_global(cx)
1315 .profiles
1316 .get(thread.profile())
1317 .is_some_and(|profile| profile.tools.is_empty())
1318 });
1319
1320 let contents = self.message_editor.update(cx, |message_editor, cx| {
1321 message_editor.contents(full_mention_content, cx)
1322 });
1323
1324 let message_editor = self.message_editor.clone();
1325
1326 cx.spawn_in(window, async move |this, cx| {
1327 let (content, tracked_buffers) = contents.await?;
1328
1329 if content.is_empty() {
1330 return Ok::<(), anyhow::Error>(());
1331 }
1332
1333 this.update_in(cx, |this, window, cx| {
1334 this.message_queue.push(QueuedMessage {
1335 content,
1336 tracked_buffers,
1337 });
1338 message_editor.update(cx, |message_editor, cx| {
1339 message_editor.clear(window, cx);
1340 });
1341 cx.notify();
1342 })?;
1343 Ok(())
1344 })
1345 .detach_and_log_err(cx);
1346 }
1347
1348 fn send_queued_message_at_index(
1349 &mut self,
1350 index: usize,
1351 is_send_now: bool,
1352 window: &mut Window,
1353 cx: &mut Context<Self>,
1354 ) {
1355 if index >= self.message_queue.len() {
1356 return;
1357 }
1358
1359 let queued = self.message_queue.remove(index);
1360 let content = queued.content;
1361 let tracked_buffers = queued.tracked_buffers;
1362
1363 let Some(thread) = self.thread().cloned() else {
1364 return;
1365 };
1366
1367 // Only increment skip count for "Send Now" operations (out-of-order sends)
1368 // Normal auto-processing from the Stopped handler doesn't need to skip
1369 if is_send_now {
1370 let is_generating = thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
1371 self.skip_queue_processing_count += if is_generating { 2 } else { 1 };
1372 }
1373
1374 // Ensure we don't end up with multiple concurrent generations
1375 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1376
1377 let session_id = thread.read(cx).session_id().clone();
1378 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
1379 let thread = thread.downgrade();
1380
1381 let should_be_following = self.should_be_following;
1382 let workspace = self.workspace.clone();
1383
1384 self.is_loading_contents = true;
1385 let model_id = self.current_model_id(cx);
1386 let mode_id = self.current_mode_id(cx);
1387 let guard = cx.new(|_| ());
1388
1389 cx.observe_release(&guard, |this, _guard, cx| {
1390 this.is_loading_contents = false;
1391 cx.notify();
1392 })
1393 .detach();
1394
1395 let task = cx.spawn_in(window, async move |this, cx| {
1396 cancelled.await;
1397 this.update_in(cx, |this, window, cx| {
1398 if should_be_following {
1399 workspace
1400 .update(cx, |workspace, cx| {
1401 workspace.follow(CollaboratorId::Agent, window, cx);
1402 })
1403 .ok();
1404 }
1405
1406 this.in_flight_prompt = Some(content.clone());
1407 this.set_editor_is_expanded(false, cx);
1408 this.scroll_to_bottom(cx);
1409 })?;
1410
1411 let turn_start_time = Instant::now();
1412 let send = thread.update(cx, |thread, cx| {
1413 thread.action_log().update(cx, |action_log, cx| {
1414 for buffer in tracked_buffers {
1415 action_log.buffer_read(buffer, cx)
1416 }
1417 });
1418 drop(guard);
1419
1420 telemetry::event!(
1421 "Agent Message Sent",
1422 agent = agent_telemetry_id,
1423 session = session_id,
1424 model = model_id,
1425 mode = mode_id
1426 );
1427
1428 thread.send(content, cx)
1429 })?;
1430
1431 let res = send.await;
1432 let turn_time_ms = turn_start_time.elapsed().as_millis();
1433 let status = if res.is_ok() {
1434 this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
1435 "success"
1436 } else {
1437 "failure"
1438 };
1439
1440 telemetry::event!(
1441 "Agent Turn Completed",
1442 agent = agent_telemetry_id,
1443 session = session_id,
1444 model = model_id,
1445 mode = mode_id,
1446 status,
1447 turn_time_ms,
1448 );
1449 res
1450 });
1451
1452 cx.spawn(async move |this, cx| {
1453 if let Err(err) = task.await {
1454 this.update(cx, |this, cx| {
1455 this.handle_thread_error(err, cx);
1456 })
1457 .ok();
1458 } else {
1459 this.update(cx, |this, cx| {
1460 this.should_be_following = this
1461 .workspace
1462 .update(cx, |workspace, _| {
1463 workspace.is_being_followed(CollaboratorId::Agent)
1464 })
1465 .unwrap_or_default();
1466 })
1467 .ok();
1468 }
1469 })
1470 .detach();
1471 }
1472
1473 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1474 let Some(thread) = self.thread().cloned() else {
1475 return;
1476 };
1477
1478 if let Some(index) = self.editing_message.take()
1479 && let Some(editor) = self
1480 .entry_view_state
1481 .read(cx)
1482 .entry(index)
1483 .and_then(|e| e.message_editor())
1484 .cloned()
1485 {
1486 editor.update(cx, |editor, cx| {
1487 if let Some(user_message) = thread
1488 .read(cx)
1489 .entries()
1490 .get(index)
1491 .and_then(|e| e.user_message())
1492 {
1493 editor.set_message(user_message.chunks.clone(), window, cx);
1494 }
1495 })
1496 };
1497 self.focus_handle(cx).focus(window, cx);
1498 cx.notify();
1499 }
1500
1501 fn regenerate(
1502 &mut self,
1503 entry_ix: usize,
1504 message_editor: Entity<MessageEditor>,
1505 window: &mut Window,
1506 cx: &mut Context<Self>,
1507 ) {
1508 let Some(thread) = self.thread().cloned() else {
1509 return;
1510 };
1511 if self.is_loading_contents {
1512 return;
1513 }
1514
1515 let Some(user_message_id) = thread.update(cx, |thread, _| {
1516 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1517 }) else {
1518 return;
1519 };
1520
1521 cx.spawn_in(window, async move |this, cx| {
1522 // Check if there are any edits from prompts before the one being regenerated.
1523 //
1524 // If there are, we keep/accept them since we're not regenerating the prompt that created them.
1525 //
1526 // If editing the prompt that generated the edits, they are auto-rejected
1527 // through the `rewind` function in the `acp_thread`.
1528 let has_earlier_edits = thread.read_with(cx, |thread, _| {
1529 thread
1530 .entries()
1531 .iter()
1532 .take(entry_ix)
1533 .any(|entry| entry.diffs().next().is_some())
1534 })?;
1535
1536 if has_earlier_edits {
1537 thread.update(cx, |thread, cx| {
1538 thread.action_log().update(cx, |action_log, cx| {
1539 action_log.keep_all_edits(None, cx);
1540 });
1541 })?;
1542 }
1543
1544 thread
1545 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
1546 .await?;
1547 this.update_in(cx, |this, window, cx| {
1548 this.send_impl(message_editor, window, cx);
1549 this.focus_handle(cx).focus(window, cx);
1550 })?;
1551 anyhow::Ok(())
1552 })
1553 .detach_and_log_err(cx);
1554 }
1555
1556 fn open_edited_buffer(
1557 &mut self,
1558 buffer: &Entity<Buffer>,
1559 window: &mut Window,
1560 cx: &mut Context<Self>,
1561 ) {
1562 let Some(thread) = self.thread() else {
1563 return;
1564 };
1565
1566 let Some(diff) =
1567 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1568 else {
1569 return;
1570 };
1571
1572 diff.update(cx, |diff, cx| {
1573 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1574 })
1575 }
1576
1577 fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1578 let Some(thread) = self.as_native_thread(cx) else {
1579 return;
1580 };
1581 let project_context = thread.read(cx).project_context().read(cx);
1582
1583 let project_entry_ids = project_context
1584 .worktrees
1585 .iter()
1586 .flat_map(|worktree| worktree.rules_file.as_ref())
1587 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
1588 .collect::<Vec<_>>();
1589
1590 self.workspace
1591 .update(cx, move |workspace, cx| {
1592 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
1593 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
1594 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
1595 let project = workspace.project().read(cx);
1596 let project_paths = project_entry_ids
1597 .into_iter()
1598 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
1599 .collect::<Vec<_>>();
1600 for project_path in project_paths {
1601 workspace
1602 .open_path(project_path, None, true, window, cx)
1603 .detach_and_log_err(cx);
1604 }
1605 })
1606 .ok();
1607 }
1608
1609 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
1610 self.thread_error = Some(ThreadError::from_err(error, &self.agent));
1611 cx.notify();
1612 }
1613
1614 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
1615 self.thread_error = None;
1616 self.thread_error_markdown = None;
1617 self.token_limit_callout_dismissed = true;
1618 cx.notify();
1619 }
1620
1621 fn handle_thread_event(
1622 &mut self,
1623 thread: &Entity<AcpThread>,
1624 event: &AcpThreadEvent,
1625 window: &mut Window,
1626 cx: &mut Context<Self>,
1627 ) {
1628 match event {
1629 AcpThreadEvent::NewEntry => {
1630 let len = thread.read(cx).entries().len();
1631 let index = len - 1;
1632 self.entry_view_state.update(cx, |view_state, cx| {
1633 view_state.sync_entry(index, thread, window, cx);
1634 self.list_state.splice_focusable(
1635 index..index,
1636 [view_state
1637 .entry(index)
1638 .and_then(|entry| entry.focus_handle(cx))],
1639 );
1640 });
1641 }
1642 AcpThreadEvent::EntryUpdated(index) => {
1643 self.entry_view_state.update(cx, |view_state, cx| {
1644 view_state.sync_entry(*index, thread, window, cx)
1645 });
1646 }
1647 AcpThreadEvent::EntriesRemoved(range) => {
1648 self.entry_view_state
1649 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
1650 self.list_state.splice(range.clone(), 0);
1651 }
1652 AcpThreadEvent::ToolAuthorizationRequired => {
1653 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1654 }
1655 AcpThreadEvent::Retry(retry) => {
1656 self.thread_retry_status = Some(retry.clone());
1657 }
1658 AcpThreadEvent::Stopped => {
1659 self.thread_retry_status.take();
1660 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1661 self.notify_with_sound(
1662 if used_tools {
1663 "Finished running tools"
1664 } else {
1665 "New message"
1666 },
1667 IconName::ZedAssistant,
1668 window,
1669 cx,
1670 );
1671
1672 if self.skip_queue_processing_count > 0 {
1673 self.skip_queue_processing_count -= 1;
1674 } else if self.user_interrupted_generation {
1675 // Manual interruption: don't auto-process queue.
1676 // Reset the flag so future completions can process normally.
1677 self.user_interrupted_generation = false;
1678 } else if !self.message_queue.is_empty() {
1679 self.send_queued_message_at_index(0, false, window, cx);
1680 }
1681 }
1682 AcpThreadEvent::Refusal => {
1683 self.thread_retry_status.take();
1684 self.thread_error = Some(ThreadError::Refusal);
1685 let model_or_agent_name = self.current_model_name(cx);
1686 let notification_message =
1687 format!("{} refused to respond to this request", model_or_agent_name);
1688 self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
1689 }
1690 AcpThreadEvent::Error => {
1691 self.thread_retry_status.take();
1692 self.notify_with_sound(
1693 "Agent stopped due to an error",
1694 IconName::Warning,
1695 window,
1696 cx,
1697 );
1698 }
1699 AcpThreadEvent::LoadError(error) => {
1700 self.thread_retry_status.take();
1701 self.thread_state = ThreadState::LoadError(error.clone());
1702 if self.message_editor.focus_handle(cx).is_focused(window) {
1703 self.focus_handle.focus(window, cx)
1704 }
1705 }
1706 AcpThreadEvent::TitleUpdated => {
1707 let title = thread.read(cx).title();
1708 if let Some(title_editor) = self.title_editor() {
1709 title_editor.update(cx, |editor, cx| {
1710 if editor.text(cx) != title {
1711 editor.set_text(title, window, cx);
1712 }
1713 });
1714 }
1715 }
1716 AcpThreadEvent::PromptCapabilitiesUpdated => {
1717 self.prompt_capabilities
1718 .replace(thread.read(cx).prompt_capabilities());
1719 }
1720 AcpThreadEvent::TokenUsageUpdated => {}
1721 AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1722 let mut available_commands = available_commands.clone();
1723
1724 if thread
1725 .read(cx)
1726 .connection()
1727 .auth_methods()
1728 .iter()
1729 .any(|method| method.id.0.as_ref() == "claude-login")
1730 {
1731 available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
1732 available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
1733 }
1734
1735 let has_commands = !available_commands.is_empty();
1736 self.available_commands.replace(available_commands);
1737
1738 let agent_display_name = self
1739 .agent_server_store
1740 .read(cx)
1741 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
1742 .unwrap_or_else(|| self.agent.name());
1743
1744 let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
1745
1746 self.message_editor.update(cx, |editor, cx| {
1747 editor.set_placeholder_text(&new_placeholder, window, cx);
1748 });
1749 }
1750 AcpThreadEvent::ModeUpdated(_mode) => {
1751 // The connection keeps track of the mode
1752 cx.notify();
1753 }
1754 AcpThreadEvent::ConfigOptionsUpdated(_) => {
1755 // The watch task in ConfigOptionsView handles rebuilding selectors
1756 cx.notify();
1757 }
1758 }
1759 cx.notify();
1760 }
1761
1762 fn authenticate(
1763 &mut self,
1764 method: acp::AuthMethodId,
1765 window: &mut Window,
1766 cx: &mut Context<Self>,
1767 ) {
1768 let ThreadState::Unauthenticated {
1769 connection,
1770 pending_auth_method,
1771 configuration_view,
1772 ..
1773 } = &mut self.thread_state
1774 else {
1775 return;
1776 };
1777 let agent_telemetry_id = connection.telemetry_id();
1778
1779 // Check for the experimental "terminal-auth" _meta field
1780 let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
1781
1782 if let Some(auth_method) = auth_method {
1783 if let Some(meta) = &auth_method.meta {
1784 if let Some(terminal_auth) = meta.get("terminal-auth") {
1785 // Extract terminal auth details from meta
1786 if let (Some(command), Some(label)) = (
1787 terminal_auth.get("command").and_then(|v| v.as_str()),
1788 terminal_auth.get("label").and_then(|v| v.as_str()),
1789 ) {
1790 let args = terminal_auth
1791 .get("args")
1792 .and_then(|v| v.as_array())
1793 .map(|arr| {
1794 arr.iter()
1795 .filter_map(|v| v.as_str().map(String::from))
1796 .collect()
1797 })
1798 .unwrap_or_default();
1799
1800 let env = terminal_auth
1801 .get("env")
1802 .and_then(|v| v.as_object())
1803 .map(|obj| {
1804 obj.iter()
1805 .filter_map(|(k, v)| {
1806 v.as_str().map(|val| (k.clone(), val.to_string()))
1807 })
1808 .collect::<HashMap<String, String>>()
1809 })
1810 .unwrap_or_default();
1811
1812 // Run SpawnInTerminal in the same dir as the ACP server
1813 let cwd = connection
1814 .clone()
1815 .downcast::<agent_servers::AcpConnection>()
1816 .map(|acp_conn| acp_conn.root_dir().to_path_buf());
1817
1818 // Build SpawnInTerminal from _meta
1819 let login = task::SpawnInTerminal {
1820 id: task::TaskId(format!("external-agent-{}-login", label)),
1821 full_label: label.to_string(),
1822 label: label.to_string(),
1823 command: Some(command.to_string()),
1824 args,
1825 command_label: label.to_string(),
1826 cwd,
1827 env,
1828 use_new_terminal: true,
1829 allow_concurrent_runs: true,
1830 hide: task::HideStrategy::Always,
1831 ..Default::default()
1832 };
1833
1834 self.thread_error.take();
1835 configuration_view.take();
1836 pending_auth_method.replace(method.clone());
1837
1838 if let Some(workspace) = self.workspace.upgrade() {
1839 let project = self.project.clone();
1840 let authenticate = Self::spawn_external_agent_login(
1841 login, workspace, project, false, true, window, cx,
1842 );
1843 cx.notify();
1844 self.auth_task = Some(cx.spawn_in(window, {
1845 async move |this, cx| {
1846 let result = authenticate.await;
1847
1848 match &result {
1849 Ok(_) => telemetry::event!(
1850 "Authenticate Agent Succeeded",
1851 agent = agent_telemetry_id
1852 ),
1853 Err(_) => {
1854 telemetry::event!(
1855 "Authenticate Agent Failed",
1856 agent = agent_telemetry_id,
1857 )
1858 }
1859 }
1860
1861 this.update_in(cx, |this, window, cx| {
1862 if let Err(err) = result {
1863 if let ThreadState::Unauthenticated {
1864 pending_auth_method,
1865 ..
1866 } = &mut this.thread_state
1867 {
1868 pending_auth_method.take();
1869 }
1870 this.handle_thread_error(err, cx);
1871 } else {
1872 this.reset(window, cx);
1873 }
1874 this.auth_task.take()
1875 })
1876 .ok();
1877 }
1878 }));
1879 }
1880 return;
1881 }
1882 }
1883 }
1884 }
1885
1886 if method.0.as_ref() == "gemini-api-key" {
1887 let registry = LanguageModelRegistry::global(cx);
1888 let provider = registry
1889 .read(cx)
1890 .provider(&language_model::GOOGLE_PROVIDER_ID)
1891 .unwrap();
1892 if !provider.is_authenticated(cx) {
1893 let this = cx.weak_entity();
1894 let agent = self.agent.clone();
1895 let connection = connection.clone();
1896 window.defer(cx, |window, cx| {
1897 Self::handle_auth_required(
1898 this,
1899 AuthRequired {
1900 description: Some("GEMINI_API_KEY must be set".to_owned()),
1901 provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
1902 },
1903 agent,
1904 connection,
1905 window,
1906 cx,
1907 );
1908 });
1909 return;
1910 }
1911 } else if method.0.as_ref() == "vertex-ai"
1912 && std::env::var("GOOGLE_API_KEY").is_err()
1913 && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
1914 || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()))
1915 {
1916 let this = cx.weak_entity();
1917 let agent = self.agent.clone();
1918 let connection = connection.clone();
1919
1920 window.defer(cx, |window, cx| {
1921 Self::handle_auth_required(
1922 this,
1923 AuthRequired {
1924 description: Some(
1925 "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed."
1926 .to_owned(),
1927 ),
1928 provider_id: None,
1929 },
1930 agent,
1931 connection,
1932 window,
1933 cx,
1934 )
1935 });
1936 return;
1937 }
1938
1939 self.thread_error.take();
1940 configuration_view.take();
1941 pending_auth_method.replace(method.clone());
1942 let authenticate = if (method.0.as_ref() == "claude-login"
1943 || method.0.as_ref() == "spawn-gemini-cli")
1944 && let Some(login) = self.login.clone()
1945 {
1946 if let Some(workspace) = self.workspace.upgrade() {
1947 let project = self.project.clone();
1948 Self::spawn_external_agent_login(
1949 login, workspace, project, false, false, window, cx,
1950 )
1951 } else {
1952 Task::ready(Ok(()))
1953 }
1954 } else {
1955 connection.authenticate(method, cx)
1956 };
1957 cx.notify();
1958 self.auth_task = Some(cx.spawn_in(window, {
1959 async move |this, cx| {
1960 let result = authenticate.await;
1961
1962 match &result {
1963 Ok(_) => telemetry::event!(
1964 "Authenticate Agent Succeeded",
1965 agent = agent_telemetry_id
1966 ),
1967 Err(_) => {
1968 telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
1969 }
1970 }
1971
1972 this.update_in(cx, |this, window, cx| {
1973 if let Err(err) = result {
1974 if let ThreadState::Unauthenticated {
1975 pending_auth_method,
1976 ..
1977 } = &mut this.thread_state
1978 {
1979 pending_auth_method.take();
1980 }
1981 this.handle_thread_error(err, cx);
1982 } else {
1983 this.reset(window, cx);
1984 }
1985 this.auth_task.take()
1986 })
1987 .ok();
1988 }
1989 }));
1990 }
1991
1992 fn spawn_external_agent_login(
1993 login: task::SpawnInTerminal,
1994 workspace: Entity<Workspace>,
1995 project: Entity<Project>,
1996 previous_attempt: bool,
1997 check_exit_code: bool,
1998 window: &mut Window,
1999 cx: &mut App,
2000 ) -> Task<Result<()>> {
2001 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
2002 return Task::ready(Ok(()));
2003 };
2004
2005 window.spawn(cx, async move |cx| {
2006 let mut task = login.clone();
2007 if let Some(cmd) = &task.command {
2008 // Have "node" command use Zed's managed Node runtime by default
2009 if cmd == "node" {
2010 let resolved_node_runtime = project
2011 .update(cx, |project, cx| {
2012 let agent_server_store = project.agent_server_store().clone();
2013 agent_server_store.update(cx, |store, cx| {
2014 store.node_runtime().map(|node_runtime| {
2015 cx.background_spawn(async move {
2016 node_runtime.binary_path().await
2017 })
2018 })
2019 })
2020 });
2021
2022 if let Ok(Some(resolve_task)) = resolved_node_runtime {
2023 if let Ok(node_path) = resolve_task.await {
2024 task.command = Some(node_path.to_string_lossy().to_string());
2025 }
2026 }
2027 }
2028 }
2029 task.shell = task::Shell::WithArguments {
2030 program: task.command.take().expect("login command should be set"),
2031 args: std::mem::take(&mut task.args),
2032 title_override: None
2033 };
2034 task.full_label = task.label.clone();
2035 task.id = task::TaskId(format!("external-agent-{}-login", task.label));
2036 task.command_label = task.label.clone();
2037 task.use_new_terminal = true;
2038 task.allow_concurrent_runs = true;
2039 task.hide = task::HideStrategy::Always;
2040
2041 let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
2042 terminal_panel.spawn_task(&task, window, cx)
2043 })?;
2044
2045 let terminal = terminal.await?;
2046
2047 if check_exit_code {
2048 // For extension-based auth, wait for the process to exit and check exit code
2049 let exit_status = terminal
2050 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2051 .await;
2052
2053 match exit_status {
2054 Some(status) if status.success() => {
2055 Ok(())
2056 }
2057 Some(status) => {
2058 Err(anyhow!("Login command failed with exit code: {:?}", status.code()))
2059 }
2060 None => {
2061 Err(anyhow!("Login command terminated without exit status"))
2062 }
2063 }
2064 } else {
2065 // For hardcoded agents (claude-login, gemini-cli): look for specific output
2066 let mut exit_status = terminal
2067 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2068 .fuse();
2069
2070 let logged_in = cx
2071 .spawn({
2072 let terminal = terminal.clone();
2073 async move |cx| {
2074 loop {
2075 cx.background_executor().timer(Duration::from_secs(1)).await;
2076 let content =
2077 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
2078 if content.contains("Login successful")
2079 || content.contains("Type your message")
2080 {
2081 return anyhow::Ok(());
2082 }
2083 }
2084 }
2085 })
2086 .fuse();
2087 futures::pin_mut!(logged_in);
2088 futures::select_biased! {
2089 result = logged_in => {
2090 if let Err(e) = result {
2091 log::error!("{e}");
2092 return Err(anyhow!("exited before logging in"));
2093 }
2094 }
2095 _ = exit_status => {
2096 if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") {
2097 return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), true, false, window, cx))?.await
2098 }
2099 return Err(anyhow!("exited before logging in"));
2100 }
2101 }
2102 terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
2103 Ok(())
2104 }
2105 })
2106 }
2107
2108 pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
2109 self.thread().is_some_and(|thread| {
2110 thread.read(cx).entries().iter().any(|entry| {
2111 matches!(
2112 entry,
2113 AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
2114 )
2115 })
2116 })
2117 }
2118
2119 fn authorize_tool_call(
2120 &mut self,
2121 tool_call_id: acp::ToolCallId,
2122 option_id: acp::PermissionOptionId,
2123 option_kind: acp::PermissionOptionKind,
2124 window: &mut Window,
2125 cx: &mut Context<Self>,
2126 ) {
2127 let Some(thread) = self.thread() else {
2128 return;
2129 };
2130 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
2131
2132 telemetry::event!(
2133 "Agent Tool Call Authorized",
2134 agent = agent_telemetry_id,
2135 session = thread.read(cx).session_id(),
2136 option = option_kind
2137 );
2138
2139 thread.update(cx, |thread, cx| {
2140 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
2141 });
2142 if self.should_be_following {
2143 self.workspace
2144 .update(cx, |workspace, cx| {
2145 workspace.follow(CollaboratorId::Agent, window, cx);
2146 })
2147 .ok();
2148 }
2149 cx.notify();
2150 }
2151
2152 fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
2153 let Some(thread) = self.thread() else {
2154 return;
2155 };
2156
2157 thread
2158 .update(cx, |thread, cx| {
2159 thread.restore_checkpoint(message_id.clone(), cx)
2160 })
2161 .detach_and_log_err(cx);
2162 }
2163
2164 fn render_entry(
2165 &self,
2166 entry_ix: usize,
2167 total_entries: usize,
2168 entry: &AgentThreadEntry,
2169 window: &mut Window,
2170 cx: &Context<Self>,
2171 ) -> AnyElement {
2172 let is_indented = entry.is_indented();
2173 let is_first_indented = is_indented
2174 && self.thread().is_some_and(|thread| {
2175 thread
2176 .read(cx)
2177 .entries()
2178 .get(entry_ix.saturating_sub(1))
2179 .is_none_or(|entry| !entry.is_indented())
2180 });
2181
2182 let primary = match &entry {
2183 AgentThreadEntry::UserMessage(message) => {
2184 let Some(editor) = self
2185 .entry_view_state
2186 .read(cx)
2187 .entry(entry_ix)
2188 .and_then(|entry| entry.message_editor())
2189 .cloned()
2190 else {
2191 return Empty.into_any_element();
2192 };
2193
2194 let editing = self.editing_message == Some(entry_ix);
2195 let editor_focus = editor.focus_handle(cx).is_focused(window);
2196 let focus_border = cx.theme().colors().border_focused;
2197
2198 let rules_item = if entry_ix == 0 {
2199 self.render_rules_item(cx)
2200 } else {
2201 None
2202 };
2203
2204 let has_checkpoint_button = message
2205 .checkpoint
2206 .as_ref()
2207 .is_some_and(|checkpoint| checkpoint.show);
2208
2209 let agent_name = self.agent.name();
2210
2211 v_flex()
2212 .id(("user_message", entry_ix))
2213 .map(|this| {
2214 if is_first_indented {
2215 this.pt_0p5()
2216 } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
2217 this.pt(rems_from_px(18.))
2218 } else if rules_item.is_some() {
2219 this.pt_3()
2220 } else {
2221 this.pt_2()
2222 }
2223 })
2224 .pb_3()
2225 .px_2()
2226 .gap_1p5()
2227 .w_full()
2228 .children(rules_item)
2229 .children(message.id.clone().and_then(|message_id| {
2230 message.checkpoint.as_ref()?.show.then(|| {
2231 h_flex()
2232 .px_3()
2233 .gap_2()
2234 .child(Divider::horizontal())
2235 .child(
2236 Button::new("restore-checkpoint", "Restore Checkpoint")
2237 .icon(IconName::Undo)
2238 .icon_size(IconSize::XSmall)
2239 .icon_position(IconPosition::Start)
2240 .label_size(LabelSize::XSmall)
2241 .icon_color(Color::Muted)
2242 .color(Color::Muted)
2243 .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
2244 .on_click(cx.listener(move |this, _, _window, cx| {
2245 this.restore_checkpoint(&message_id, cx);
2246 }))
2247 )
2248 .child(Divider::horizontal())
2249 })
2250 }))
2251 .child(
2252 div()
2253 .relative()
2254 .child(
2255 div()
2256 .py_3()
2257 .px_2()
2258 .rounded_md()
2259 .shadow_md()
2260 .bg(cx.theme().colors().editor_background)
2261 .border_1()
2262 .when(is_indented, |this| {
2263 this.py_2().px_2().shadow_sm()
2264 })
2265 .when(editing && !editor_focus, |this| this.border_dashed())
2266 .border_color(cx.theme().colors().border)
2267 .map(|this|{
2268 if editing && editor_focus {
2269 this.border_color(focus_border)
2270 } else if message.id.is_some() {
2271 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
2272 } else {
2273 this
2274 }
2275 })
2276 .text_xs()
2277 .child(editor.clone().into_any_element())
2278 )
2279 .when(editor_focus, |this| {
2280 let base_container = h_flex()
2281 .absolute()
2282 .top_neg_3p5()
2283 .right_3()
2284 .gap_1()
2285 .rounded_sm()
2286 .border_1()
2287 .border_color(cx.theme().colors().border)
2288 .bg(cx.theme().colors().editor_background)
2289 .overflow_hidden();
2290
2291 if message.id.is_some() {
2292 this.child(
2293 base_container
2294 .child(
2295 IconButton::new("cancel", IconName::Close)
2296 .disabled(self.is_loading_contents)
2297 .icon_color(Color::Error)
2298 .icon_size(IconSize::XSmall)
2299 .on_click(cx.listener(Self::cancel_editing))
2300 )
2301 .child(
2302 if self.is_loading_contents {
2303 div()
2304 .id("loading-edited-message-content")
2305 .tooltip(Tooltip::text("Loading Added Context…"))
2306 .child(loading_contents_spinner(IconSize::XSmall))
2307 .into_any_element()
2308 } else {
2309 IconButton::new("regenerate", IconName::Return)
2310 .icon_color(Color::Muted)
2311 .icon_size(IconSize::XSmall)
2312 .tooltip(Tooltip::text(
2313 "Editing will restart the thread from this point."
2314 ))
2315 .on_click(cx.listener({
2316 let editor = editor.clone();
2317 move |this, _, window, cx| {
2318 this.regenerate(
2319 entry_ix, editor.clone(), window, cx,
2320 );
2321 }
2322 })).into_any_element()
2323 }
2324 )
2325 )
2326 } else {
2327 this.child(
2328 base_container
2329 .border_dashed()
2330 .child(
2331 IconButton::new("editing_unavailable", IconName::PencilUnavailable)
2332 .icon_size(IconSize::Small)
2333 .icon_color(Color::Muted)
2334 .style(ButtonStyle::Transparent)
2335 .tooltip(Tooltip::element({
2336 move |_, _| {
2337 v_flex()
2338 .gap_1()
2339 .child(Label::new("Unavailable Editing")).child(
2340 div().max_w_64().child(
2341 Label::new(format!(
2342 "Editing previous messages is not available for {} yet.",
2343 agent_name.clone()
2344 ))
2345 .size(LabelSize::Small)
2346 .color(Color::Muted),
2347 ),
2348 )
2349 .into_any_element()
2350 }
2351 }))
2352 )
2353 )
2354 }
2355 }),
2356 )
2357 .into_any()
2358 }
2359 AgentThreadEntry::AssistantMessage(AssistantMessage {
2360 chunks,
2361 indented: _,
2362 }) => {
2363 let mut is_blank = true;
2364 let is_last = entry_ix + 1 == total_entries;
2365
2366 let style = default_markdown_style(false, false, window, cx);
2367 let message_body = v_flex()
2368 .w_full()
2369 .gap_3()
2370 .children(chunks.iter().enumerate().filter_map(
2371 |(chunk_ix, chunk)| match chunk {
2372 AssistantMessageChunk::Message { block } => {
2373 block.markdown().and_then(|md| {
2374 let this_is_blank = md.read(cx).source().trim().is_empty();
2375 is_blank = is_blank && this_is_blank;
2376 if this_is_blank {
2377 return None;
2378 }
2379
2380 Some(
2381 self.render_markdown(md.clone(), style.clone())
2382 .into_any_element(),
2383 )
2384 })
2385 }
2386 AssistantMessageChunk::Thought { block } => {
2387 block.markdown().and_then(|md| {
2388 let this_is_blank = md.read(cx).source().trim().is_empty();
2389 is_blank = is_blank && this_is_blank;
2390 if this_is_blank {
2391 return None;
2392 }
2393 Some(
2394 self.render_thinking_block(
2395 entry_ix,
2396 chunk_ix,
2397 md.clone(),
2398 window,
2399 cx,
2400 )
2401 .into_any_element(),
2402 )
2403 })
2404 }
2405 },
2406 ))
2407 .into_any();
2408
2409 if is_blank {
2410 Empty.into_any()
2411 } else {
2412 v_flex()
2413 .px_5()
2414 .py_1p5()
2415 .when(is_last, |this| this.pb_4())
2416 .w_full()
2417 .text_ui(cx)
2418 .child(self.render_message_context_menu(entry_ix, message_body, cx))
2419 .into_any()
2420 }
2421 }
2422 AgentThreadEntry::ToolCall(tool_call) => {
2423 let has_terminals = tool_call.terminals().next().is_some();
2424
2425 div()
2426 .w_full()
2427 .map(|this| {
2428 if has_terminals {
2429 this.children(tool_call.terminals().map(|terminal| {
2430 self.render_terminal_tool_call(
2431 entry_ix, terminal, tool_call, window, cx,
2432 )
2433 }))
2434 } else {
2435 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
2436 }
2437 })
2438 .into_any()
2439 }
2440 };
2441
2442 let primary = if is_indented {
2443 let line_top = if is_first_indented {
2444 rems_from_px(-12.0)
2445 } else {
2446 rems_from_px(0.0)
2447 };
2448
2449 div()
2450 .relative()
2451 .w_full()
2452 .pl_5()
2453 .bg(cx.theme().colors().panel_background.opacity(0.2))
2454 .child(
2455 div()
2456 .absolute()
2457 .left(rems_from_px(18.0))
2458 .top(line_top)
2459 .bottom_0()
2460 .w_px()
2461 .bg(cx.theme().colors().border.opacity(0.6)),
2462 )
2463 .child(primary)
2464 .into_any_element()
2465 } else {
2466 primary
2467 };
2468
2469 let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
2470 matches!(
2471 tool_call.status,
2472 ToolCallStatus::WaitingForConfirmation { .. }
2473 )
2474 } else {
2475 false
2476 };
2477
2478 let Some(thread) = self.thread() else {
2479 return primary;
2480 };
2481
2482 let primary = if entry_ix == total_entries - 1 {
2483 v_flex()
2484 .w_full()
2485 .child(primary)
2486 .map(|this| {
2487 if needs_confirmation {
2488 this.child(self.render_generating(true))
2489 } else {
2490 this.child(self.render_thread_controls(&thread, cx))
2491 }
2492 })
2493 .when_some(
2494 self.thread_feedback.comments_editor.clone(),
2495 |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
2496 )
2497 .into_any_element()
2498 } else {
2499 primary
2500 };
2501
2502 if let Some(editing_index) = self.editing_message.as_ref()
2503 && *editing_index < entry_ix
2504 {
2505 let backdrop = div()
2506 .id(("backdrop", entry_ix))
2507 .size_full()
2508 .absolute()
2509 .inset_0()
2510 .bg(cx.theme().colors().panel_background)
2511 .opacity(0.8)
2512 .block_mouse_except_scroll()
2513 .on_click(cx.listener(Self::cancel_editing));
2514
2515 div()
2516 .relative()
2517 .child(primary)
2518 .child(backdrop)
2519 .into_any_element()
2520 } else {
2521 primary
2522 }
2523 }
2524
2525 fn render_message_context_menu(
2526 &self,
2527 entry_ix: usize,
2528 message_body: AnyElement,
2529 cx: &Context<Self>,
2530 ) -> AnyElement {
2531 let entity = cx.entity();
2532 let workspace = self.workspace.clone();
2533
2534 right_click_menu(format!("agent_context_menu-{}", entry_ix))
2535 .trigger(move |_, _, _| message_body)
2536 .menu(move |window, cx| {
2537 let focus = window.focused(cx);
2538 let entity = entity.clone();
2539 let workspace = workspace.clone();
2540
2541 ContextMenu::build(window, cx, move |menu, _, cx| {
2542 let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0;
2543
2544 let scroll_item = if is_at_top {
2545 ContextMenuEntry::new("Scroll to Bottom").handler({
2546 let entity = entity.clone();
2547 move |_, cx| {
2548 entity.update(cx, |this, cx| {
2549 this.scroll_to_bottom(cx);
2550 });
2551 }
2552 })
2553 } else {
2554 ContextMenuEntry::new("Scroll to Top").handler({
2555 let entity = entity.clone();
2556 move |_, cx| {
2557 entity.update(cx, |this, cx| {
2558 this.scroll_to_top(cx);
2559 });
2560 }
2561 })
2562 };
2563
2564 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
2565 .handler({
2566 let entity = entity.clone();
2567 let workspace = workspace.clone();
2568 move |window, cx| {
2569 if let Some(workspace) = workspace.upgrade() {
2570 entity
2571 .update(cx, |this, cx| {
2572 this.open_thread_as_markdown(workspace, window, cx)
2573 })
2574 .detach_and_log_err(cx);
2575 }
2576 }
2577 });
2578
2579 menu.when_some(focus, |menu, focus| menu.context(focus))
2580 .action("Copy", Box::new(markdown::CopyAsMarkdown))
2581 .separator()
2582 .item(scroll_item)
2583 .item(open_thread_as_markdown)
2584 })
2585 })
2586 .into_any_element()
2587 }
2588
2589 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
2590 cx.theme()
2591 .colors()
2592 .element_background
2593 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
2594 }
2595
2596 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
2597 cx.theme().colors().border.opacity(0.8)
2598 }
2599
2600 fn tool_name_font_size(&self) -> Rems {
2601 rems_from_px(13.)
2602 }
2603
2604 fn render_thinking_block(
2605 &self,
2606 entry_ix: usize,
2607 chunk_ix: usize,
2608 chunk: Entity<Markdown>,
2609 window: &Window,
2610 cx: &Context<Self>,
2611 ) -> AnyElement {
2612 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
2613 let card_header_id = SharedString::from("inner-card-header");
2614
2615 let key = (entry_ix, chunk_ix);
2616
2617 let is_open = self.expanded_thinking_blocks.contains(&key);
2618
2619 let scroll_handle = self
2620 .entry_view_state
2621 .read(cx)
2622 .entry(entry_ix)
2623 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
2624
2625 let thinking_content = {
2626 div()
2627 .id(("thinking-content", chunk_ix))
2628 .when_some(scroll_handle, |this, scroll_handle| {
2629 this.track_scroll(&scroll_handle)
2630 })
2631 .text_ui_sm(cx)
2632 .overflow_hidden()
2633 .child(
2634 self.render_markdown(chunk, default_markdown_style(false, false, window, cx)),
2635 )
2636 };
2637
2638 v_flex()
2639 .gap_1()
2640 .child(
2641 h_flex()
2642 .id(header_id)
2643 .group(&card_header_id)
2644 .relative()
2645 .w_full()
2646 .pr_1()
2647 .justify_between()
2648 .child(
2649 h_flex()
2650 .h(window.line_height() - px(2.))
2651 .gap_1p5()
2652 .overflow_hidden()
2653 .child(
2654 Icon::new(IconName::ToolThink)
2655 .size(IconSize::Small)
2656 .color(Color::Muted),
2657 )
2658 .child(
2659 div()
2660 .text_size(self.tool_name_font_size())
2661 .text_color(cx.theme().colors().text_muted)
2662 .child("Thinking"),
2663 ),
2664 )
2665 .child(
2666 Disclosure::new(("expand", entry_ix), is_open)
2667 .opened_icon(IconName::ChevronUp)
2668 .closed_icon(IconName::ChevronDown)
2669 .visible_on_hover(&card_header_id)
2670 .on_click(cx.listener({
2671 move |this, _event, _window, cx| {
2672 if is_open {
2673 this.expanded_thinking_blocks.remove(&key);
2674 } else {
2675 this.expanded_thinking_blocks.insert(key);
2676 }
2677 cx.notify();
2678 }
2679 })),
2680 )
2681 .on_click(cx.listener({
2682 move |this, _event, _window, cx| {
2683 if is_open {
2684 this.expanded_thinking_blocks.remove(&key);
2685 } else {
2686 this.expanded_thinking_blocks.insert(key);
2687 }
2688 cx.notify();
2689 }
2690 })),
2691 )
2692 .when(is_open, |this| {
2693 this.child(
2694 div()
2695 .ml_1p5()
2696 .pl_3p5()
2697 .border_l_1()
2698 .border_color(self.tool_card_border_color(cx))
2699 .child(thinking_content),
2700 )
2701 })
2702 .into_any_element()
2703 }
2704
2705 fn render_tool_call(
2706 &self,
2707 entry_ix: usize,
2708 tool_call: &ToolCall,
2709 window: &Window,
2710 cx: &Context<Self>,
2711 ) -> Div {
2712 let has_location = tool_call.locations.len() == 1;
2713 let card_header_id = SharedString::from("inner-tool-call-header");
2714
2715 let failed_or_canceled = match &tool_call.status {
2716 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
2717 _ => false,
2718 };
2719
2720 let needs_confirmation = matches!(
2721 tool_call.status,
2722 ToolCallStatus::WaitingForConfirmation { .. }
2723 );
2724 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
2725 let is_edit =
2726 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
2727
2728 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
2729
2730 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
2731
2732 let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
2733 let input_output_header = |label: SharedString| {
2734 Label::new(label)
2735 .size(LabelSize::XSmall)
2736 .color(Color::Muted)
2737 .buffer_font(cx)
2738 };
2739
2740 let tool_output_display =
2741 if is_open {
2742 match &tool_call.status {
2743 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
2744 .w_full()
2745 .children(tool_call.content.iter().enumerate().map(
2746 |(content_ix, content)| {
2747 div()
2748 .child(self.render_tool_call_content(
2749 entry_ix,
2750 content,
2751 content_ix,
2752 tool_call,
2753 use_card_layout,
2754 window,
2755 cx,
2756 ))
2757 .into_any_element()
2758 },
2759 ))
2760 .child(self.render_permission_buttons(
2761 tool_call.kind,
2762 options,
2763 entry_ix,
2764 tool_call.id.clone(),
2765 cx,
2766 ))
2767 .into_any(),
2768 ToolCallStatus::Pending | ToolCallStatus::InProgress
2769 if is_edit
2770 && tool_call.content.is_empty()
2771 && self.as_native_connection(cx).is_some() =>
2772 {
2773 self.render_diff_loading(cx).into_any()
2774 }
2775 ToolCallStatus::Pending
2776 | ToolCallStatus::InProgress
2777 | ToolCallStatus::Completed
2778 | ToolCallStatus::Failed
2779 | ToolCallStatus::Canceled => v_flex()
2780 .when(!is_edit && !is_terminal_tool, |this| {
2781 this.mt_1p5().w_full().child(
2782 v_flex()
2783 .ml(rems(0.4))
2784 .px_3p5()
2785 .pb_1()
2786 .gap_1()
2787 .border_l_1()
2788 .border_color(self.tool_card_border_color(cx))
2789 .child(input_output_header("Raw Input:".into()))
2790 .children(tool_call.raw_input_markdown.clone().map(|input| {
2791 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
2792 self.render_markdown(
2793 input,
2794 default_markdown_style(false, false, window, cx),
2795 ),
2796 )
2797 }))
2798 .child(input_output_header("Output:".into())),
2799 )
2800 })
2801 .children(tool_call.content.iter().enumerate().map(
2802 |(content_ix, content)| {
2803 div().id(("tool-call-output", entry_ix)).child(
2804 self.render_tool_call_content(
2805 entry_ix,
2806 content,
2807 content_ix,
2808 tool_call,
2809 use_card_layout,
2810 window,
2811 cx,
2812 ),
2813 )
2814 },
2815 ))
2816 .into_any(),
2817 ToolCallStatus::Rejected => Empty.into_any(),
2818 }
2819 .into()
2820 } else {
2821 None
2822 };
2823
2824 v_flex()
2825 .map(|this| {
2826 if use_card_layout {
2827 this.my_1p5()
2828 .rounded_md()
2829 .border_1()
2830 .border_color(self.tool_card_border_color(cx))
2831 .bg(cx.theme().colors().editor_background)
2832 .overflow_hidden()
2833 } else {
2834 this.my_1()
2835 }
2836 })
2837 .map(|this| {
2838 if has_location && !use_card_layout {
2839 this.ml_4()
2840 } else {
2841 this.ml_5()
2842 }
2843 })
2844 .mr_5()
2845 .map(|this| {
2846 if is_terminal_tool {
2847 this.child(
2848 v_flex()
2849 .p_1p5()
2850 .gap_0p5()
2851 .text_ui_sm(cx)
2852 .bg(self.tool_card_header_bg(cx))
2853 .child(
2854 Label::new("Run Command")
2855 .buffer_font(cx)
2856 .size(LabelSize::XSmall)
2857 .color(Color::Muted),
2858 )
2859 .child(
2860 MarkdownElement::new(
2861 tool_call.label.clone(),
2862 terminal_command_markdown_style(window, cx),
2863 )
2864 .code_block_renderer(
2865 markdown::CodeBlockRenderer::Default {
2866 copy_button: false,
2867 copy_button_on_hover: false,
2868 border: false,
2869 },
2870 )
2871 ),
2872 )
2873 } else {
2874 this.child(
2875 h_flex()
2876 .group(&card_header_id)
2877 .relative()
2878 .w_full()
2879 .gap_1()
2880 .justify_between()
2881 .when(use_card_layout, |this| {
2882 this.p_0p5()
2883 .rounded_t(rems_from_px(5.))
2884 .bg(self.tool_card_header_bg(cx))
2885 })
2886 .child(self.render_tool_call_label(
2887 entry_ix,
2888 tool_call,
2889 is_edit,
2890 use_card_layout,
2891 window,
2892 cx,
2893 ))
2894 .when(is_collapsible || failed_or_canceled, |this| {
2895 this.child(
2896 h_flex()
2897 .px_1()
2898 .gap_px()
2899 .when(is_collapsible, |this| {
2900 this.child(
2901 Disclosure::new(("expand-output", entry_ix), is_open)
2902 .opened_icon(IconName::ChevronUp)
2903 .closed_icon(IconName::ChevronDown)
2904 .visible_on_hover(&card_header_id)
2905 .on_click(cx.listener({
2906 let id = tool_call.id.clone();
2907 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
2908 if is_open {
2909 this.expanded_tool_calls.remove(&id);
2910 } else {
2911 this.expanded_tool_calls.insert(id.clone());
2912 }
2913 cx.notify();
2914 }
2915 })),
2916 )
2917 })
2918 .when(failed_or_canceled, |this| {
2919 this.child(
2920 Icon::new(IconName::Close)
2921 .color(Color::Error)
2922 .size(IconSize::Small),
2923 )
2924 }),
2925 )
2926 }),
2927 )
2928 }
2929 })
2930 .children(tool_output_display)
2931 }
2932
2933 fn render_tool_call_label(
2934 &self,
2935 entry_ix: usize,
2936 tool_call: &ToolCall,
2937 is_edit: bool,
2938 use_card_layout: bool,
2939 window: &Window,
2940 cx: &Context<Self>,
2941 ) -> Div {
2942 let has_location = tool_call.locations.len() == 1;
2943
2944 let tool_icon = if tool_call.kind == acp::ToolKind::Edit && has_location {
2945 FileIcons::get_icon(&tool_call.locations[0].path, cx)
2946 .map(Icon::from_path)
2947 .unwrap_or(Icon::new(IconName::ToolPencil))
2948 } else {
2949 Icon::new(match tool_call.kind {
2950 acp::ToolKind::Read => IconName::ToolSearch,
2951 acp::ToolKind::Edit => IconName::ToolPencil,
2952 acp::ToolKind::Delete => IconName::ToolDeleteFile,
2953 acp::ToolKind::Move => IconName::ArrowRightLeft,
2954 acp::ToolKind::Search => IconName::ToolSearch,
2955 acp::ToolKind::Execute => IconName::ToolTerminal,
2956 acp::ToolKind::Think => IconName::ToolThink,
2957 acp::ToolKind::Fetch => IconName::ToolWeb,
2958 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
2959 acp::ToolKind::Other | _ => IconName::ToolHammer,
2960 })
2961 }
2962 .size(IconSize::Small)
2963 .color(Color::Muted);
2964
2965 let gradient_overlay = {
2966 div()
2967 .absolute()
2968 .top_0()
2969 .right_0()
2970 .w_12()
2971 .h_full()
2972 .map(|this| {
2973 if use_card_layout {
2974 this.bg(linear_gradient(
2975 90.,
2976 linear_color_stop(self.tool_card_header_bg(cx), 1.),
2977 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
2978 ))
2979 } else {
2980 this.bg(linear_gradient(
2981 90.,
2982 linear_color_stop(cx.theme().colors().panel_background, 1.),
2983 linear_color_stop(
2984 cx.theme().colors().panel_background.opacity(0.2),
2985 0.,
2986 ),
2987 ))
2988 }
2989 })
2990 };
2991
2992 h_flex()
2993 .relative()
2994 .w_full()
2995 .h(window.line_height() - px(2.))
2996 .text_size(self.tool_name_font_size())
2997 .gap_1p5()
2998 .when(has_location || use_card_layout, |this| this.px_1())
2999 .when(has_location, |this| {
3000 this.cursor(CursorStyle::PointingHand)
3001 .rounded(rems_from_px(3.)) // Concentric border radius
3002 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
3003 })
3004 .overflow_hidden()
3005 .child(tool_icon)
3006 .child(if has_location {
3007 h_flex()
3008 .id(("open-tool-call-location", entry_ix))
3009 .w_full()
3010 .map(|this| {
3011 if use_card_layout {
3012 this.text_color(cx.theme().colors().text)
3013 } else {
3014 this.text_color(cx.theme().colors().text_muted)
3015 }
3016 })
3017 .child(self.render_markdown(
3018 tool_call.label.clone(),
3019 MarkdownStyle {
3020 prevent_mouse_interaction: true,
3021 ..default_markdown_style(false, true, window, cx)
3022 },
3023 ))
3024 .tooltip(Tooltip::text("Go to File"))
3025 .on_click(cx.listener(move |this, _, window, cx| {
3026 this.open_tool_call_location(entry_ix, 0, window, cx);
3027 }))
3028 .into_any_element()
3029 } else {
3030 h_flex()
3031 .w_full()
3032 .child(self.render_markdown(
3033 tool_call.label.clone(),
3034 default_markdown_style(false, true, window, cx),
3035 ))
3036 .into_any()
3037 })
3038 .when(!is_edit, |this| this.child(gradient_overlay))
3039 }
3040
3041 fn render_tool_call_content(
3042 &self,
3043 entry_ix: usize,
3044 content: &ToolCallContent,
3045 context_ix: usize,
3046 tool_call: &ToolCall,
3047 card_layout: bool,
3048 window: &Window,
3049 cx: &Context<Self>,
3050 ) -> AnyElement {
3051 match content {
3052 ToolCallContent::ContentBlock(content) => {
3053 if let Some(resource_link) = content.resource_link() {
3054 self.render_resource_link(resource_link, cx)
3055 } else if let Some(markdown) = content.markdown() {
3056 self.render_markdown_output(
3057 markdown.clone(),
3058 tool_call.id.clone(),
3059 context_ix,
3060 card_layout,
3061 window,
3062 cx,
3063 )
3064 } else {
3065 Empty.into_any_element()
3066 }
3067 }
3068 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx),
3069 ToolCallContent::Terminal(terminal) => {
3070 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
3071 }
3072 }
3073 }
3074
3075 fn render_markdown_output(
3076 &self,
3077 markdown: Entity<Markdown>,
3078 tool_call_id: acp::ToolCallId,
3079 context_ix: usize,
3080 card_layout: bool,
3081 window: &Window,
3082 cx: &Context<Self>,
3083 ) -> AnyElement {
3084 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
3085
3086 v_flex()
3087 .gap_2()
3088 .map(|this| {
3089 if card_layout {
3090 this.when(context_ix > 0, |this| {
3091 this.pt_2()
3092 .border_t_1()
3093 .border_color(self.tool_card_border_color(cx))
3094 })
3095 } else {
3096 this.ml(rems(0.4))
3097 .px_3p5()
3098 .border_l_1()
3099 .border_color(self.tool_card_border_color(cx))
3100 }
3101 })
3102 .text_xs()
3103 .text_color(cx.theme().colors().text_muted)
3104 .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
3105 .when(!card_layout, |this| {
3106 this.child(
3107 IconButton::new(button_id, IconName::ChevronUp)
3108 .full_width()
3109 .style(ButtonStyle::Outlined)
3110 .icon_color(Color::Muted)
3111 .on_click(cx.listener({
3112 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
3113 this.expanded_tool_calls.remove(&tool_call_id);
3114 cx.notify();
3115 }
3116 })),
3117 )
3118 })
3119 .into_any_element()
3120 }
3121
3122 fn render_resource_link(
3123 &self,
3124 resource_link: &acp::ResourceLink,
3125 cx: &Context<Self>,
3126 ) -> AnyElement {
3127 let uri: SharedString = resource_link.uri.clone().into();
3128 let is_file = resource_link.uri.strip_prefix("file://");
3129
3130 let label: SharedString = if let Some(abs_path) = is_file {
3131 if let Some(project_path) = self
3132 .project
3133 .read(cx)
3134 .project_path_for_absolute_path(&Path::new(abs_path), cx)
3135 && let Some(worktree) = self
3136 .project
3137 .read(cx)
3138 .worktree_for_id(project_path.worktree_id, cx)
3139 {
3140 worktree
3141 .read(cx)
3142 .full_path(&project_path.path)
3143 .to_string_lossy()
3144 .to_string()
3145 .into()
3146 } else {
3147 abs_path.to_string().into()
3148 }
3149 } else {
3150 uri.clone()
3151 };
3152
3153 let button_id = SharedString::from(format!("item-{}", uri));
3154
3155 div()
3156 .ml(rems(0.4))
3157 .pl_2p5()
3158 .border_l_1()
3159 .border_color(self.tool_card_border_color(cx))
3160 .overflow_hidden()
3161 .child(
3162 Button::new(button_id, label)
3163 .label_size(LabelSize::Small)
3164 .color(Color::Muted)
3165 .truncate(true)
3166 .when(is_file.is_none(), |this| {
3167 this.icon(IconName::ArrowUpRight)
3168 .icon_size(IconSize::XSmall)
3169 .icon_color(Color::Muted)
3170 })
3171 .on_click(cx.listener({
3172 let workspace = self.workspace.clone();
3173 move |_, _, window, cx: &mut Context<Self>| {
3174 Self::open_link(uri.clone(), &workspace, window, cx);
3175 }
3176 })),
3177 )
3178 .into_any_element()
3179 }
3180
3181 fn render_permission_buttons(
3182 &self,
3183 kind: acp::ToolKind,
3184 options: &[acp::PermissionOption],
3185 entry_ix: usize,
3186 tool_call_id: acp::ToolCallId,
3187 cx: &Context<Self>,
3188 ) -> Div {
3189 let is_first = self.thread().is_some_and(|thread| {
3190 thread
3191 .read(cx)
3192 .first_tool_awaiting_confirmation()
3193 .is_some_and(|call| call.id == tool_call_id)
3194 });
3195 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3> = ArrayVec::new();
3196
3197 div()
3198 .p_1()
3199 .border_t_1()
3200 .border_color(self.tool_card_border_color(cx))
3201 .w_full()
3202 .map(|this| {
3203 if kind == acp::ToolKind::SwitchMode {
3204 this.v_flex()
3205 } else {
3206 this.h_flex().justify_end().flex_wrap()
3207 }
3208 })
3209 .gap_0p5()
3210 .children(options.iter().map(move |option| {
3211 let option_id = SharedString::from(option.option_id.0.clone());
3212 Button::new((option_id, entry_ix), option.name.clone())
3213 .map(|this| {
3214 let (this, action) = match option.kind {
3215 acp::PermissionOptionKind::AllowOnce => (
3216 this.icon(IconName::Check).icon_color(Color::Success),
3217 Some(&AllowOnce as &dyn Action),
3218 ),
3219 acp::PermissionOptionKind::AllowAlways => (
3220 this.icon(IconName::CheckDouble).icon_color(Color::Success),
3221 Some(&AllowAlways as &dyn Action),
3222 ),
3223 acp::PermissionOptionKind::RejectOnce => (
3224 this.icon(IconName::Close).icon_color(Color::Error),
3225 Some(&RejectOnce as &dyn Action),
3226 ),
3227 acp::PermissionOptionKind::RejectAlways | _ => {
3228 (this.icon(IconName::Close).icon_color(Color::Error), None)
3229 }
3230 };
3231
3232 let Some(action) = action else {
3233 return this;
3234 };
3235
3236 if !is_first || seen_kinds.contains(&option.kind) {
3237 return this;
3238 }
3239
3240 seen_kinds.push(option.kind);
3241
3242 this.key_binding(
3243 KeyBinding::for_action_in(action, &self.focus_handle, cx)
3244 .map(|kb| kb.size(rems_from_px(10.))),
3245 )
3246 })
3247 .icon_position(IconPosition::Start)
3248 .icon_size(IconSize::XSmall)
3249 .label_size(LabelSize::Small)
3250 .on_click(cx.listener({
3251 let tool_call_id = tool_call_id.clone();
3252 let option_id = option.option_id.clone();
3253 let option_kind = option.kind;
3254 move |this, _, window, cx| {
3255 this.authorize_tool_call(
3256 tool_call_id.clone(),
3257 option_id.clone(),
3258 option_kind,
3259 window,
3260 cx,
3261 );
3262 }
3263 }))
3264 }))
3265 }
3266
3267 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
3268 let bar = |n: u64, width_class: &str| {
3269 let bg_color = cx.theme().colors().element_active;
3270 let base = h_flex().h_1().rounded_full();
3271
3272 let modified = match width_class {
3273 "w_4_5" => base.w_3_4(),
3274 "w_1_4" => base.w_1_4(),
3275 "w_2_4" => base.w_2_4(),
3276 "w_3_5" => base.w_3_5(),
3277 "w_2_5" => base.w_2_5(),
3278 _ => base.w_1_2(),
3279 };
3280
3281 modified.with_animation(
3282 ElementId::Integer(n),
3283 Animation::new(Duration::from_secs(2)).repeat(),
3284 move |tab, delta| {
3285 let delta = (delta - 0.15 * n as f32) / 0.7;
3286 let delta = 1.0 - (0.5 - delta).abs() * 2.;
3287 let delta = ease_in_out(delta.clamp(0., 1.));
3288 let delta = 0.1 + 0.9 * delta;
3289
3290 tab.bg(bg_color.opacity(delta))
3291 },
3292 )
3293 };
3294
3295 v_flex()
3296 .p_3()
3297 .gap_1()
3298 .rounded_b_md()
3299 .bg(cx.theme().colors().editor_background)
3300 .child(bar(0, "w_4_5"))
3301 .child(bar(1, "w_1_4"))
3302 .child(bar(2, "w_2_4"))
3303 .child(bar(3, "w_3_5"))
3304 .child(bar(4, "w_2_5"))
3305 .into_any_element()
3306 }
3307
3308 fn render_diff_editor(
3309 &self,
3310 entry_ix: usize,
3311 diff: &Entity<acp_thread::Diff>,
3312 tool_call: &ToolCall,
3313 cx: &Context<Self>,
3314 ) -> AnyElement {
3315 let tool_progress = matches!(
3316 &tool_call.status,
3317 ToolCallStatus::InProgress | ToolCallStatus::Pending
3318 );
3319
3320 v_flex()
3321 .h_full()
3322 .border_t_1()
3323 .border_color(self.tool_card_border_color(cx))
3324 .child(
3325 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
3326 && let Some(editor) = entry.editor_for_diff(diff)
3327 && diff.read(cx).has_revealed_range(cx)
3328 {
3329 editor.into_any_element()
3330 } else if tool_progress && self.as_native_connection(cx).is_some() {
3331 self.render_diff_loading(cx)
3332 } else {
3333 Empty.into_any()
3334 },
3335 )
3336 .into_any()
3337 }
3338
3339 fn render_terminal_tool_call(
3340 &self,
3341 entry_ix: usize,
3342 terminal: &Entity<acp_thread::Terminal>,
3343 tool_call: &ToolCall,
3344 window: &Window,
3345 cx: &Context<Self>,
3346 ) -> AnyElement {
3347 let terminal_data = terminal.read(cx);
3348 let working_dir = terminal_data.working_dir();
3349 let command = terminal_data.command();
3350 let started_at = terminal_data.started_at();
3351
3352 let tool_failed = matches!(
3353 &tool_call.status,
3354 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
3355 );
3356
3357 let output = terminal_data.output();
3358 let command_finished = output.is_some();
3359 let truncated_output =
3360 output.is_some_and(|output| output.original_content_len > output.content.len());
3361 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
3362
3363 let command_failed = command_finished
3364 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
3365
3366 let time_elapsed = if let Some(output) = output {
3367 output.ended_at.duration_since(started_at)
3368 } else {
3369 started_at.elapsed()
3370 };
3371
3372 let header_id =
3373 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
3374 let header_group = SharedString::from(format!(
3375 "terminal-tool-header-group-{}",
3376 terminal.entity_id()
3377 ));
3378 let header_bg = cx
3379 .theme()
3380 .colors()
3381 .element_background
3382 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
3383 let border_color = cx.theme().colors().border.opacity(0.6);
3384
3385 let working_dir = working_dir
3386 .as_ref()
3387 .map(|path| path.display().to_string())
3388 .unwrap_or_else(|| "current directory".to_string());
3389
3390 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
3391
3392 let header = h_flex()
3393 .id(header_id)
3394 .flex_none()
3395 .gap_1()
3396 .justify_between()
3397 .rounded_t_md()
3398 .child(
3399 div()
3400 .id(("command-target-path", terminal.entity_id()))
3401 .w_full()
3402 .max_w_full()
3403 .overflow_x_scroll()
3404 .child(
3405 Label::new(working_dir)
3406 .buffer_font(cx)
3407 .size(LabelSize::XSmall)
3408 .color(Color::Muted),
3409 ),
3410 )
3411 .when(!command_finished, |header| {
3412 header
3413 .gap_1p5()
3414 .child(
3415 Button::new(
3416 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
3417 "Stop",
3418 )
3419 .icon(IconName::Stop)
3420 .icon_position(IconPosition::Start)
3421 .icon_size(IconSize::Small)
3422 .icon_color(Color::Error)
3423 .label_size(LabelSize::Small)
3424 .tooltip(move |_window, cx| {
3425 Tooltip::with_meta(
3426 "Stop This Command",
3427 None,
3428 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
3429 cx,
3430 )
3431 })
3432 .on_click({
3433 let terminal = terminal.clone();
3434 cx.listener(move |_this, _event, _window, cx| {
3435 let inner_terminal = terminal.read(cx).inner().clone();
3436 inner_terminal.update(cx, |inner_terminal, _cx| {
3437 inner_terminal.kill_active_task();
3438 });
3439 })
3440 }),
3441 )
3442 .child(Divider::vertical())
3443 .child(
3444 Icon::new(IconName::ArrowCircle)
3445 .size(IconSize::XSmall)
3446 .color(Color::Info)
3447 .with_rotate_animation(2)
3448 )
3449 })
3450 .when(truncated_output, |header| {
3451 let tooltip = if let Some(output) = output {
3452 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
3453 format!("Output exceeded terminal max lines and was \
3454 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
3455 } else {
3456 format!(
3457 "Output is {} long, and to avoid unexpected token usage, \
3458 only {} was sent back to the agent.",
3459 format_file_size(output.original_content_len as u64, true),
3460 format_file_size(output.content.len() as u64, true)
3461 )
3462 }
3463 } else {
3464 "Output was truncated".to_string()
3465 };
3466
3467 header.child(
3468 h_flex()
3469 .id(("terminal-tool-truncated-label", terminal.entity_id()))
3470 .gap_1()
3471 .child(
3472 Icon::new(IconName::Info)
3473 .size(IconSize::XSmall)
3474 .color(Color::Ignored),
3475 )
3476 .child(
3477 Label::new("Truncated")
3478 .color(Color::Muted)
3479 .size(LabelSize::XSmall),
3480 )
3481 .tooltip(Tooltip::text(tooltip)),
3482 )
3483 })
3484 .when(time_elapsed > Duration::from_secs(10), |header| {
3485 header.child(
3486 Label::new(format!("({})", duration_alt_display(time_elapsed)))
3487 .buffer_font(cx)
3488 .color(Color::Muted)
3489 .size(LabelSize::XSmall),
3490 )
3491 })
3492 .when(tool_failed || command_failed, |header| {
3493 header.child(
3494 div()
3495 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
3496 .child(
3497 Icon::new(IconName::Close)
3498 .size(IconSize::Small)
3499 .color(Color::Error),
3500 )
3501 .when_some(output.and_then(|o| o.exit_status), |this, status| {
3502 this.tooltip(Tooltip::text(format!(
3503 "Exited with code {}",
3504 status.code().unwrap_or(-1),
3505 )))
3506 }),
3507 )
3508 })
3509 .child(
3510 Disclosure::new(
3511 SharedString::from(format!(
3512 "terminal-tool-disclosure-{}",
3513 terminal.entity_id()
3514 )),
3515 is_expanded,
3516 )
3517 .opened_icon(IconName::ChevronUp)
3518 .closed_icon(IconName::ChevronDown)
3519 .visible_on_hover(&header_group)
3520 .on_click(cx.listener({
3521 let id = tool_call.id.clone();
3522 move |this, _event, _window, _cx| {
3523 if is_expanded {
3524 this.expanded_tool_calls.remove(&id);
3525 } else {
3526 this.expanded_tool_calls.insert(id.clone());
3527 }
3528 }
3529 })),
3530 );
3531
3532 let terminal_view = self
3533 .entry_view_state
3534 .read(cx)
3535 .entry(entry_ix)
3536 .and_then(|entry| entry.terminal(terminal));
3537 let show_output = is_expanded && terminal_view.is_some();
3538
3539 v_flex()
3540 .my_1p5()
3541 .mx_5()
3542 .border_1()
3543 .when(tool_failed || command_failed, |card| card.border_dashed())
3544 .border_color(border_color)
3545 .rounded_md()
3546 .overflow_hidden()
3547 .child(
3548 v_flex()
3549 .group(&header_group)
3550 .py_1p5()
3551 .pr_1p5()
3552 .pl_2()
3553 .gap_0p5()
3554 .bg(header_bg)
3555 .text_xs()
3556 .child(header)
3557 .child(
3558 MarkdownElement::new(
3559 command.clone(),
3560 terminal_command_markdown_style(window, cx),
3561 )
3562 .code_block_renderer(
3563 markdown::CodeBlockRenderer::Default {
3564 copy_button: false,
3565 copy_button_on_hover: true,
3566 border: false,
3567 },
3568 ),
3569 ),
3570 )
3571 .when(show_output, |this| {
3572 this.child(
3573 div()
3574 .pt_2()
3575 .border_t_1()
3576 .when(tool_failed || command_failed, |card| card.border_dashed())
3577 .border_color(border_color)
3578 .bg(cx.theme().colors().editor_background)
3579 .rounded_b_md()
3580 .text_ui_sm(cx)
3581 .h_full()
3582 .children(terminal_view.map(|terminal_view| {
3583 let element = if terminal_view
3584 .read(cx)
3585 .content_mode(window, cx)
3586 .is_scrollable()
3587 {
3588 div().h_72().child(terminal_view).into_any_element()
3589 } else {
3590 terminal_view.into_any_element()
3591 };
3592
3593 div()
3594 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
3595 window.dispatch_action(NewThread.boxed_clone(), cx);
3596 cx.stop_propagation();
3597 }))
3598 .child(element)
3599 .into_any_element()
3600 })),
3601 )
3602 })
3603 .into_any()
3604 }
3605
3606 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
3607 let project_context = self
3608 .as_native_thread(cx)?
3609 .read(cx)
3610 .project_context()
3611 .read(cx);
3612
3613 let user_rules_text = if project_context.user_rules.is_empty() {
3614 None
3615 } else if project_context.user_rules.len() == 1 {
3616 let user_rules = &project_context.user_rules[0];
3617
3618 match user_rules.title.as_ref() {
3619 Some(title) => Some(format!("Using \"{title}\" user rule")),
3620 None => Some("Using user rule".into()),
3621 }
3622 } else {
3623 Some(format!(
3624 "Using {} user rules",
3625 project_context.user_rules.len()
3626 ))
3627 };
3628
3629 let first_user_rules_id = project_context
3630 .user_rules
3631 .first()
3632 .map(|user_rules| user_rules.uuid.0);
3633
3634 let rules_files = project_context
3635 .worktrees
3636 .iter()
3637 .filter_map(|worktree| worktree.rules_file.as_ref())
3638 .collect::<Vec<_>>();
3639
3640 let rules_file_text = match rules_files.as_slice() {
3641 &[] => None,
3642 &[rules_file] => Some(format!(
3643 "Using project {:?} file",
3644 rules_file.path_in_worktree
3645 )),
3646 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
3647 };
3648
3649 if user_rules_text.is_none() && rules_file_text.is_none() {
3650 return None;
3651 }
3652
3653 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
3654
3655 Some(
3656 h_flex()
3657 .px_2p5()
3658 .child(
3659 Icon::new(IconName::Attach)
3660 .size(IconSize::XSmall)
3661 .color(Color::Disabled),
3662 )
3663 .when_some(user_rules_text, |parent, user_rules_text| {
3664 parent.child(
3665 h_flex()
3666 .id("user-rules")
3667 .ml_1()
3668 .mr_1p5()
3669 .child(
3670 Label::new(user_rules_text)
3671 .size(LabelSize::XSmall)
3672 .color(Color::Muted)
3673 .truncate(),
3674 )
3675 .hover(|s| s.bg(cx.theme().colors().element_hover))
3676 .tooltip(Tooltip::text("View User Rules"))
3677 .on_click(move |_event, window, cx| {
3678 window.dispatch_action(
3679 Box::new(OpenRulesLibrary {
3680 prompt_to_select: first_user_rules_id,
3681 }),
3682 cx,
3683 )
3684 }),
3685 )
3686 })
3687 .when(has_both, |this| {
3688 this.child(
3689 Label::new("•")
3690 .size(LabelSize::XSmall)
3691 .color(Color::Disabled),
3692 )
3693 })
3694 .when_some(rules_file_text, |parent, rules_file_text| {
3695 parent.child(
3696 h_flex()
3697 .id("project-rules")
3698 .ml_1p5()
3699 .child(
3700 Label::new(rules_file_text)
3701 .size(LabelSize::XSmall)
3702 .color(Color::Muted),
3703 )
3704 .hover(|s| s.bg(cx.theme().colors().element_hover))
3705 .tooltip(Tooltip::text("View Project Rules"))
3706 .on_click(cx.listener(Self::handle_open_rules)),
3707 )
3708 })
3709 .into_any(),
3710 )
3711 }
3712
3713 fn render_empty_state_section_header(
3714 &self,
3715 label: impl Into<SharedString>,
3716 action_slot: Option<AnyElement>,
3717 cx: &mut Context<Self>,
3718 ) -> impl IntoElement {
3719 div().pl_1().pr_1p5().child(
3720 h_flex()
3721 .mt_2()
3722 .pl_1p5()
3723 .pb_1()
3724 .w_full()
3725 .justify_between()
3726 .border_b_1()
3727 .border_color(cx.theme().colors().border_variant)
3728 .child(
3729 Label::new(label.into())
3730 .size(LabelSize::Small)
3731 .color(Color::Muted),
3732 )
3733 .children(action_slot),
3734 )
3735 }
3736
3737 fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
3738 let render_history = self
3739 .agent
3740 .clone()
3741 .downcast::<agent::NativeAgentServer>()
3742 .is_some()
3743 && self
3744 .history_store
3745 .update(cx, |history_store, cx| !history_store.is_empty(cx));
3746
3747 v_flex()
3748 .size_full()
3749 .when(render_history, |this| {
3750 let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| {
3751 history_store.entries().take(3).collect()
3752 });
3753 this.justify_end().child(
3754 v_flex()
3755 .child(
3756 self.render_empty_state_section_header(
3757 "Recent",
3758 Some(
3759 Button::new("view-history", "View All")
3760 .style(ButtonStyle::Subtle)
3761 .label_size(LabelSize::Small)
3762 .key_binding(
3763 KeyBinding::for_action_in(
3764 &OpenHistory,
3765 &self.focus_handle(cx),
3766 cx,
3767 )
3768 .map(|kb| kb.size(rems_from_px(12.))),
3769 )
3770 .on_click(move |_event, window, cx| {
3771 window.dispatch_action(OpenHistory.boxed_clone(), cx);
3772 })
3773 .into_any_element(),
3774 ),
3775 cx,
3776 ),
3777 )
3778 .child(
3779 v_flex().p_1().pr_1p5().gap_1().children(
3780 recent_history
3781 .into_iter()
3782 .enumerate()
3783 .map(|(index, entry)| {
3784 // TODO: Add keyboard navigation.
3785 let is_hovered =
3786 self.hovered_recent_history_item == Some(index);
3787 crate::acp::thread_history::AcpHistoryEntryElement::new(
3788 entry,
3789 cx.entity().downgrade(),
3790 )
3791 .hovered(is_hovered)
3792 .on_hover(cx.listener(
3793 move |this, is_hovered, _window, cx| {
3794 if *is_hovered {
3795 this.hovered_recent_history_item = Some(index);
3796 } else if this.hovered_recent_history_item
3797 == Some(index)
3798 {
3799 this.hovered_recent_history_item = None;
3800 }
3801 cx.notify();
3802 },
3803 ))
3804 .into_any_element()
3805 }),
3806 ),
3807 ),
3808 )
3809 })
3810 .into_any()
3811 }
3812
3813 fn render_auth_required_state(
3814 &self,
3815 connection: &Rc<dyn AgentConnection>,
3816 description: Option<&Entity<Markdown>>,
3817 configuration_view: Option<&AnyView>,
3818 pending_auth_method: Option<&acp::AuthMethodId>,
3819 window: &mut Window,
3820 cx: &Context<Self>,
3821 ) -> impl IntoElement {
3822 let auth_methods = connection.auth_methods();
3823
3824 let agent_display_name = self
3825 .agent_server_store
3826 .read(cx)
3827 .agent_display_name(&ExternalAgentServerName(self.agent.name()))
3828 .unwrap_or_else(|| self.agent.name());
3829
3830 let show_fallback_description = auth_methods.len() > 1
3831 && configuration_view.is_none()
3832 && description.is_none()
3833 && pending_auth_method.is_none();
3834
3835 let auth_buttons = || {
3836 h_flex().justify_end().flex_wrap().gap_1().children(
3837 connection
3838 .auth_methods()
3839 .iter()
3840 .enumerate()
3841 .rev()
3842 .map(|(ix, method)| {
3843 let (method_id, name) = if self.project.read(cx).is_via_remote_server()
3844 && method.id.0.as_ref() == "oauth-personal"
3845 && method.name == "Log in with Google"
3846 {
3847 ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
3848 } else {
3849 (method.id.0.clone(), method.name.clone())
3850 };
3851
3852 let agent_telemetry_id = connection.telemetry_id();
3853
3854 Button::new(method_id.clone(), name)
3855 .label_size(LabelSize::Small)
3856 .map(|this| {
3857 if ix == 0 {
3858 this.style(ButtonStyle::Tinted(TintColor::Accent))
3859 } else {
3860 this.style(ButtonStyle::Outlined)
3861 }
3862 })
3863 .when_some(method.description.clone(), |this, description| {
3864 this.tooltip(Tooltip::text(description))
3865 })
3866 .on_click({
3867 cx.listener(move |this, _, window, cx| {
3868 telemetry::event!(
3869 "Authenticate Agent Started",
3870 agent = agent_telemetry_id,
3871 method = method_id
3872 );
3873
3874 this.authenticate(
3875 acp::AuthMethodId::new(method_id.clone()),
3876 window,
3877 cx,
3878 )
3879 })
3880 })
3881 }),
3882 )
3883 };
3884
3885 if pending_auth_method.is_some() {
3886 return Callout::new()
3887 .icon(IconName::Info)
3888 .title(format!("Authenticating to {}…", agent_display_name))
3889 .actions_slot(
3890 Icon::new(IconName::ArrowCircle)
3891 .size(IconSize::Small)
3892 .color(Color::Muted)
3893 .with_rotate_animation(2)
3894 .into_any_element(),
3895 )
3896 .into_any_element();
3897 }
3898
3899 Callout::new()
3900 .icon(IconName::Info)
3901 .title(format!("Authenticate to {}", agent_display_name))
3902 .when(auth_methods.len() == 1, |this| {
3903 this.actions_slot(auth_buttons())
3904 })
3905 .description_slot(
3906 v_flex()
3907 .text_ui(cx)
3908 .map(|this| {
3909 if show_fallback_description {
3910 this.child(
3911 Label::new("Choose one of the following authentication options:")
3912 .size(LabelSize::Small)
3913 .color(Color::Muted),
3914 )
3915 } else {
3916 this.children(
3917 configuration_view
3918 .cloned()
3919 .map(|view| div().w_full().child(view)),
3920 )
3921 .children(description.map(|desc| {
3922 self.render_markdown(
3923 desc.clone(),
3924 default_markdown_style(false, false, window, cx),
3925 )
3926 }))
3927 }
3928 })
3929 .when(auth_methods.len() > 1, |this| {
3930 this.gap_1().child(auth_buttons())
3931 }),
3932 )
3933 .into_any_element()
3934 }
3935
3936 fn render_load_error(
3937 &self,
3938 e: &LoadError,
3939 window: &mut Window,
3940 cx: &mut Context<Self>,
3941 ) -> AnyElement {
3942 let (title, message, action_slot): (_, SharedString, _) = match e {
3943 LoadError::Unsupported {
3944 command: path,
3945 current_version,
3946 minimum_version,
3947 } => {
3948 return self.render_unsupported(path, current_version, minimum_version, window, cx);
3949 }
3950 LoadError::FailedToInstall(msg) => (
3951 "Failed to Install",
3952 msg.into(),
3953 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3954 ),
3955 LoadError::Exited { status } => (
3956 "Failed to Launch",
3957 format!("Server exited with status {status}").into(),
3958 None,
3959 ),
3960 LoadError::Other(msg) => (
3961 "Failed to Launch",
3962 msg.into(),
3963 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3964 ),
3965 };
3966
3967 Callout::new()
3968 .severity(Severity::Error)
3969 .icon(IconName::XCircleFilled)
3970 .title(title)
3971 .description(message)
3972 .actions_slot(div().children(action_slot))
3973 .into_any_element()
3974 }
3975
3976 fn render_unsupported(
3977 &self,
3978 path: &SharedString,
3979 version: &SharedString,
3980 minimum_version: &SharedString,
3981 _window: &mut Window,
3982 cx: &mut Context<Self>,
3983 ) -> AnyElement {
3984 let (heading_label, description_label) = (
3985 format!("Upgrade {} to work with Zed", self.agent.name()),
3986 if version.is_empty() {
3987 format!(
3988 "Currently using {}, which does not report a valid --version",
3989 path,
3990 )
3991 } else {
3992 format!(
3993 "Currently using {}, which is only version {} (need at least {minimum_version})",
3994 path, version
3995 )
3996 },
3997 );
3998
3999 v_flex()
4000 .w_full()
4001 .p_3p5()
4002 .gap_2p5()
4003 .border_t_1()
4004 .border_color(cx.theme().colors().border)
4005 .bg(linear_gradient(
4006 180.,
4007 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
4008 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
4009 ))
4010 .child(
4011 v_flex().gap_0p5().child(Label::new(heading_label)).child(
4012 Label::new(description_label)
4013 .size(LabelSize::Small)
4014 .color(Color::Muted),
4015 ),
4016 )
4017 .into_any_element()
4018 }
4019
4020 fn activity_bar_bg(&self, cx: &Context<Self>) -> Hsla {
4021 let editor_bg_color = cx.theme().colors().editor_background;
4022 let active_color = cx.theme().colors().element_selected;
4023 editor_bg_color.blend(active_color.opacity(0.3))
4024 }
4025
4026 fn render_activity_bar(
4027 &self,
4028 thread_entity: &Entity<AcpThread>,
4029 window: &mut Window,
4030 cx: &Context<Self>,
4031 ) -> Option<AnyElement> {
4032 let thread = thread_entity.read(cx);
4033 let action_log = thread.action_log();
4034 let telemetry = ActionLogTelemetry::from(thread);
4035 let changed_buffers = action_log.read(cx).changed_buffers(cx);
4036 let plan = thread.plan();
4037
4038 if changed_buffers.is_empty() && plan.is_empty() && self.message_queue.is_empty() {
4039 return None;
4040 }
4041
4042 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
4043 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
4044 // be, which blocks you from being able to accept or reject edits. This switches the
4045 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
4046 // block you from using the panel.
4047 let pending_edits = false;
4048
4049 v_flex()
4050 .mt_1()
4051 .mx_2()
4052 .bg(self.activity_bar_bg(cx))
4053 .border_1()
4054 .border_b_0()
4055 .border_color(cx.theme().colors().border)
4056 .rounded_t_md()
4057 .shadow(vec![gpui::BoxShadow {
4058 color: gpui::black().opacity(0.15),
4059 offset: point(px(1.), px(-1.)),
4060 blur_radius: px(3.),
4061 spread_radius: px(0.),
4062 }])
4063 .when(!plan.is_empty(), |this| {
4064 this.child(self.render_plan_summary(plan, window, cx))
4065 .when(self.plan_expanded, |parent| {
4066 parent.child(self.render_plan_entries(plan, window, cx))
4067 })
4068 })
4069 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
4070 this.child(Divider::horizontal().color(DividerColor::Border))
4071 })
4072 .when(!changed_buffers.is_empty(), |this| {
4073 this.child(self.render_edits_summary(
4074 &changed_buffers,
4075 self.edits_expanded,
4076 pending_edits,
4077 cx,
4078 ))
4079 .when(self.edits_expanded, |parent| {
4080 parent.child(self.render_edited_files(
4081 action_log,
4082 telemetry,
4083 &changed_buffers,
4084 pending_edits,
4085 cx,
4086 ))
4087 })
4088 })
4089 .when(!self.message_queue.is_empty(), |this| {
4090 this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| {
4091 this.child(Divider::horizontal().color(DividerColor::Border))
4092 })
4093 .child(self.render_message_queue_summary(window, cx))
4094 .when(self.queue_expanded, |parent| {
4095 parent.child(self.render_message_queue_entries(window, cx))
4096 })
4097 })
4098 .into_any()
4099 .into()
4100 }
4101
4102 fn render_plan_summary(
4103 &self,
4104 plan: &Plan,
4105 window: &mut Window,
4106 cx: &Context<Self>,
4107 ) -> impl IntoElement {
4108 let stats = plan.stats();
4109
4110 let title = if let Some(entry) = stats.in_progress_entry
4111 && !self.plan_expanded
4112 {
4113 h_flex()
4114 .cursor_default()
4115 .relative()
4116 .w_full()
4117 .gap_1()
4118 .truncate()
4119 .child(
4120 Label::new("Current:")
4121 .size(LabelSize::Small)
4122 .color(Color::Muted),
4123 )
4124 .child(
4125 div()
4126 .text_xs()
4127 .text_color(cx.theme().colors().text_muted)
4128 .line_clamp(1)
4129 .child(MarkdownElement::new(
4130 entry.content.clone(),
4131 plan_label_markdown_style(&entry.status, window, cx),
4132 )),
4133 )
4134 .when(stats.pending > 0, |this| {
4135 this.child(
4136 h_flex()
4137 .absolute()
4138 .top_0()
4139 .right_0()
4140 .h_full()
4141 .child(div().min_w_8().h_full().bg(linear_gradient(
4142 90.,
4143 linear_color_stop(self.activity_bar_bg(cx), 1.),
4144 linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.),
4145 )))
4146 .child(
4147 div().pr_0p5().bg(self.activity_bar_bg(cx)).child(
4148 Label::new(format!("{} left", stats.pending))
4149 .size(LabelSize::Small)
4150 .color(Color::Muted),
4151 ),
4152 ),
4153 )
4154 })
4155 } else {
4156 let status_label = if stats.pending == 0 {
4157 "All Done".to_string()
4158 } else if stats.completed == 0 {
4159 format!("{} Tasks", plan.entries.len())
4160 } else {
4161 format!("{}/{}", stats.completed, plan.entries.len())
4162 };
4163
4164 h_flex()
4165 .w_full()
4166 .gap_1()
4167 .justify_between()
4168 .child(
4169 Label::new("Plan")
4170 .size(LabelSize::Small)
4171 .color(Color::Muted),
4172 )
4173 .child(
4174 Label::new(status_label)
4175 .size(LabelSize::Small)
4176 .color(Color::Muted)
4177 .mr_1(),
4178 )
4179 };
4180
4181 h_flex()
4182 .id("plan_summary")
4183 .p_1()
4184 .w_full()
4185 .gap_1()
4186 .when(self.plan_expanded, |this| {
4187 this.border_b_1().border_color(cx.theme().colors().border)
4188 })
4189 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
4190 .child(title)
4191 .on_click(cx.listener(|this, _, _, cx| {
4192 this.plan_expanded = !this.plan_expanded;
4193 cx.notify();
4194 }))
4195 }
4196
4197 fn render_plan_entries(
4198 &self,
4199 plan: &Plan,
4200 window: &mut Window,
4201 cx: &Context<Self>,
4202 ) -> impl IntoElement {
4203 v_flex()
4204 .id("plan_items_list")
4205 .max_h_40()
4206 .overflow_y_scroll()
4207 .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
4208 let element = h_flex()
4209 .py_1()
4210 .px_2()
4211 .gap_2()
4212 .justify_between()
4213 .bg(cx.theme().colors().editor_background)
4214 .when(index < plan.entries.len() - 1, |parent| {
4215 parent.border_color(cx.theme().colors().border).border_b_1()
4216 })
4217 .child(
4218 h_flex()
4219 .id(("plan_entry", index))
4220 .gap_1p5()
4221 .max_w_full()
4222 .overflow_x_scroll()
4223 .text_xs()
4224 .text_color(cx.theme().colors().text_muted)
4225 .child(match entry.status {
4226 acp::PlanEntryStatus::InProgress => {
4227 Icon::new(IconName::TodoProgress)
4228 .size(IconSize::Small)
4229 .color(Color::Accent)
4230 .with_rotate_animation(2)
4231 .into_any_element()
4232 }
4233 acp::PlanEntryStatus::Completed => {
4234 Icon::new(IconName::TodoComplete)
4235 .size(IconSize::Small)
4236 .color(Color::Success)
4237 .into_any_element()
4238 }
4239 acp::PlanEntryStatus::Pending | _ => {
4240 Icon::new(IconName::TodoPending)
4241 .size(IconSize::Small)
4242 .color(Color::Muted)
4243 .into_any_element()
4244 }
4245 })
4246 .child(MarkdownElement::new(
4247 entry.content.clone(),
4248 plan_label_markdown_style(&entry.status, window, cx),
4249 )),
4250 );
4251
4252 Some(element)
4253 }))
4254 .into_any_element()
4255 }
4256
4257 fn render_edits_summary(
4258 &self,
4259 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
4260 expanded: bool,
4261 pending_edits: bool,
4262 cx: &Context<Self>,
4263 ) -> Div {
4264 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
4265
4266 let focus_handle = self.focus_handle(cx);
4267
4268 h_flex()
4269 .p_1()
4270 .justify_between()
4271 .flex_wrap()
4272 .when(expanded, |this| {
4273 this.border_b_1().border_color(cx.theme().colors().border)
4274 })
4275 .child(
4276 h_flex()
4277 .id("edits-container")
4278 .cursor_pointer()
4279 .gap_1()
4280 .child(Disclosure::new("edits-disclosure", expanded))
4281 .map(|this| {
4282 if pending_edits {
4283 this.child(
4284 Label::new(format!(
4285 "Editing {} {}…",
4286 changed_buffers.len(),
4287 if changed_buffers.len() == 1 {
4288 "file"
4289 } else {
4290 "files"
4291 }
4292 ))
4293 .color(Color::Muted)
4294 .size(LabelSize::Small)
4295 .with_animation(
4296 "edit-label",
4297 Animation::new(Duration::from_secs(2))
4298 .repeat()
4299 .with_easing(pulsating_between(0.3, 0.7)),
4300 |label, delta| label.alpha(delta),
4301 ),
4302 )
4303 } else {
4304 this.child(
4305 Label::new("Edits")
4306 .size(LabelSize::Small)
4307 .color(Color::Muted),
4308 )
4309 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
4310 .child(
4311 Label::new(format!(
4312 "{} {}",
4313 changed_buffers.len(),
4314 if changed_buffers.len() == 1 {
4315 "file"
4316 } else {
4317 "files"
4318 }
4319 ))
4320 .size(LabelSize::Small)
4321 .color(Color::Muted),
4322 )
4323 }
4324 })
4325 .on_click(cx.listener(|this, _, _, cx| {
4326 this.edits_expanded = !this.edits_expanded;
4327 cx.notify();
4328 })),
4329 )
4330 .child(
4331 h_flex()
4332 .gap_1()
4333 .child(
4334 IconButton::new("review-changes", IconName::ListTodo)
4335 .icon_size(IconSize::Small)
4336 .tooltip({
4337 let focus_handle = focus_handle.clone();
4338 move |_window, cx| {
4339 Tooltip::for_action_in(
4340 "Review Changes",
4341 &OpenAgentDiff,
4342 &focus_handle,
4343 cx,
4344 )
4345 }
4346 })
4347 .on_click(cx.listener(|_, _, window, cx| {
4348 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
4349 })),
4350 )
4351 .child(Divider::vertical().color(DividerColor::Border))
4352 .child(
4353 Button::new("reject-all-changes", "Reject All")
4354 .label_size(LabelSize::Small)
4355 .disabled(pending_edits)
4356 .when(pending_edits, |this| {
4357 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
4358 })
4359 .key_binding(
4360 KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx)
4361 .map(|kb| kb.size(rems_from_px(10.))),
4362 )
4363 .on_click(cx.listener(move |this, _, window, cx| {
4364 this.reject_all(&RejectAll, window, cx);
4365 })),
4366 )
4367 .child(
4368 Button::new("keep-all-changes", "Keep All")
4369 .label_size(LabelSize::Small)
4370 .disabled(pending_edits)
4371 .when(pending_edits, |this| {
4372 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
4373 })
4374 .key_binding(
4375 KeyBinding::for_action_in(&KeepAll, &focus_handle, cx)
4376 .map(|kb| kb.size(rems_from_px(10.))),
4377 )
4378 .on_click(cx.listener(move |this, _, window, cx| {
4379 this.keep_all(&KeepAll, window, cx);
4380 })),
4381 ),
4382 )
4383 }
4384
4385 fn render_edited_files(
4386 &self,
4387 action_log: &Entity<ActionLog>,
4388 telemetry: ActionLogTelemetry,
4389 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
4390 pending_edits: bool,
4391 cx: &Context<Self>,
4392 ) -> impl IntoElement {
4393 let editor_bg_color = cx.theme().colors().editor_background;
4394
4395 v_flex()
4396 .id("edited_files_list")
4397 .max_h_40()
4398 .overflow_y_scroll()
4399 .children(
4400 changed_buffers
4401 .iter()
4402 .enumerate()
4403 .flat_map(|(index, (buffer, _diff))| {
4404 let file = buffer.read(cx).file()?;
4405 let path = file.path();
4406 let path_style = file.path_style(cx);
4407 let separator = file.path_style(cx).primary_separator();
4408
4409 let file_path = path.parent().and_then(|parent| {
4410 if parent.is_empty() {
4411 None
4412 } else {
4413 Some(
4414 Label::new(format!(
4415 "{}{separator}",
4416 parent.display(path_style)
4417 ))
4418 .color(Color::Muted)
4419 .size(LabelSize::XSmall)
4420 .buffer_font(cx),
4421 )
4422 }
4423 });
4424
4425 let file_name = path.file_name().map(|name| {
4426 Label::new(name.to_string())
4427 .size(LabelSize::XSmall)
4428 .buffer_font(cx)
4429 .ml_1p5()
4430 });
4431
4432 let full_path = path.display(path_style).to_string();
4433
4434 let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
4435 .map(Icon::from_path)
4436 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
4437 .unwrap_or_else(|| {
4438 Icon::new(IconName::File)
4439 .color(Color::Muted)
4440 .size(IconSize::Small)
4441 });
4442
4443 let overlay_gradient = linear_gradient(
4444 90.,
4445 linear_color_stop(editor_bg_color, 1.),
4446 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
4447 );
4448
4449 let element = h_flex()
4450 .group("edited-code")
4451 .id(("file-container", index))
4452 .py_1()
4453 .pl_2()
4454 .pr_1()
4455 .gap_2()
4456 .justify_between()
4457 .bg(editor_bg_color)
4458 .when(index < changed_buffers.len() - 1, |parent| {
4459 parent.border_color(cx.theme().colors().border).border_b_1()
4460 })
4461 .child(
4462 h_flex()
4463 .id(("file-name-row", index))
4464 .relative()
4465 .pr_8()
4466 .w_full()
4467 .child(
4468 h_flex()
4469 .id(("file-name-path", index))
4470 .cursor_pointer()
4471 .pr_0p5()
4472 .gap_0p5()
4473 .hover(|s| s.bg(cx.theme().colors().element_hover))
4474 .rounded_xs()
4475 .child(file_icon)
4476 .children(file_name)
4477 .children(file_path)
4478 .tooltip(move |_, cx| {
4479 Tooltip::with_meta(
4480 "Go to File",
4481 None,
4482 full_path.clone(),
4483 cx,
4484 )
4485 })
4486 .on_click({
4487 let buffer = buffer.clone();
4488 cx.listener(move |this, _, window, cx| {
4489 this.open_edited_buffer(&buffer, window, cx);
4490 })
4491 }),
4492 )
4493 .child(
4494 div()
4495 .absolute()
4496 .h_full()
4497 .w_12()
4498 .top_0()
4499 .bottom_0()
4500 .right_0()
4501 .bg(overlay_gradient),
4502 ),
4503 )
4504 .child(
4505 h_flex()
4506 .gap_1()
4507 .visible_on_hover("edited-code")
4508 .child(
4509 Button::new("review", "Review")
4510 .label_size(LabelSize::Small)
4511 .on_click({
4512 let buffer = buffer.clone();
4513 cx.listener(move |this, _, window, cx| {
4514 this.open_edited_buffer(&buffer, window, cx);
4515 })
4516 }),
4517 )
4518 .child(Divider::vertical().color(DividerColor::BorderVariant))
4519 .child(
4520 Button::new("reject-file", "Reject")
4521 .label_size(LabelSize::Small)
4522 .disabled(pending_edits)
4523 .on_click({
4524 let buffer = buffer.clone();
4525 let action_log = action_log.clone();
4526 let telemetry = telemetry.clone();
4527 move |_, _, cx| {
4528 action_log.update(cx, |action_log, cx| {
4529 action_log
4530 .reject_edits_in_ranges(
4531 buffer.clone(),
4532 vec![Anchor::min_max_range_for_buffer(
4533 buffer.read(cx).remote_id(),
4534 )],
4535 Some(telemetry.clone()),
4536 cx,
4537 )
4538 .detach_and_log_err(cx);
4539 })
4540 }
4541 }),
4542 )
4543 .child(
4544 Button::new("keep-file", "Keep")
4545 .label_size(LabelSize::Small)
4546 .disabled(pending_edits)
4547 .on_click({
4548 let buffer = buffer.clone();
4549 let action_log = action_log.clone();
4550 let telemetry = telemetry.clone();
4551 move |_, _, cx| {
4552 action_log.update(cx, |action_log, cx| {
4553 action_log.keep_edits_in_range(
4554 buffer.clone(),
4555 Anchor::min_max_range_for_buffer(
4556 buffer.read(cx).remote_id(),
4557 ),
4558 Some(telemetry.clone()),
4559 cx,
4560 );
4561 })
4562 }
4563 }),
4564 ),
4565 );
4566
4567 Some(element)
4568 }),
4569 )
4570 .into_any_element()
4571 }
4572
4573 fn render_message_queue_summary(
4574 &self,
4575 _window: &mut Window,
4576 cx: &Context<Self>,
4577 ) -> impl IntoElement {
4578 let queue_count = self.message_queue.len();
4579 let title: SharedString = if queue_count == 1 {
4580 "1 Queued Message".into()
4581 } else {
4582 format!("{} Queued Messages", queue_count).into()
4583 };
4584
4585 h_flex()
4586 .p_1()
4587 .w_full()
4588 .gap_1()
4589 .justify_between()
4590 .when(self.queue_expanded, |this| {
4591 this.border_b_1().border_color(cx.theme().colors().border)
4592 })
4593 .child(
4594 h_flex()
4595 .id("queue_summary")
4596 .gap_1()
4597 .child(Disclosure::new("queue_disclosure", self.queue_expanded))
4598 .child(Label::new(title).size(LabelSize::Small).color(Color::Muted))
4599 .on_click(cx.listener(|this, _, _, cx| {
4600 this.queue_expanded = !this.queue_expanded;
4601 cx.notify();
4602 })),
4603 )
4604 .child(
4605 Button::new("clear_queue", "Clear All")
4606 .label_size(LabelSize::Small)
4607 .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx))
4608 .on_click(cx.listener(|this, _, _, cx| {
4609 this.message_queue.clear();
4610 cx.notify();
4611 })),
4612 )
4613 }
4614
4615 fn render_message_queue_entries(
4616 &self,
4617 _window: &mut Window,
4618 cx: &Context<Self>,
4619 ) -> impl IntoElement {
4620 let message_editor = self.message_editor.read(cx);
4621 let focus_handle = message_editor.focus_handle(cx);
4622
4623 v_flex()
4624 .id("message_queue_list")
4625 .max_h_40()
4626 .overflow_y_scroll()
4627 .children(
4628 self.message_queue
4629 .iter()
4630 .enumerate()
4631 .map(|(index, queued)| {
4632 let is_next = index == 0;
4633 let icon_color = if is_next { Color::Accent } else { Color::Muted };
4634 let queue_len = self.message_queue.len();
4635
4636 let preview = queued
4637 .content
4638 .iter()
4639 .find_map(|block| match block {
4640 acp::ContentBlock::Text(text) => {
4641 text.text.lines().next().map(str::to_owned)
4642 }
4643 _ => None,
4644 })
4645 .unwrap_or_default();
4646
4647 h_flex()
4648 .group("queue_entry")
4649 .w_full()
4650 .p_1()
4651 .pl_2()
4652 .gap_1()
4653 .justify_between()
4654 .bg(cx.theme().colors().editor_background)
4655 .when(index < queue_len - 1, |parent| {
4656 parent.border_color(cx.theme().colors().border).border_b_1()
4657 })
4658 .child(
4659 h_flex()
4660 .id(("queued_prompt", index))
4661 .min_w_0()
4662 .w_full()
4663 .gap_1p5()
4664 .child(
4665 Icon::new(IconName::Circle)
4666 .size(IconSize::Small)
4667 .color(icon_color),
4668 )
4669 .child(
4670 Label::new(preview)
4671 .size(LabelSize::XSmall)
4672 .color(Color::Muted)
4673 .buffer_font(cx)
4674 .truncate(),
4675 )
4676 .when(is_next, |this| {
4677 this.tooltip(Tooltip::text("Next Prompt in the Queue"))
4678 }),
4679 )
4680 .child(
4681 h_flex()
4682 .flex_none()
4683 .gap_1()
4684 .visible_on_hover("queue_entry")
4685 .child(
4686 Button::new(("delete", index), "Remove")
4687 .label_size(LabelSize::Small)
4688 .on_click(cx.listener(move |this, _, _, cx| {
4689 if index < this.message_queue.len() {
4690 this.message_queue.remove(index);
4691 cx.notify();
4692 }
4693 })),
4694 )
4695 .child(
4696 Button::new(("send_now", index), "Send Now")
4697 .style(ButtonStyle::Outlined)
4698 .label_size(LabelSize::Small)
4699 .when(is_next, |this| {
4700 this.key_binding(
4701 KeyBinding::for_action_in(
4702 &SendNextQueuedMessage,
4703 &focus_handle.clone(),
4704 cx,
4705 )
4706 .map(|kb| kb.size(rems_from_px(10.))),
4707 )
4708 })
4709 .on_click(cx.listener(move |this, _, window, cx| {
4710 this.send_queued_message_at_index(
4711 index, true, window, cx,
4712 );
4713 })),
4714 ),
4715 )
4716 }),
4717 )
4718 .into_any_element()
4719 }
4720
4721 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
4722 let focus_handle = self.message_editor.focus_handle(cx);
4723 let editor_bg_color = cx.theme().colors().editor_background;
4724 let (expand_icon, expand_tooltip) = if self.editor_expanded {
4725 (IconName::Minimize, "Minimize Message Editor")
4726 } else {
4727 (IconName::Maximize, "Expand Message Editor")
4728 };
4729
4730 let backdrop = div()
4731 .size_full()
4732 .absolute()
4733 .inset_0()
4734 .bg(cx.theme().colors().panel_background)
4735 .opacity(0.8)
4736 .block_mouse_except_scroll();
4737
4738 let enable_editor = match self.thread_state {
4739 ThreadState::Ready { .. } => true,
4740 ThreadState::Loading { .. }
4741 | ThreadState::Unauthenticated { .. }
4742 | ThreadState::LoadError(..) => false,
4743 };
4744
4745 v_flex()
4746 .on_action(cx.listener(Self::expand_message_editor))
4747 .p_2()
4748 .gap_2()
4749 .border_t_1()
4750 .border_color(cx.theme().colors().border)
4751 .bg(editor_bg_color)
4752 .when(self.editor_expanded, |this| {
4753 this.h(vh(0.8, window)).size_full().justify_between()
4754 })
4755 .child(
4756 v_flex()
4757 .relative()
4758 .size_full()
4759 .pt_1()
4760 .pr_2p5()
4761 .child(self.message_editor.clone())
4762 .child(
4763 h_flex()
4764 .absolute()
4765 .top_0()
4766 .right_0()
4767 .opacity(0.5)
4768 .hover(|this| this.opacity(1.0))
4769 .child(
4770 IconButton::new("toggle-height", expand_icon)
4771 .icon_size(IconSize::Small)
4772 .icon_color(Color::Muted)
4773 .tooltip({
4774 move |_window, cx| {
4775 Tooltip::for_action_in(
4776 expand_tooltip,
4777 &ExpandMessageEditor,
4778 &focus_handle,
4779 cx,
4780 )
4781 }
4782 })
4783 .on_click(cx.listener(|this, _, window, cx| {
4784 this.expand_message_editor(
4785 &ExpandMessageEditor,
4786 window,
4787 cx,
4788 );
4789 })),
4790 ),
4791 ),
4792 )
4793 .child(
4794 h_flex()
4795 .flex_none()
4796 .flex_wrap()
4797 .justify_between()
4798 .child(
4799 h_flex()
4800 .gap_0p5()
4801 .child(self.render_add_context_button(cx))
4802 .child(self.render_follow_toggle(cx))
4803 .children(self.render_burn_mode_toggle(cx)),
4804 )
4805 .child(
4806 h_flex()
4807 .gap_1()
4808 .children(self.render_token_usage(cx))
4809 .children(self.profile_selector.clone())
4810 // Either config_options_view OR (mode_selector + model_selector)
4811 .children(self.config_options_view.clone())
4812 .when(self.config_options_view.is_none(), |this| {
4813 this.children(self.mode_selector().cloned())
4814 .children(self.model_selector.clone())
4815 })
4816 .child(self.render_send_button(cx)),
4817 ),
4818 )
4819 .when(!enable_editor, |this| this.child(backdrop))
4820 .into_any()
4821 }
4822
4823 pub(crate) fn as_native_connection(
4824 &self,
4825 cx: &App,
4826 ) -> Option<Rc<agent::NativeAgentConnection>> {
4827 let acp_thread = self.thread()?.read(cx);
4828 acp_thread.connection().clone().downcast()
4829 }
4830
4831 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
4832 let acp_thread = self.thread()?.read(cx);
4833 self.as_native_connection(cx)?
4834 .thread(acp_thread.session_id(), cx)
4835 }
4836
4837 fn is_using_zed_ai_models(&self, cx: &App) -> bool {
4838 self.as_native_thread(cx)
4839 .and_then(|thread| thread.read(cx).model())
4840 .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID)
4841 }
4842
4843 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
4844 let thread = self.thread()?.read(cx);
4845 let usage = thread.token_usage()?;
4846 let is_generating = thread.status() != ThreadStatus::Idle;
4847
4848 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
4849 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
4850
4851 Some(
4852 h_flex()
4853 .flex_shrink_0()
4854 .gap_0p5()
4855 .mr_1p5()
4856 .child(
4857 Label::new(used)
4858 .size(LabelSize::Small)
4859 .color(Color::Muted)
4860 .map(|label| {
4861 if is_generating {
4862 label
4863 .with_animation(
4864 "used-tokens-label",
4865 Animation::new(Duration::from_secs(2))
4866 .repeat()
4867 .with_easing(pulsating_between(0.3, 0.8)),
4868 |label, delta| label.alpha(delta),
4869 )
4870 .into_any()
4871 } else {
4872 label.into_any_element()
4873 }
4874 }),
4875 )
4876 .child(
4877 Label::new("/")
4878 .size(LabelSize::Small)
4879 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
4880 )
4881 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
4882 )
4883 }
4884
4885 fn toggle_burn_mode(
4886 &mut self,
4887 _: &ToggleBurnMode,
4888 _window: &mut Window,
4889 cx: &mut Context<Self>,
4890 ) {
4891 let Some(thread) = self.as_native_thread(cx) else {
4892 return;
4893 };
4894
4895 thread.update(cx, |thread, cx| {
4896 let current_mode = thread.completion_mode();
4897 thread.set_completion_mode(
4898 match current_mode {
4899 CompletionMode::Burn => CompletionMode::Normal,
4900 CompletionMode::Normal => CompletionMode::Burn,
4901 },
4902 cx,
4903 );
4904 });
4905 }
4906
4907 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
4908 let Some(thread) = self.thread() else {
4909 return;
4910 };
4911 let telemetry = ActionLogTelemetry::from(thread.read(cx));
4912 let action_log = thread.read(cx).action_log().clone();
4913 action_log.update(cx, |action_log, cx| {
4914 action_log.keep_all_edits(Some(telemetry), cx)
4915 });
4916 }
4917
4918 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
4919 let Some(thread) = self.thread() else {
4920 return;
4921 };
4922 let telemetry = ActionLogTelemetry::from(thread.read(cx));
4923 let action_log = thread.read(cx).action_log().clone();
4924 action_log
4925 .update(cx, |action_log, cx| {
4926 action_log.reject_all_edits(Some(telemetry), cx)
4927 })
4928 .detach();
4929 }
4930
4931 fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
4932 self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
4933 }
4934
4935 fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
4936 self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowOnce, window, cx);
4937 }
4938
4939 fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
4940 self.authorize_pending_tool_call(acp::PermissionOptionKind::RejectOnce, window, cx);
4941 }
4942
4943 fn authorize_pending_tool_call(
4944 &mut self,
4945 kind: acp::PermissionOptionKind,
4946 window: &mut Window,
4947 cx: &mut Context<Self>,
4948 ) -> Option<()> {
4949 let thread = self.thread()?.read(cx);
4950 let tool_call = thread.first_tool_awaiting_confirmation()?;
4951 let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
4952 return None;
4953 };
4954 let option = options.iter().find(|o| o.kind == kind)?;
4955
4956 self.authorize_tool_call(
4957 tool_call.id.clone(),
4958 option.option_id.clone(),
4959 option.kind,
4960 window,
4961 cx,
4962 );
4963
4964 Some(())
4965 }
4966
4967 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4968 let thread = self.as_native_thread(cx)?.read(cx);
4969
4970 if thread
4971 .model()
4972 .is_none_or(|model| !model.supports_burn_mode())
4973 {
4974 return None;
4975 }
4976
4977 let active_completion_mode = thread.completion_mode();
4978 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
4979 let icon = if burn_mode_enabled {
4980 IconName::ZedBurnModeOn
4981 } else {
4982 IconName::ZedBurnMode
4983 };
4984
4985 Some(
4986 IconButton::new("burn-mode", icon)
4987 .icon_size(IconSize::Small)
4988 .icon_color(Color::Muted)
4989 .toggle_state(burn_mode_enabled)
4990 .selected_icon_color(Color::Error)
4991 .on_click(cx.listener(|this, _event, window, cx| {
4992 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4993 }))
4994 .tooltip(move |_window, cx| {
4995 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
4996 .into()
4997 })
4998 .into_any_element(),
4999 )
5000 }
5001
5002 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
5003 let message_editor = self.message_editor.read(cx);
5004 let is_editor_empty = message_editor.is_empty(cx);
5005 let focus_handle = message_editor.focus_handle(cx);
5006
5007 let is_generating = self
5008 .thread()
5009 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
5010
5011 if self.is_loading_contents {
5012 div()
5013 .id("loading-message-content")
5014 .px_1()
5015 .tooltip(Tooltip::text("Loading Added Context…"))
5016 .child(loading_contents_spinner(IconSize::default()))
5017 .into_any_element()
5018 } else if is_generating && is_editor_empty {
5019 IconButton::new("stop-generation", IconName::Stop)
5020 .icon_color(Color::Error)
5021 .style(ButtonStyle::Tinted(TintColor::Error))
5022 .tooltip(move |_window, cx| {
5023 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx)
5024 })
5025 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
5026 .into_any_element()
5027 } else {
5028 IconButton::new("send-message", IconName::Send)
5029 .style(ButtonStyle::Filled)
5030 .map(|this| {
5031 if is_editor_empty && !is_generating {
5032 this.disabled(true).icon_color(Color::Muted)
5033 } else {
5034 this.icon_color(Color::Accent)
5035 }
5036 })
5037 .tooltip(move |_window, cx| {
5038 if is_editor_empty && !is_generating {
5039 Tooltip::for_action("Type to Send", &Chat, cx)
5040 } else {
5041 let title = if is_generating {
5042 "Stop and Send Message"
5043 } else {
5044 "Send"
5045 };
5046
5047 let focus_handle = focus_handle.clone();
5048
5049 Tooltip::element(move |_window, cx| {
5050 v_flex()
5051 .gap_1()
5052 .child(
5053 h_flex()
5054 .gap_2()
5055 .justify_between()
5056 .child(Label::new(title))
5057 .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)),
5058 )
5059 .child(
5060 h_flex()
5061 .pt_1()
5062 .gap_2()
5063 .justify_between()
5064 .border_t_1()
5065 .border_color(cx.theme().colors().border_variant)
5066 .child(Label::new("Queue Message"))
5067 .child(KeyBinding::for_action_in(
5068 &QueueMessage,
5069 &focus_handle,
5070 cx,
5071 )),
5072 )
5073 .into_any_element()
5074 })(_window, cx)
5075 }
5076 })
5077 .on_click(cx.listener(|this, _, window, cx| {
5078 this.send(window, cx);
5079 }))
5080 .into_any_element()
5081 }
5082 }
5083
5084 fn is_following(&self, cx: &App) -> bool {
5085 match self.thread().map(|thread| thread.read(cx).status()) {
5086 Some(ThreadStatus::Generating) => self
5087 .workspace
5088 .read_with(cx, |workspace, _| {
5089 workspace.is_being_followed(CollaboratorId::Agent)
5090 })
5091 .unwrap_or(false),
5092 _ => self.should_be_following,
5093 }
5094 }
5095
5096 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5097 let following = self.is_following(cx);
5098
5099 self.should_be_following = !following;
5100 if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
5101 self.workspace
5102 .update(cx, |workspace, cx| {
5103 if following {
5104 workspace.unfollow(CollaboratorId::Agent, window, cx);
5105 } else {
5106 workspace.follow(CollaboratorId::Agent, window, cx);
5107 }
5108 })
5109 .ok();
5110 }
5111
5112 telemetry::event!("Follow Agent Selected", following = !following);
5113 }
5114
5115 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
5116 let following = self.is_following(cx);
5117
5118 let tooltip_label = if following {
5119 if self.agent.name() == "Zed Agent" {
5120 format!("Stop Following the {}", self.agent.name())
5121 } else {
5122 format!("Stop Following {}", self.agent.name())
5123 }
5124 } else {
5125 if self.agent.name() == "Zed Agent" {
5126 format!("Follow the {}", self.agent.name())
5127 } else {
5128 format!("Follow {}", self.agent.name())
5129 }
5130 };
5131
5132 IconButton::new("follow-agent", IconName::Crosshair)
5133 .icon_size(IconSize::Small)
5134 .icon_color(Color::Muted)
5135 .toggle_state(following)
5136 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
5137 .tooltip(move |_window, cx| {
5138 if following {
5139 Tooltip::for_action(tooltip_label.clone(), &Follow, cx)
5140 } else {
5141 Tooltip::with_meta(
5142 tooltip_label.clone(),
5143 Some(&Follow),
5144 "Track the agent's location as it reads and edits files.",
5145 cx,
5146 )
5147 }
5148 })
5149 .on_click(cx.listener(move |this, _, window, cx| {
5150 this.toggle_following(window, cx);
5151 }))
5152 }
5153
5154 fn render_add_context_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5155 let message_editor = self.message_editor.clone();
5156 let menu_visible = message_editor.read(cx).is_completions_menu_visible(cx);
5157
5158 IconButton::new("add-context", IconName::AtSign)
5159 .icon_size(IconSize::Small)
5160 .icon_color(Color::Muted)
5161 .when(!menu_visible, |this| {
5162 this.tooltip(move |_window, cx| {
5163 Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
5164 })
5165 })
5166 .on_click(cx.listener(move |_this, _, window, cx| {
5167 let message_editor_clone = message_editor.clone();
5168
5169 window.defer(cx, move |window, cx| {
5170 message_editor_clone.update(cx, |message_editor, cx| {
5171 message_editor.trigger_completion_menu(window, cx);
5172 });
5173 });
5174 }))
5175 }
5176
5177 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
5178 let workspace = self.workspace.clone();
5179 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
5180 Self::open_link(text, &workspace, window, cx);
5181 })
5182 }
5183
5184 fn open_link(
5185 url: SharedString,
5186 workspace: &WeakEntity<Workspace>,
5187 window: &mut Window,
5188 cx: &mut App,
5189 ) {
5190 let Some(workspace) = workspace.upgrade() else {
5191 cx.open_url(&url);
5192 return;
5193 };
5194
5195 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err()
5196 {
5197 workspace.update(cx, |workspace, cx| match mention {
5198 MentionUri::File { abs_path } => {
5199 let project = workspace.project();
5200 let Some(path) =
5201 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
5202 else {
5203 return;
5204 };
5205
5206 workspace
5207 .open_path(path, None, true, window, cx)
5208 .detach_and_log_err(cx);
5209 }
5210 MentionUri::PastedImage => {}
5211 MentionUri::Directory { abs_path } => {
5212 let project = workspace.project();
5213 let Some(entry_id) = project.update(cx, |project, cx| {
5214 let path = project.find_project_path(abs_path, cx)?;
5215 project.entry_for_path(&path, cx).map(|entry| entry.id)
5216 }) else {
5217 return;
5218 };
5219
5220 project.update(cx, |_, cx| {
5221 cx.emit(project::Event::RevealInProjectPanel(entry_id));
5222 });
5223 }
5224 MentionUri::Symbol {
5225 abs_path: path,
5226 line_range,
5227 ..
5228 }
5229 | MentionUri::Selection {
5230 abs_path: Some(path),
5231 line_range,
5232 } => {
5233 let project = workspace.project();
5234 let Some(path) =
5235 project.update(cx, |project, cx| project.find_project_path(path, cx))
5236 else {
5237 return;
5238 };
5239
5240 let item = workspace.open_path(path, None, true, window, cx);
5241 window
5242 .spawn(cx, async move |cx| {
5243 let Some(editor) = item.await?.downcast::<Editor>() else {
5244 return Ok(());
5245 };
5246 let range = Point::new(*line_range.start(), 0)
5247 ..Point::new(*line_range.start(), 0);
5248 editor
5249 .update_in(cx, |editor, window, cx| {
5250 editor.change_selections(
5251 SelectionEffects::scroll(Autoscroll::center()),
5252 window,
5253 cx,
5254 |s| s.select_ranges(vec![range]),
5255 );
5256 })
5257 .ok();
5258 anyhow::Ok(())
5259 })
5260 .detach_and_log_err(cx);
5261 }
5262 MentionUri::Selection { abs_path: None, .. } => {}
5263 MentionUri::Thread { id, name } => {
5264 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5265 panel.update(cx, |panel, cx| {
5266 panel.load_agent_thread(
5267 DbThreadMetadata {
5268 id,
5269 title: name.into(),
5270 updated_at: Default::default(),
5271 },
5272 window,
5273 cx,
5274 )
5275 });
5276 }
5277 }
5278 MentionUri::TextThread { path, .. } => {
5279 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5280 panel.update(cx, |panel, cx| {
5281 panel
5282 .open_saved_text_thread(path.as_path().into(), window, cx)
5283 .detach_and_log_err(cx);
5284 });
5285 }
5286 }
5287 MentionUri::Rule { id, .. } => {
5288 let PromptId::User { uuid } = id else {
5289 return;
5290 };
5291 window.dispatch_action(
5292 Box::new(OpenRulesLibrary {
5293 prompt_to_select: Some(uuid.0),
5294 }),
5295 cx,
5296 )
5297 }
5298 MentionUri::Fetch { url } => {
5299 cx.open_url(url.as_str());
5300 }
5301 })
5302 } else {
5303 cx.open_url(&url);
5304 }
5305 }
5306
5307 fn open_tool_call_location(
5308 &self,
5309 entry_ix: usize,
5310 location_ix: usize,
5311 window: &mut Window,
5312 cx: &mut Context<Self>,
5313 ) -> Option<()> {
5314 let (tool_call_location, agent_location) = self
5315 .thread()?
5316 .read(cx)
5317 .entries()
5318 .get(entry_ix)?
5319 .location(location_ix)?;
5320
5321 let project_path = self
5322 .project
5323 .read(cx)
5324 .find_project_path(&tool_call_location.path, cx)?;
5325
5326 let open_task = self
5327 .workspace
5328 .update(cx, |workspace, cx| {
5329 workspace.open_path(project_path, None, true, window, cx)
5330 })
5331 .log_err()?;
5332 window
5333 .spawn(cx, async move |cx| {
5334 let item = open_task.await?;
5335
5336 let Some(active_editor) = item.downcast::<Editor>() else {
5337 return anyhow::Ok(());
5338 };
5339
5340 active_editor.update_in(cx, |editor, window, cx| {
5341 let multibuffer = editor.buffer().read(cx);
5342 let buffer = multibuffer.as_singleton();
5343 if agent_location.buffer.upgrade() == buffer {
5344 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
5345 let anchor =
5346 editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position);
5347 editor.change_selections(Default::default(), window, cx, |selections| {
5348 selections.select_anchor_ranges([anchor..anchor]);
5349 })
5350 } else {
5351 let row = tool_call_location.line.unwrap_or_default();
5352 editor.change_selections(Default::default(), window, cx, |selections| {
5353 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
5354 })
5355 }
5356 })?;
5357
5358 anyhow::Ok(())
5359 })
5360 .detach_and_log_err(cx);
5361
5362 None
5363 }
5364
5365 pub fn open_thread_as_markdown(
5366 &self,
5367 workspace: Entity<Workspace>,
5368 window: &mut Window,
5369 cx: &mut App,
5370 ) -> Task<Result<()>> {
5371 let markdown_language_task = workspace
5372 .read(cx)
5373 .app_state()
5374 .languages
5375 .language_for_name("Markdown");
5376
5377 let (thread_title, markdown) = if let Some(thread) = self.thread() {
5378 let thread = thread.read(cx);
5379 (thread.title().to_string(), thread.to_markdown(cx))
5380 } else {
5381 return Task::ready(Ok(()));
5382 };
5383
5384 let project = workspace.read(cx).project().clone();
5385 window.spawn(cx, async move |cx| {
5386 let markdown_language = markdown_language_task.await?;
5387
5388 let buffer = project
5389 .update(cx, |project, cx| project.create_buffer(false, cx))?
5390 .await?;
5391
5392 buffer.update(cx, |buffer, cx| {
5393 buffer.set_text(markdown, cx);
5394 buffer.set_language(Some(markdown_language), cx);
5395 buffer.set_capability(language::Capability::ReadWrite, cx);
5396 })?;
5397
5398 workspace.update_in(cx, |workspace, window, cx| {
5399 let buffer = cx
5400 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
5401
5402 workspace.add_item_to_active_pane(
5403 Box::new(cx.new(|cx| {
5404 let mut editor =
5405 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5406 editor.set_breadcrumb_header(thread_title);
5407 editor
5408 })),
5409 None,
5410 true,
5411 window,
5412 cx,
5413 );
5414 })?;
5415 anyhow::Ok(())
5416 })
5417 }
5418
5419 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
5420 self.list_state.scroll_to(ListOffset::default());
5421 cx.notify();
5422 }
5423
5424 fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
5425 let Some(thread) = self.thread() else {
5426 return;
5427 };
5428
5429 let entries = thread.read(cx).entries();
5430 if entries.is_empty() {
5431 return;
5432 }
5433
5434 // Find the most recent user message and scroll it to the top of the viewport.
5435 // (Fallback: if no user message exists, scroll to the bottom.)
5436 if let Some(ix) = entries
5437 .iter()
5438 .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
5439 {
5440 self.list_state.scroll_to(ListOffset {
5441 item_ix: ix,
5442 offset_in_item: px(0.0),
5443 });
5444 cx.notify();
5445 } else {
5446 self.scroll_to_bottom(cx);
5447 }
5448 }
5449
5450 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
5451 if let Some(thread) = self.thread() {
5452 let entry_count = thread.read(cx).entries().len();
5453 self.list_state.reset(entry_count);
5454 cx.notify();
5455 }
5456 }
5457
5458 fn notify_with_sound(
5459 &mut self,
5460 caption: impl Into<SharedString>,
5461 icon: IconName,
5462 window: &mut Window,
5463 cx: &mut Context<Self>,
5464 ) {
5465 self.play_notification_sound(window, cx);
5466 self.show_notification(caption, icon, window, cx);
5467 }
5468
5469 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
5470 let settings = AgentSettings::get_global(cx);
5471 if settings.play_sound_when_agent_done && !window.is_window_active() {
5472 Audio::play_sound(Sound::AgentDone, cx);
5473 }
5474 }
5475
5476 fn show_notification(
5477 &mut self,
5478 caption: impl Into<SharedString>,
5479 icon: IconName,
5480 window: &mut Window,
5481 cx: &mut Context<Self>,
5482 ) {
5483 if !self.notifications.is_empty() {
5484 return;
5485 }
5486
5487 let settings = AgentSettings::get_global(cx);
5488
5489 let window_is_inactive = !window.is_window_active();
5490 let panel_is_hidden = self
5491 .workspace
5492 .upgrade()
5493 .map(|workspace| AgentPanel::is_hidden(&workspace, cx))
5494 .unwrap_or(true);
5495
5496 let should_notify = window_is_inactive || panel_is_hidden;
5497
5498 if !should_notify {
5499 return;
5500 }
5501
5502 // TODO: Change this once we have title summarization for external agents.
5503 let title = self.agent.name();
5504
5505 match settings.notify_when_agent_waiting {
5506 NotifyWhenAgentWaiting::PrimaryScreen => {
5507 if let Some(primary) = cx.primary_display() {
5508 self.pop_up(icon, caption.into(), title, window, primary, cx);
5509 }
5510 }
5511 NotifyWhenAgentWaiting::AllScreens => {
5512 let caption = caption.into();
5513 for screen in cx.displays() {
5514 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
5515 }
5516 }
5517 NotifyWhenAgentWaiting::Never => {
5518 // Don't show anything
5519 }
5520 }
5521 }
5522
5523 fn pop_up(
5524 &mut self,
5525 icon: IconName,
5526 caption: SharedString,
5527 title: SharedString,
5528 window: &mut Window,
5529 screen: Rc<dyn PlatformDisplay>,
5530 cx: &mut Context<Self>,
5531 ) {
5532 let options = AgentNotification::window_options(screen, cx);
5533
5534 let project_name = self.workspace.upgrade().and_then(|workspace| {
5535 workspace
5536 .read(cx)
5537 .project()
5538 .read(cx)
5539 .visible_worktrees(cx)
5540 .next()
5541 .map(|worktree| worktree.read(cx).root_name_str().to_string())
5542 });
5543
5544 if let Some(screen_window) = cx
5545 .open_window(options, |_window, cx| {
5546 cx.new(|_cx| {
5547 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
5548 })
5549 })
5550 .log_err()
5551 && let Some(pop_up) = screen_window.entity(cx).log_err()
5552 {
5553 self.notification_subscriptions
5554 .entry(screen_window)
5555 .or_insert_with(Vec::new)
5556 .push(cx.subscribe_in(&pop_up, window, {
5557 |this, _, event, window, cx| match event {
5558 AgentNotificationEvent::Accepted => {
5559 let handle = window.window_handle();
5560 cx.activate(true);
5561
5562 let workspace_handle = this.workspace.clone();
5563
5564 // If there are multiple Zed windows, activate the correct one.
5565 cx.defer(move |cx| {
5566 handle
5567 .update(cx, |_view, window, _cx| {
5568 window.activate_window();
5569
5570 if let Some(workspace) = workspace_handle.upgrade() {
5571 workspace.update(_cx, |workspace, cx| {
5572 workspace.focus_panel::<AgentPanel>(window, cx);
5573 });
5574 }
5575 })
5576 .log_err();
5577 });
5578
5579 this.dismiss_notifications(cx);
5580 }
5581 AgentNotificationEvent::Dismissed => {
5582 this.dismiss_notifications(cx);
5583 }
5584 }
5585 }));
5586
5587 self.notifications.push(screen_window);
5588
5589 // If the user manually refocuses the original window, dismiss the popup.
5590 self.notification_subscriptions
5591 .entry(screen_window)
5592 .or_insert_with(Vec::new)
5593 .push({
5594 let pop_up_weak = pop_up.downgrade();
5595
5596 cx.observe_window_activation(window, move |_, window, cx| {
5597 if window.is_window_active()
5598 && let Some(pop_up) = pop_up_weak.upgrade()
5599 {
5600 pop_up.update(cx, |_, cx| {
5601 cx.emit(AgentNotificationEvent::Dismissed);
5602 });
5603 }
5604 })
5605 });
5606 }
5607 }
5608
5609 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
5610 for window in self.notifications.drain(..) {
5611 window
5612 .update(cx, |_, window, _| {
5613 window.remove_window();
5614 })
5615 .ok();
5616
5617 self.notification_subscriptions.remove(&window);
5618 }
5619 }
5620
5621 fn render_generating(&self, confirmation: bool) -> impl IntoElement {
5622 h_flex()
5623 .id("generating-spinner")
5624 .py_2()
5625 .px(rems_from_px(22.))
5626 .map(|this| {
5627 if confirmation {
5628 this.gap_2()
5629 .child(
5630 h_flex()
5631 .w_2()
5632 .child(SpinnerLabel::sand().size(LabelSize::Small)),
5633 )
5634 .child(
5635 LoadingLabel::new("Waiting Confirmation")
5636 .size(LabelSize::Small)
5637 .color(Color::Muted),
5638 )
5639 } else {
5640 this.child(SpinnerLabel::new().size(LabelSize::Small))
5641 }
5642 })
5643 .into_any_element()
5644 }
5645
5646 fn render_thread_controls(
5647 &self,
5648 thread: &Entity<AcpThread>,
5649 cx: &Context<Self>,
5650 ) -> impl IntoElement {
5651 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
5652 if is_generating {
5653 return self.render_generating(false).into_any_element();
5654 }
5655
5656 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
5657 .shape(ui::IconButtonShape::Square)
5658 .icon_size(IconSize::Small)
5659 .icon_color(Color::Ignored)
5660 .tooltip(Tooltip::text("Open Thread as Markdown"))
5661 .on_click(cx.listener(move |this, _, window, cx| {
5662 if let Some(workspace) = this.workspace.upgrade() {
5663 this.open_thread_as_markdown(workspace, window, cx)
5664 .detach_and_log_err(cx);
5665 }
5666 }));
5667
5668 let scroll_to_recent_user_prompt =
5669 IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
5670 .shape(ui::IconButtonShape::Square)
5671 .icon_size(IconSize::Small)
5672 .icon_color(Color::Ignored)
5673 .tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
5674 .on_click(cx.listener(move |this, _, _, cx| {
5675 this.scroll_to_most_recent_user_prompt(cx);
5676 }));
5677
5678 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
5679 .shape(ui::IconButtonShape::Square)
5680 .icon_size(IconSize::Small)
5681 .icon_color(Color::Ignored)
5682 .tooltip(Tooltip::text("Scroll To Top"))
5683 .on_click(cx.listener(move |this, _, _, cx| {
5684 this.scroll_to_top(cx);
5685 }));
5686
5687 let mut container = h_flex()
5688 .w_full()
5689 .py_2()
5690 .px_5()
5691 .gap_px()
5692 .opacity(0.6)
5693 .hover(|s| s.opacity(1.))
5694 .justify_end();
5695
5696 if AgentSettings::get_global(cx).enable_feedback
5697 && self
5698 .thread()
5699 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
5700 {
5701 let feedback = self.thread_feedback.feedback;
5702
5703 let tooltip_meta = || {
5704 SharedString::new(
5705 "Rating the thread sends all of your current conversation to the Zed team.",
5706 )
5707 };
5708
5709 container = container
5710 .child(
5711 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
5712 .shape(ui::IconButtonShape::Square)
5713 .icon_size(IconSize::Small)
5714 .icon_color(match feedback {
5715 Some(ThreadFeedback::Positive) => Color::Accent,
5716 _ => Color::Ignored,
5717 })
5718 .tooltip(move |window, cx| match feedback {
5719 Some(ThreadFeedback::Positive) => {
5720 Tooltip::text("Thanks for your feedback!")(window, cx)
5721 }
5722 _ => Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx),
5723 })
5724 .on_click(cx.listener(move |this, _, window, cx| {
5725 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
5726 })),
5727 )
5728 .child(
5729 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
5730 .shape(ui::IconButtonShape::Square)
5731 .icon_size(IconSize::Small)
5732 .icon_color(match feedback {
5733 Some(ThreadFeedback::Negative) => Color::Accent,
5734 _ => Color::Ignored,
5735 })
5736 .tooltip(move |window, cx| match feedback {
5737 Some(ThreadFeedback::Negative) => {
5738 Tooltip::text(
5739 "We appreciate your feedback and will use it to improve in the future.",
5740 )(window, cx)
5741 }
5742 _ => {
5743 Tooltip::with_meta("Not Helpful Response", None, tooltip_meta(), cx)
5744 }
5745 })
5746 .on_click(cx.listener(move |this, _, window, cx| {
5747 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
5748 })),
5749 );
5750 }
5751
5752 container
5753 .child(open_as_markdown)
5754 .child(scroll_to_recent_user_prompt)
5755 .child(scroll_to_top)
5756 .into_any_element()
5757 }
5758
5759 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
5760 h_flex()
5761 .key_context("AgentFeedbackMessageEditor")
5762 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
5763 this.thread_feedback.dismiss_comments();
5764 cx.notify();
5765 }))
5766 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
5767 this.submit_feedback_message(cx);
5768 }))
5769 .p_2()
5770 .mb_2()
5771 .mx_5()
5772 .gap_1()
5773 .rounded_md()
5774 .border_1()
5775 .border_color(cx.theme().colors().border)
5776 .bg(cx.theme().colors().editor_background)
5777 .child(div().w_full().child(editor))
5778 .child(
5779 h_flex()
5780 .child(
5781 IconButton::new("dismiss-feedback-message", IconName::Close)
5782 .icon_color(Color::Error)
5783 .icon_size(IconSize::XSmall)
5784 .shape(ui::IconButtonShape::Square)
5785 .on_click(cx.listener(move |this, _, _window, cx| {
5786 this.thread_feedback.dismiss_comments();
5787 cx.notify();
5788 })),
5789 )
5790 .child(
5791 IconButton::new("submit-feedback-message", IconName::Return)
5792 .icon_size(IconSize::XSmall)
5793 .shape(ui::IconButtonShape::Square)
5794 .on_click(cx.listener(move |this, _, _window, cx| {
5795 this.submit_feedback_message(cx);
5796 })),
5797 ),
5798 )
5799 }
5800
5801 fn handle_feedback_click(
5802 &mut self,
5803 feedback: ThreadFeedback,
5804 window: &mut Window,
5805 cx: &mut Context<Self>,
5806 ) {
5807 let Some(thread) = self.thread().cloned() else {
5808 return;
5809 };
5810
5811 self.thread_feedback.submit(thread, feedback, window, cx);
5812 cx.notify();
5813 }
5814
5815 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
5816 let Some(thread) = self.thread().cloned() else {
5817 return;
5818 };
5819
5820 self.thread_feedback.submit_comments(thread, cx);
5821 cx.notify();
5822 }
5823
5824 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
5825 if self.token_limit_callout_dismissed {
5826 return None;
5827 }
5828
5829 let token_usage = self.thread()?.read(cx).token_usage()?;
5830 let ratio = token_usage.ratio();
5831
5832 let (severity, icon, title) = match ratio {
5833 acp_thread::TokenUsageRatio::Normal => return None,
5834 acp_thread::TokenUsageRatio::Warning => (
5835 Severity::Warning,
5836 IconName::Warning,
5837 "Thread reaching the token limit soon",
5838 ),
5839 acp_thread::TokenUsageRatio::Exceeded => (
5840 Severity::Error,
5841 IconName::XCircle,
5842 "Thread reached the token limit",
5843 ),
5844 };
5845
5846 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
5847 thread.read(cx).completion_mode() == CompletionMode::Normal
5848 && thread
5849 .read(cx)
5850 .model()
5851 .is_some_and(|model| model.supports_burn_mode())
5852 });
5853
5854 let description = if burn_mode_available {
5855 "To continue, start a new thread from a summary or turn Burn Mode on."
5856 } else {
5857 "To continue, start a new thread from a summary."
5858 };
5859
5860 Some(
5861 Callout::new()
5862 .severity(severity)
5863 .icon(icon)
5864 .title(title)
5865 .description(description)
5866 .actions_slot(
5867 h_flex()
5868 .gap_0p5()
5869 .child(
5870 Button::new("start-new-thread", "Start New Thread")
5871 .label_size(LabelSize::Small)
5872 .on_click(cx.listener(|this, _, window, cx| {
5873 let Some(thread) = this.thread() else {
5874 return;
5875 };
5876 let session_id = thread.read(cx).session_id().clone();
5877 window.dispatch_action(
5878 crate::NewNativeAgentThreadFromSummary {
5879 from_session_id: session_id,
5880 }
5881 .boxed_clone(),
5882 cx,
5883 );
5884 })),
5885 )
5886 .when(burn_mode_available, |this| {
5887 this.child(
5888 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
5889 .icon_size(IconSize::XSmall)
5890 .on_click(cx.listener(|this, _event, window, cx| {
5891 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
5892 })),
5893 )
5894 }),
5895 )
5896 .dismiss_action(self.dismiss_error_button(cx)),
5897 )
5898 }
5899
5900 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
5901 if !self.is_using_zed_ai_models(cx) {
5902 return None;
5903 }
5904
5905 let user_store = self.project.read(cx).user_store().read(cx);
5906 if user_store.is_usage_based_billing_enabled() {
5907 return None;
5908 }
5909
5910 let plan = user_store
5911 .plan()
5912 .unwrap_or(cloud_llm_client::Plan::V1(PlanV1::ZedFree));
5913
5914 let usage = user_store.model_request_usage()?;
5915
5916 Some(
5917 div()
5918 .child(UsageCallout::new(plan, usage))
5919 .line_height(line_height),
5920 )
5921 }
5922
5923 fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
5924 self.entry_view_state.update(cx, |entry_view_state, cx| {
5925 entry_view_state.agent_ui_font_size_changed(cx);
5926 });
5927 }
5928
5929 pub(crate) fn insert_dragged_files(
5930 &self,
5931 paths: Vec<project::ProjectPath>,
5932 added_worktrees: Vec<Entity<project::Worktree>>,
5933 window: &mut Window,
5934 cx: &mut Context<Self>,
5935 ) {
5936 self.message_editor.update(cx, |message_editor, cx| {
5937 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
5938 })
5939 }
5940
5941 /// Inserts the selected text into the message editor or the message being
5942 /// edited, if any.
5943 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
5944 self.active_editor(cx).update(cx, |editor, cx| {
5945 editor.insert_selections(window, cx);
5946 });
5947 }
5948
5949 fn render_thread_retry_status_callout(
5950 &self,
5951 _window: &mut Window,
5952 _cx: &mut Context<Self>,
5953 ) -> Option<Callout> {
5954 let state = self.thread_retry_status.as_ref()?;
5955
5956 let next_attempt_in = state
5957 .duration
5958 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
5959 if next_attempt_in.is_zero() {
5960 return None;
5961 }
5962
5963 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
5964
5965 let retry_message = if state.max_attempts == 1 {
5966 if next_attempt_in_secs == 1 {
5967 "Retrying. Next attempt in 1 second.".to_string()
5968 } else {
5969 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
5970 }
5971 } else if next_attempt_in_secs == 1 {
5972 format!(
5973 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
5974 state.attempt, state.max_attempts,
5975 )
5976 } else {
5977 format!(
5978 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
5979 state.attempt, state.max_attempts,
5980 )
5981 };
5982
5983 Some(
5984 Callout::new()
5985 .severity(Severity::Warning)
5986 .title(state.last_error.clone())
5987 .description(retry_message),
5988 )
5989 }
5990
5991 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
5992 Callout::new()
5993 .icon(IconName::Warning)
5994 .severity(Severity::Warning)
5995 .title("Codex on Windows")
5996 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
5997 .actions_slot(
5998 Button::new("open-wsl-modal", "Open in WSL")
5999 .icon_size(IconSize::Small)
6000 .icon_color(Color::Muted)
6001 .on_click(cx.listener({
6002 move |_, _, _window, cx| {
6003 #[cfg(windows)]
6004 _window.dispatch_action(
6005 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
6006 cx,
6007 );
6008 cx.notify();
6009 }
6010 })),
6011 )
6012 .dismiss_action(
6013 IconButton::new("dismiss", IconName::Close)
6014 .icon_size(IconSize::Small)
6015 .icon_color(Color::Muted)
6016 .tooltip(Tooltip::text("Dismiss Warning"))
6017 .on_click(cx.listener({
6018 move |this, _, _, cx| {
6019 this.show_codex_windows_warning = false;
6020 cx.notify();
6021 }
6022 })),
6023 )
6024 }
6025
6026 fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
6027 let content = match self.thread_error.as_ref()? {
6028 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx),
6029 ThreadError::Refusal => self.render_refusal_error(cx),
6030 ThreadError::AuthenticationRequired(error) => {
6031 self.render_authentication_required_error(error.clone(), cx)
6032 }
6033 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
6034 ThreadError::ModelRequestLimitReached(plan) => {
6035 self.render_model_request_limit_reached_error(*plan, cx)
6036 }
6037 ThreadError::ToolUseLimitReached => self.render_tool_use_limit_reached_error(cx)?,
6038 };
6039
6040 Some(div().child(content))
6041 }
6042
6043 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
6044 v_flex().w_full().justify_end().child(
6045 h_flex()
6046 .p_2()
6047 .pr_3()
6048 .w_full()
6049 .gap_1p5()
6050 .border_t_1()
6051 .border_color(cx.theme().colors().border)
6052 .bg(cx.theme().colors().element_background)
6053 .child(
6054 h_flex()
6055 .flex_1()
6056 .gap_1p5()
6057 .child(
6058 Icon::new(IconName::Download)
6059 .color(Color::Accent)
6060 .size(IconSize::Small),
6061 )
6062 .child(Label::new("New version available").size(LabelSize::Small)),
6063 )
6064 .child(
6065 Button::new("update-button", format!("Update to v{}", version))
6066 .label_size(LabelSize::Small)
6067 .style(ButtonStyle::Tinted(TintColor::Accent))
6068 .on_click(cx.listener(|this, _, window, cx| {
6069 this.reset(window, cx);
6070 })),
6071 ),
6072 )
6073 }
6074
6075 fn current_mode_id(&self, cx: &App) -> Option<Arc<str>> {
6076 if let Some(thread) = self.as_native_thread(cx) {
6077 Some(thread.read(cx).profile().0.clone())
6078 } else if let Some(mode_selector) = self.mode_selector() {
6079 Some(mode_selector.read(cx).mode().0)
6080 } else {
6081 None
6082 }
6083 }
6084
6085 fn current_model_id(&self, cx: &App) -> Option<String> {
6086 self.model_selector
6087 .as_ref()
6088 .and_then(|selector| selector.read(cx).active_model(cx).map(|m| m.id.to_string()))
6089 }
6090
6091 fn current_model_name(&self, cx: &App) -> SharedString {
6092 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
6093 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
6094 // This provides better clarity about what refused the request
6095 if self.as_native_connection(cx).is_some() {
6096 self.model_selector
6097 .as_ref()
6098 .and_then(|selector| selector.read(cx).active_model(cx))
6099 .map(|model| model.name.clone())
6100 .unwrap_or_else(|| SharedString::from("The model"))
6101 } else {
6102 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
6103 self.agent.name()
6104 }
6105 }
6106
6107 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
6108 let model_or_agent_name = self.current_model_name(cx);
6109 let refusal_message = format!(
6110 "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
6111 model_or_agent_name
6112 );
6113
6114 Callout::new()
6115 .severity(Severity::Error)
6116 .title("Request Refused")
6117 .icon(IconName::XCircle)
6118 .description(refusal_message.clone())
6119 .actions_slot(self.create_copy_button(&refusal_message))
6120 .dismiss_action(self.dismiss_error_button(cx))
6121 }
6122
6123 fn render_any_thread_error(
6124 &mut self,
6125 error: SharedString,
6126 window: &mut Window,
6127 cx: &mut Context<'_, Self>,
6128 ) -> Callout {
6129 let can_resume = self
6130 .thread()
6131 .map_or(false, |thread| thread.read(cx).can_resume(cx));
6132
6133 let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| {
6134 let thread = thread.read(cx);
6135 let supports_burn_mode = thread
6136 .model()
6137 .map_or(false, |model| model.supports_burn_mode());
6138 supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
6139 });
6140
6141 let markdown = if let Some(markdown) = &self.thread_error_markdown {
6142 markdown.clone()
6143 } else {
6144 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
6145 self.thread_error_markdown = Some(markdown.clone());
6146 markdown
6147 };
6148
6149 let markdown_style = default_markdown_style(false, true, window, cx);
6150 let description = self
6151 .render_markdown(markdown, markdown_style)
6152 .into_any_element();
6153
6154 Callout::new()
6155 .severity(Severity::Error)
6156 .icon(IconName::XCircle)
6157 .title("An Error Happened")
6158 .description_slot(description)
6159 .actions_slot(
6160 h_flex()
6161 .gap_0p5()
6162 .when(can_resume && can_enable_burn_mode, |this| {
6163 this.child(
6164 Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry")
6165 .icon(IconName::ZedBurnMode)
6166 .icon_position(IconPosition::Start)
6167 .icon_size(IconSize::Small)
6168 .label_size(LabelSize::Small)
6169 .on_click(cx.listener(|this, _, window, cx| {
6170 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
6171 this.resume_chat(cx);
6172 })),
6173 )
6174 })
6175 .when(can_resume, |this| {
6176 this.child(
6177 IconButton::new("retry", IconName::RotateCw)
6178 .icon_size(IconSize::Small)
6179 .tooltip(Tooltip::text("Retry Generation"))
6180 .on_click(cx.listener(|this, _, _window, cx| {
6181 this.resume_chat(cx);
6182 })),
6183 )
6184 })
6185 .child(self.create_copy_button(error.to_string())),
6186 )
6187 .dismiss_action(self.dismiss_error_button(cx))
6188 }
6189
6190 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
6191 const ERROR_MESSAGE: &str =
6192 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
6193
6194 Callout::new()
6195 .severity(Severity::Error)
6196 .icon(IconName::XCircle)
6197 .title("Free Usage Exceeded")
6198 .description(ERROR_MESSAGE)
6199 .actions_slot(
6200 h_flex()
6201 .gap_0p5()
6202 .child(self.upgrade_button(cx))
6203 .child(self.create_copy_button(ERROR_MESSAGE)),
6204 )
6205 .dismiss_action(self.dismiss_error_button(cx))
6206 }
6207
6208 fn render_authentication_required_error(
6209 &self,
6210 error: SharedString,
6211 cx: &mut Context<Self>,
6212 ) -> Callout {
6213 Callout::new()
6214 .severity(Severity::Error)
6215 .title("Authentication Required")
6216 .icon(IconName::XCircle)
6217 .description(error.clone())
6218 .actions_slot(
6219 h_flex()
6220 .gap_0p5()
6221 .child(self.authenticate_button(cx))
6222 .child(self.create_copy_button(error)),
6223 )
6224 .dismiss_action(self.dismiss_error_button(cx))
6225 }
6226
6227 fn render_model_request_limit_reached_error(
6228 &self,
6229 plan: cloud_llm_client::Plan,
6230 cx: &mut Context<Self>,
6231 ) -> Callout {
6232 let error_message = match plan {
6233 cloud_llm_client::Plan::V1(PlanV1::ZedPro) => {
6234 "Upgrade to usage-based billing for more prompts."
6235 }
6236 cloud_llm_client::Plan::V1(PlanV1::ZedProTrial)
6237 | cloud_llm_client::Plan::V1(PlanV1::ZedFree) => "Upgrade to Zed Pro for more prompts.",
6238 cloud_llm_client::Plan::V2(_) => "",
6239 };
6240
6241 Callout::new()
6242 .severity(Severity::Error)
6243 .title("Model Prompt Limit Reached")
6244 .icon(IconName::XCircle)
6245 .description(error_message)
6246 .actions_slot(
6247 h_flex()
6248 .gap_0p5()
6249 .child(self.upgrade_button(cx))
6250 .child(self.create_copy_button(error_message)),
6251 )
6252 .dismiss_action(self.dismiss_error_button(cx))
6253 }
6254
6255 fn render_tool_use_limit_reached_error(&self, cx: &mut Context<Self>) -> Option<Callout> {
6256 let thread = self.as_native_thread(cx)?;
6257 let supports_burn_mode = thread
6258 .read(cx)
6259 .model()
6260 .is_some_and(|model| model.supports_burn_mode());
6261
6262 let focus_handle = self.focus_handle(cx);
6263
6264 Some(
6265 Callout::new()
6266 .icon(IconName::Info)
6267 .title("Consecutive tool use limit reached.")
6268 .actions_slot(
6269 h_flex()
6270 .gap_0p5()
6271 .when(supports_burn_mode, |this| {
6272 this.child(
6273 Button::new("continue-burn-mode", "Continue with Burn Mode")
6274 .style(ButtonStyle::Filled)
6275 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
6276 .layer(ElevationIndex::ModalSurface)
6277 .label_size(LabelSize::Small)
6278 .key_binding(
6279 KeyBinding::for_action_in(
6280 &ContinueWithBurnMode,
6281 &focus_handle,
6282 cx,
6283 )
6284 .map(|kb| kb.size(rems_from_px(10.))),
6285 )
6286 .tooltip(Tooltip::text(
6287 "Enable Burn Mode for unlimited tool use.",
6288 ))
6289 .on_click({
6290 cx.listener(move |this, _, _window, cx| {
6291 thread.update(cx, |thread, cx| {
6292 thread
6293 .set_completion_mode(CompletionMode::Burn, cx);
6294 });
6295 this.resume_chat(cx);
6296 })
6297 }),
6298 )
6299 })
6300 .child(
6301 Button::new("continue-conversation", "Continue")
6302 .layer(ElevationIndex::ModalSurface)
6303 .label_size(LabelSize::Small)
6304 .key_binding(
6305 KeyBinding::for_action_in(&ContinueThread, &focus_handle, cx)
6306 .map(|kb| kb.size(rems_from_px(10.))),
6307 )
6308 .on_click(cx.listener(|this, _, _window, cx| {
6309 this.resume_chat(cx);
6310 })),
6311 ),
6312 ),
6313 )
6314 }
6315
6316 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
6317 let message = message.into();
6318
6319 CopyButton::new(message).tooltip_label("Copy Error Message")
6320 }
6321
6322 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6323 IconButton::new("dismiss", IconName::Close)
6324 .icon_size(IconSize::Small)
6325 .tooltip(Tooltip::text("Dismiss"))
6326 .on_click(cx.listener({
6327 move |this, _, _, cx| {
6328 this.clear_thread_error(cx);
6329 cx.notify();
6330 }
6331 }))
6332 }
6333
6334 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6335 Button::new("authenticate", "Authenticate")
6336 .label_size(LabelSize::Small)
6337 .style(ButtonStyle::Filled)
6338 .on_click(cx.listener({
6339 move |this, _, window, cx| {
6340 let agent = this.agent.clone();
6341 let ThreadState::Ready { thread, .. } = &this.thread_state else {
6342 return;
6343 };
6344
6345 let connection = thread.read(cx).connection().clone();
6346 this.clear_thread_error(cx);
6347 if let Some(message) = this.in_flight_prompt.take() {
6348 this.message_editor.update(cx, |editor, cx| {
6349 editor.set_message(message, window, cx);
6350 });
6351 }
6352 let this = cx.weak_entity();
6353 window.defer(cx, |window, cx| {
6354 Self::handle_auth_required(
6355 this,
6356 AuthRequired::new(),
6357 agent,
6358 connection,
6359 window,
6360 cx,
6361 );
6362 })
6363 }
6364 }))
6365 }
6366
6367 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
6368 let agent = self.agent.clone();
6369 let ThreadState::Ready { thread, .. } = &self.thread_state else {
6370 return;
6371 };
6372
6373 let connection = thread.read(cx).connection().clone();
6374 self.clear_thread_error(cx);
6375 let this = cx.weak_entity();
6376 window.defer(cx, |window, cx| {
6377 Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
6378 })
6379 }
6380
6381 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6382 Button::new("upgrade", "Upgrade")
6383 .label_size(LabelSize::Small)
6384 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
6385 .on_click(cx.listener({
6386 move |this, _, _, cx| {
6387 this.clear_thread_error(cx);
6388 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
6389 }
6390 }))
6391 }
6392
6393 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
6394 let task = match entry {
6395 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
6396 history.delete_thread(thread.id.clone(), cx)
6397 }),
6398 HistoryEntry::TextThread(text_thread) => {
6399 self.history_store.update(cx, |history, cx| {
6400 history.delete_text_thread(text_thread.path.clone(), cx)
6401 })
6402 }
6403 };
6404 task.detach_and_log_err(cx);
6405 }
6406
6407 /// Returns the currently active editor, either for a message that is being
6408 /// edited or the editor for a new message.
6409 fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
6410 if let Some(index) = self.editing_message
6411 && let Some(editor) = self
6412 .entry_view_state
6413 .read(cx)
6414 .entry(index)
6415 .and_then(|e| e.message_editor())
6416 .cloned()
6417 {
6418 editor
6419 } else {
6420 self.message_editor.clone()
6421 }
6422 }
6423}
6424
6425fn loading_contents_spinner(size: IconSize) -> AnyElement {
6426 Icon::new(IconName::LoadCircle)
6427 .size(size)
6428 .color(Color::Accent)
6429 .with_rotate_animation(3)
6430 .into_any_element()
6431}
6432
6433fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
6434 if agent_name == "Zed Agent" {
6435 format!("Message the {} — @ to include context", agent_name)
6436 } else if has_commands {
6437 format!(
6438 "Message {} — @ to include context, / for commands",
6439 agent_name
6440 )
6441 } else {
6442 format!("Message {} — @ to include context", agent_name)
6443 }
6444}
6445
6446impl Focusable for AcpThreadView {
6447 fn focus_handle(&self, cx: &App) -> FocusHandle {
6448 match self.thread_state {
6449 ThreadState::Ready { .. } => self.active_editor(cx).focus_handle(cx),
6450 ThreadState::Loading { .. }
6451 | ThreadState::LoadError(_)
6452 | ThreadState::Unauthenticated { .. } => self.focus_handle.clone(),
6453 }
6454 }
6455}
6456
6457impl Render for AcpThreadView {
6458 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6459 let has_messages = self.list_state.item_count() > 0;
6460 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
6461
6462 v_flex()
6463 .size_full()
6464 .key_context("AcpThread")
6465 .on_action(cx.listener(Self::toggle_burn_mode))
6466 .on_action(cx.listener(Self::keep_all))
6467 .on_action(cx.listener(Self::reject_all))
6468 .on_action(cx.listener(Self::allow_always))
6469 .on_action(cx.listener(Self::allow_once))
6470 .on_action(cx.listener(Self::reject_once))
6471 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
6472 this.send_queued_message_at_index(0, true, window, cx);
6473 }))
6474 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
6475 this.message_queue.clear();
6476 cx.notify();
6477 }))
6478 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
6479 if let Some(profile_selector) = this.profile_selector.as_ref() {
6480 profile_selector.read(cx).menu_handle().toggle(window, cx);
6481 } else if let Some(mode_selector) = this.mode_selector() {
6482 mode_selector.read(cx).menu_handle().toggle(window, cx);
6483 }
6484 }))
6485 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
6486 if let Some(profile_selector) = this.profile_selector.as_ref() {
6487 profile_selector.update(cx, |profile_selector, cx| {
6488 profile_selector.cycle_profile(cx);
6489 });
6490 } else if let Some(mode_selector) = this.mode_selector() {
6491 mode_selector.update(cx, |mode_selector, cx| {
6492 mode_selector.cycle_mode(window, cx);
6493 });
6494 }
6495 }))
6496 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
6497 if let Some(model_selector) = this.model_selector.as_ref() {
6498 model_selector
6499 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
6500 }
6501 }))
6502 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
6503 if let Some(model_selector) = this.model_selector.as_ref() {
6504 model_selector.update(cx, |model_selector, cx| {
6505 model_selector.cycle_favorite_models(window, cx);
6506 });
6507 }
6508 }))
6509 .track_focus(&self.focus_handle)
6510 .bg(cx.theme().colors().panel_background)
6511 .child(match &self.thread_state {
6512 ThreadState::Unauthenticated {
6513 connection,
6514 description,
6515 configuration_view,
6516 pending_auth_method,
6517 ..
6518 } => v_flex()
6519 .flex_1()
6520 .size_full()
6521 .justify_end()
6522 .child(self.render_auth_required_state(
6523 connection,
6524 description.as_ref(),
6525 configuration_view.as_ref(),
6526 pending_auth_method.as_ref(),
6527 window,
6528 cx,
6529 ))
6530 .into_any_element(),
6531 ThreadState::Loading { .. } => v_flex()
6532 .flex_1()
6533 .child(self.render_recent_history(cx))
6534 .into_any(),
6535 ThreadState::LoadError(e) => v_flex()
6536 .flex_1()
6537 .size_full()
6538 .items_center()
6539 .justify_end()
6540 .child(self.render_load_error(e, window, cx))
6541 .into_any(),
6542 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
6543 if has_messages {
6544 this.child(
6545 list(
6546 self.list_state.clone(),
6547 cx.processor(|this, index: usize, window, cx| {
6548 let Some((entry, len)) = this.thread().and_then(|thread| {
6549 let entries = &thread.read(cx).entries();
6550 Some((entries.get(index)?, entries.len()))
6551 }) else {
6552 return Empty.into_any();
6553 };
6554 this.render_entry(index, len, entry, window, cx)
6555 }),
6556 )
6557 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
6558 .flex_grow()
6559 .into_any(),
6560 )
6561 .vertical_scrollbar_for(&self.list_state, window, cx)
6562 .into_any()
6563 } else {
6564 this.child(self.render_recent_history(cx)).into_any()
6565 }
6566 }),
6567 })
6568 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
6569 // above so that the scrollbar doesn't render behind it. The current setup allows
6570 // the scrollbar to stop exactly at the activity bar start.
6571 .when(has_messages, |this| match &self.thread_state {
6572 ThreadState::Ready { thread, .. } => {
6573 this.children(self.render_activity_bar(thread, window, cx))
6574 }
6575 _ => this,
6576 })
6577 .children(self.render_thread_retry_status_callout(window, cx))
6578 .when(self.show_codex_windows_warning, |this| {
6579 this.child(self.render_codex_windows_warning(cx))
6580 })
6581 .children(self.render_thread_error(window, cx))
6582 .when_some(
6583 self.new_server_version_available.as_ref().filter(|_| {
6584 !has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
6585 }),
6586 |this, version| this.child(self.render_new_version_callout(&version, cx)),
6587 )
6588 .children(
6589 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
6590 Some(usage_callout.into_any_element())
6591 } else {
6592 self.render_token_limit_callout(cx)
6593 .map(|token_limit_callout| token_limit_callout.into_any_element())
6594 },
6595 )
6596 .child(self.render_message_editor(window, cx))
6597 }
6598}
6599
6600fn default_markdown_style(
6601 buffer_font: bool,
6602 muted_text: bool,
6603 window: &Window,
6604 cx: &App,
6605) -> MarkdownStyle {
6606 let theme_settings = ThemeSettings::get_global(cx);
6607 let colors = cx.theme().colors();
6608
6609 let buffer_font_size = theme_settings.agent_buffer_font_size(cx);
6610
6611 let mut text_style = window.text_style();
6612 let line_height = buffer_font_size * 1.75;
6613
6614 let font_family = if buffer_font {
6615 theme_settings.buffer_font.family.clone()
6616 } else {
6617 theme_settings.ui_font.family.clone()
6618 };
6619
6620 let font_size = if buffer_font {
6621 theme_settings.agent_buffer_font_size(cx)
6622 } else {
6623 theme_settings.agent_ui_font_size(cx)
6624 };
6625
6626 let text_color = if muted_text {
6627 colors.text_muted
6628 } else {
6629 colors.text
6630 };
6631
6632 text_style.refine(&TextStyleRefinement {
6633 font_family: Some(font_family),
6634 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
6635 font_features: Some(theme_settings.ui_font.features.clone()),
6636 font_size: Some(font_size.into()),
6637 line_height: Some(line_height.into()),
6638 color: Some(text_color),
6639 ..Default::default()
6640 });
6641
6642 MarkdownStyle {
6643 base_text_style: text_style.clone(),
6644 syntax: cx.theme().syntax().clone(),
6645 selection_background_color: colors.element_selection_background,
6646 code_block_overflow_x_scroll: true,
6647 heading_level_styles: Some(HeadingLevelStyles {
6648 h1: Some(TextStyleRefinement {
6649 font_size: Some(rems(1.15).into()),
6650 ..Default::default()
6651 }),
6652 h2: Some(TextStyleRefinement {
6653 font_size: Some(rems(1.1).into()),
6654 ..Default::default()
6655 }),
6656 h3: Some(TextStyleRefinement {
6657 font_size: Some(rems(1.05).into()),
6658 ..Default::default()
6659 }),
6660 h4: Some(TextStyleRefinement {
6661 font_size: Some(rems(1.).into()),
6662 ..Default::default()
6663 }),
6664 h5: Some(TextStyleRefinement {
6665 font_size: Some(rems(0.95).into()),
6666 ..Default::default()
6667 }),
6668 h6: Some(TextStyleRefinement {
6669 font_size: Some(rems(0.875).into()),
6670 ..Default::default()
6671 }),
6672 }),
6673 code_block: StyleRefinement {
6674 padding: EdgesRefinement {
6675 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
6676 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
6677 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
6678 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
6679 },
6680 margin: EdgesRefinement {
6681 top: Some(Length::Definite(px(8.).into())),
6682 left: Some(Length::Definite(px(0.).into())),
6683 right: Some(Length::Definite(px(0.).into())),
6684 bottom: Some(Length::Definite(px(12.).into())),
6685 },
6686 border_style: Some(BorderStyle::Solid),
6687 border_widths: EdgesRefinement {
6688 top: Some(AbsoluteLength::Pixels(px(1.))),
6689 left: Some(AbsoluteLength::Pixels(px(1.))),
6690 right: Some(AbsoluteLength::Pixels(px(1.))),
6691 bottom: Some(AbsoluteLength::Pixels(px(1.))),
6692 },
6693 border_color: Some(colors.border_variant),
6694 background: Some(colors.editor_background.into()),
6695 text: TextStyleRefinement {
6696 font_family: Some(theme_settings.buffer_font.family.clone()),
6697 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
6698 font_features: Some(theme_settings.buffer_font.features.clone()),
6699 font_size: Some(buffer_font_size.into()),
6700 ..Default::default()
6701 },
6702 ..Default::default()
6703 },
6704 inline_code: TextStyleRefinement {
6705 font_family: Some(theme_settings.buffer_font.family.clone()),
6706 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
6707 font_features: Some(theme_settings.buffer_font.features.clone()),
6708 font_size: Some(buffer_font_size.into()),
6709 background_color: Some(colors.editor_foreground.opacity(0.08)),
6710 ..Default::default()
6711 },
6712 link: TextStyleRefinement {
6713 background_color: Some(colors.editor_foreground.opacity(0.025)),
6714 color: Some(colors.text_accent),
6715 underline: Some(UnderlineStyle {
6716 color: Some(colors.text_accent.opacity(0.5)),
6717 thickness: px(1.),
6718 ..Default::default()
6719 }),
6720 ..Default::default()
6721 },
6722 ..Default::default()
6723 }
6724}
6725
6726fn plan_label_markdown_style(
6727 status: &acp::PlanEntryStatus,
6728 window: &Window,
6729 cx: &App,
6730) -> MarkdownStyle {
6731 let default_md_style = default_markdown_style(false, false, window, cx);
6732
6733 MarkdownStyle {
6734 base_text_style: TextStyle {
6735 color: cx.theme().colors().text_muted,
6736 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
6737 Some(gpui::StrikethroughStyle {
6738 thickness: px(1.),
6739 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
6740 })
6741 } else {
6742 None
6743 },
6744 ..default_md_style.base_text_style
6745 },
6746 ..default_md_style
6747 }
6748}
6749
6750fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
6751 let default_md_style = default_markdown_style(true, false, window, cx);
6752
6753 MarkdownStyle {
6754 base_text_style: TextStyle {
6755 ..default_md_style.base_text_style
6756 },
6757 selection_background_color: cx.theme().colors().element_selection_background,
6758 ..Default::default()
6759 }
6760}
6761
6762#[cfg(test)]
6763pub(crate) mod tests {
6764 use acp_thread::StubAgentConnection;
6765 use agent_client_protocol::SessionId;
6766 use assistant_text_thread::TextThreadStore;
6767 use editor::MultiBufferOffset;
6768 use fs::FakeFs;
6769 use gpui::{EventEmitter, TestAppContext, VisualTestContext};
6770 use project::Project;
6771 use serde_json::json;
6772 use settings::SettingsStore;
6773 use std::any::Any;
6774 use std::path::Path;
6775 use workspace::Item;
6776
6777 use super::*;
6778
6779 #[gpui::test]
6780 async fn test_drop(cx: &mut TestAppContext) {
6781 init_test(cx);
6782
6783 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6784 let weak_view = thread_view.downgrade();
6785 drop(thread_view);
6786 assert!(!weak_view.is_upgradable());
6787 }
6788
6789 #[gpui::test]
6790 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
6791 init_test(cx);
6792
6793 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6794
6795 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6796 message_editor.update_in(cx, |editor, window, cx| {
6797 editor.set_text("Hello", window, cx);
6798 });
6799
6800 cx.deactivate_window();
6801
6802 thread_view.update_in(cx, |thread_view, window, cx| {
6803 thread_view.send(window, cx);
6804 });
6805
6806 cx.run_until_parked();
6807
6808 assert!(
6809 cx.windows()
6810 .iter()
6811 .any(|window| window.downcast::<AgentNotification>().is_some())
6812 );
6813 }
6814
6815 #[gpui::test]
6816 async fn test_notification_for_error(cx: &mut TestAppContext) {
6817 init_test(cx);
6818
6819 let (thread_view, cx) =
6820 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
6821
6822 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6823 message_editor.update_in(cx, |editor, window, cx| {
6824 editor.set_text("Hello", window, cx);
6825 });
6826
6827 cx.deactivate_window();
6828
6829 thread_view.update_in(cx, |thread_view, window, cx| {
6830 thread_view.send(window, cx);
6831 });
6832
6833 cx.run_until_parked();
6834
6835 assert!(
6836 cx.windows()
6837 .iter()
6838 .any(|window| window.downcast::<AgentNotification>().is_some())
6839 );
6840 }
6841
6842 #[gpui::test]
6843 async fn test_refusal_handling(cx: &mut TestAppContext) {
6844 init_test(cx);
6845
6846 let (thread_view, cx) =
6847 setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
6848
6849 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6850 message_editor.update_in(cx, |editor, window, cx| {
6851 editor.set_text("Do something harmful", window, cx);
6852 });
6853
6854 thread_view.update_in(cx, |thread_view, window, cx| {
6855 thread_view.send(window, cx);
6856 });
6857
6858 cx.run_until_parked();
6859
6860 // Check that the refusal error is set
6861 thread_view.read_with(cx, |thread_view, _cx| {
6862 assert!(
6863 matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
6864 "Expected refusal error to be set"
6865 );
6866 });
6867 }
6868
6869 #[gpui::test]
6870 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
6871 init_test(cx);
6872
6873 let tool_call_id = acp::ToolCallId::new("1");
6874 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
6875 .kind(acp::ToolKind::Edit)
6876 .content(vec!["hi".into()]);
6877 let connection =
6878 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6879 tool_call_id,
6880 vec![acp::PermissionOption::new(
6881 "1",
6882 "Allow",
6883 acp::PermissionOptionKind::AllowOnce,
6884 )],
6885 )]));
6886
6887 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6888
6889 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
6890
6891 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6892 message_editor.update_in(cx, |editor, window, cx| {
6893 editor.set_text("Hello", window, cx);
6894 });
6895
6896 cx.deactivate_window();
6897
6898 thread_view.update_in(cx, |thread_view, window, cx| {
6899 thread_view.send(window, cx);
6900 });
6901
6902 cx.run_until_parked();
6903
6904 assert!(
6905 cx.windows()
6906 .iter()
6907 .any(|window| window.downcast::<AgentNotification>().is_some())
6908 );
6909 }
6910
6911 #[gpui::test]
6912 async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
6913 init_test(cx);
6914
6915 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6916
6917 add_to_workspace(thread_view.clone(), cx);
6918
6919 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6920
6921 message_editor.update_in(cx, |editor, window, cx| {
6922 editor.set_text("Hello", window, cx);
6923 });
6924
6925 // Window is active (don't deactivate), but panel will be hidden
6926 // Note: In the test environment, the panel is not actually added to the dock,
6927 // so is_agent_panel_hidden will return true
6928
6929 thread_view.update_in(cx, |thread_view, window, cx| {
6930 thread_view.send(window, cx);
6931 });
6932
6933 cx.run_until_parked();
6934
6935 // Should show notification because window is active but panel is hidden
6936 assert!(
6937 cx.windows()
6938 .iter()
6939 .any(|window| window.downcast::<AgentNotification>().is_some()),
6940 "Expected notification when panel is hidden"
6941 );
6942 }
6943
6944 #[gpui::test]
6945 async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
6946 init_test(cx);
6947
6948 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6949
6950 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6951 message_editor.update_in(cx, |editor, window, cx| {
6952 editor.set_text("Hello", window, cx);
6953 });
6954
6955 // Deactivate window - should show notification regardless of setting
6956 cx.deactivate_window();
6957
6958 thread_view.update_in(cx, |thread_view, window, cx| {
6959 thread_view.send(window, cx);
6960 });
6961
6962 cx.run_until_parked();
6963
6964 // Should still show notification when window is inactive (existing behavior)
6965 assert!(
6966 cx.windows()
6967 .iter()
6968 .any(|window| window.downcast::<AgentNotification>().is_some()),
6969 "Expected notification when window is inactive"
6970 );
6971 }
6972
6973 #[gpui::test]
6974 async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
6975 init_test(cx);
6976
6977 // Set notify_when_agent_waiting to Never
6978 cx.update(|cx| {
6979 AgentSettings::override_global(
6980 AgentSettings {
6981 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6982 ..AgentSettings::get_global(cx).clone()
6983 },
6984 cx,
6985 );
6986 });
6987
6988 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6989
6990 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6991 message_editor.update_in(cx, |editor, window, cx| {
6992 editor.set_text("Hello", window, cx);
6993 });
6994
6995 // Window is active
6996
6997 thread_view.update_in(cx, |thread_view, window, cx| {
6998 thread_view.send(window, cx);
6999 });
7000
7001 cx.run_until_parked();
7002
7003 // Should NOT show notification because notify_when_agent_waiting is Never
7004 assert!(
7005 !cx.windows()
7006 .iter()
7007 .any(|window| window.downcast::<AgentNotification>().is_some()),
7008 "Expected no notification when notify_when_agent_waiting is Never"
7009 );
7010 }
7011
7012 #[gpui::test]
7013 async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
7014 init_test(cx);
7015
7016 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
7017
7018 let weak_view = thread_view.downgrade();
7019
7020 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7021 message_editor.update_in(cx, |editor, window, cx| {
7022 editor.set_text("Hello", window, cx);
7023 });
7024
7025 cx.deactivate_window();
7026
7027 thread_view.update_in(cx, |thread_view, window, cx| {
7028 thread_view.send(window, cx);
7029 });
7030
7031 cx.run_until_parked();
7032
7033 // Verify notification is shown
7034 assert!(
7035 cx.windows()
7036 .iter()
7037 .any(|window| window.downcast::<AgentNotification>().is_some()),
7038 "Expected notification to be shown"
7039 );
7040
7041 // Drop the thread view (simulating navigation to a new thread)
7042 drop(thread_view);
7043 drop(message_editor);
7044 // Trigger an update to flush effects, which will call release_dropped_entities
7045 cx.update(|_window, _cx| {});
7046 cx.run_until_parked();
7047
7048 // Verify the entity was actually released
7049 assert!(
7050 !weak_view.is_upgradable(),
7051 "Thread view entity should be released after dropping"
7052 );
7053
7054 // The notification should be automatically closed via on_release
7055 assert!(
7056 !cx.windows()
7057 .iter()
7058 .any(|window| window.downcast::<AgentNotification>().is_some()),
7059 "Notification should be closed when thread view is dropped"
7060 );
7061 }
7062
7063 async fn setup_thread_view(
7064 agent: impl AgentServer + 'static,
7065 cx: &mut TestAppContext,
7066 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
7067 let fs = FakeFs::new(cx.executor());
7068 let project = Project::test(fs, [], cx).await;
7069 let (workspace, cx) =
7070 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
7071
7072 let text_thread_store =
7073 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
7074 let history_store =
7075 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx)));
7076
7077 let thread_view = cx.update(|window, cx| {
7078 cx.new(|cx| {
7079 AcpThreadView::new(
7080 Rc::new(agent),
7081 None,
7082 None,
7083 workspace.downgrade(),
7084 project,
7085 history_store,
7086 None,
7087 false,
7088 window,
7089 cx,
7090 )
7091 })
7092 });
7093 cx.run_until_parked();
7094 (thread_view, cx)
7095 }
7096
7097 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
7098 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
7099
7100 workspace
7101 .update_in(cx, |workspace, window, cx| {
7102 workspace.add_item_to_active_pane(
7103 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
7104 None,
7105 true,
7106 window,
7107 cx,
7108 );
7109 })
7110 .unwrap();
7111 }
7112
7113 struct ThreadViewItem(Entity<AcpThreadView>);
7114
7115 impl Item for ThreadViewItem {
7116 type Event = ();
7117
7118 fn include_in_nav_history() -> bool {
7119 false
7120 }
7121
7122 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
7123 "Test".into()
7124 }
7125 }
7126
7127 impl EventEmitter<()> for ThreadViewItem {}
7128
7129 impl Focusable for ThreadViewItem {
7130 fn focus_handle(&self, cx: &App) -> FocusHandle {
7131 self.0.read(cx).focus_handle(cx)
7132 }
7133 }
7134
7135 impl Render for ThreadViewItem {
7136 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
7137 self.0.clone().into_any_element()
7138 }
7139 }
7140
7141 struct StubAgentServer<C> {
7142 connection: C,
7143 }
7144
7145 impl<C> StubAgentServer<C> {
7146 fn new(connection: C) -> Self {
7147 Self { connection }
7148 }
7149 }
7150
7151 impl StubAgentServer<StubAgentConnection> {
7152 fn default_response() -> Self {
7153 let conn = StubAgentConnection::new();
7154 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7155 acp::ContentChunk::new("Default response".into()),
7156 )]);
7157 Self::new(conn)
7158 }
7159 }
7160
7161 impl<C> AgentServer for StubAgentServer<C>
7162 where
7163 C: 'static + AgentConnection + Send + Clone,
7164 {
7165 fn logo(&self) -> ui::IconName {
7166 ui::IconName::Ai
7167 }
7168
7169 fn name(&self) -> SharedString {
7170 "Test".into()
7171 }
7172
7173 fn connect(
7174 &self,
7175 _root_dir: Option<&Path>,
7176 _delegate: AgentServerDelegate,
7177 _cx: &mut App,
7178 ) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
7179 Task::ready(Ok((Rc::new(self.connection.clone()), None)))
7180 }
7181
7182 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7183 self
7184 }
7185 }
7186
7187 #[derive(Clone)]
7188 struct SaboteurAgentConnection;
7189
7190 impl AgentConnection for SaboteurAgentConnection {
7191 fn telemetry_id(&self) -> SharedString {
7192 "saboteur".into()
7193 }
7194
7195 fn new_thread(
7196 self: Rc<Self>,
7197 project: Entity<Project>,
7198 _cwd: &Path,
7199 cx: &mut gpui::App,
7200 ) -> Task<gpui::Result<Entity<AcpThread>>> {
7201 Task::ready(Ok(cx.new(|cx| {
7202 let action_log = cx.new(|_| ActionLog::new(project.clone()));
7203 AcpThread::new(
7204 "SaboteurAgentConnection",
7205 self,
7206 project,
7207 action_log,
7208 SessionId::new("test"),
7209 watch::Receiver::constant(
7210 acp::PromptCapabilities::new()
7211 .image(true)
7212 .audio(true)
7213 .embedded_context(true),
7214 ),
7215 cx,
7216 )
7217 })))
7218 }
7219
7220 fn auth_methods(&self) -> &[acp::AuthMethod] {
7221 &[]
7222 }
7223
7224 fn authenticate(
7225 &self,
7226 _method_id: acp::AuthMethodId,
7227 _cx: &mut App,
7228 ) -> Task<gpui::Result<()>> {
7229 unimplemented!()
7230 }
7231
7232 fn prompt(
7233 &self,
7234 _id: Option<acp_thread::UserMessageId>,
7235 _params: acp::PromptRequest,
7236 _cx: &mut App,
7237 ) -> Task<gpui::Result<acp::PromptResponse>> {
7238 Task::ready(Err(anyhow::anyhow!("Error prompting")))
7239 }
7240
7241 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
7242 unimplemented!()
7243 }
7244
7245 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7246 self
7247 }
7248 }
7249
7250 /// Simulates a model which always returns a refusal response
7251 #[derive(Clone)]
7252 struct RefusalAgentConnection;
7253
7254 impl AgentConnection for RefusalAgentConnection {
7255 fn telemetry_id(&self) -> SharedString {
7256 "refusal".into()
7257 }
7258
7259 fn new_thread(
7260 self: Rc<Self>,
7261 project: Entity<Project>,
7262 _cwd: &Path,
7263 cx: &mut gpui::App,
7264 ) -> Task<gpui::Result<Entity<AcpThread>>> {
7265 Task::ready(Ok(cx.new(|cx| {
7266 let action_log = cx.new(|_| ActionLog::new(project.clone()));
7267 AcpThread::new(
7268 "RefusalAgentConnection",
7269 self,
7270 project,
7271 action_log,
7272 SessionId::new("test"),
7273 watch::Receiver::constant(
7274 acp::PromptCapabilities::new()
7275 .image(true)
7276 .audio(true)
7277 .embedded_context(true),
7278 ),
7279 cx,
7280 )
7281 })))
7282 }
7283
7284 fn auth_methods(&self) -> &[acp::AuthMethod] {
7285 &[]
7286 }
7287
7288 fn authenticate(
7289 &self,
7290 _method_id: acp::AuthMethodId,
7291 _cx: &mut App,
7292 ) -> Task<gpui::Result<()>> {
7293 unimplemented!()
7294 }
7295
7296 fn prompt(
7297 &self,
7298 _id: Option<acp_thread::UserMessageId>,
7299 _params: acp::PromptRequest,
7300 _cx: &mut App,
7301 ) -> Task<gpui::Result<acp::PromptResponse>> {
7302 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
7303 }
7304
7305 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
7306 unimplemented!()
7307 }
7308
7309 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7310 self
7311 }
7312 }
7313
7314 pub(crate) fn init_test(cx: &mut TestAppContext) {
7315 cx.update(|cx| {
7316 let settings_store = SettingsStore::test(cx);
7317 cx.set_global(settings_store);
7318 theme::init(theme::LoadThemes::JustBase, cx);
7319 release_channel::init(semver::Version::new(0, 0, 0), cx);
7320 prompt_store::init(cx)
7321 });
7322 }
7323
7324 #[gpui::test]
7325 async fn test_rewind_views(cx: &mut TestAppContext) {
7326 init_test(cx);
7327
7328 let fs = FakeFs::new(cx.executor());
7329 fs.insert_tree(
7330 "/project",
7331 json!({
7332 "test1.txt": "old content 1",
7333 "test2.txt": "old content 2"
7334 }),
7335 )
7336 .await;
7337 let project = Project::test(fs, [Path::new("/project")], cx).await;
7338 let (workspace, cx) =
7339 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
7340
7341 let text_thread_store =
7342 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
7343 let history_store =
7344 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx)));
7345
7346 let connection = Rc::new(StubAgentConnection::new());
7347 let thread_view = cx.update(|window, cx| {
7348 cx.new(|cx| {
7349 AcpThreadView::new(
7350 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
7351 None,
7352 None,
7353 workspace.downgrade(),
7354 project.clone(),
7355 history_store.clone(),
7356 None,
7357 false,
7358 window,
7359 cx,
7360 )
7361 })
7362 });
7363
7364 cx.run_until_parked();
7365
7366 let thread = thread_view
7367 .read_with(cx, |view, _| view.thread().cloned())
7368 .unwrap();
7369
7370 // First user message
7371 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
7372 acp::ToolCall::new("tool1", "Edit file 1")
7373 .kind(acp::ToolKind::Edit)
7374 .status(acp::ToolCallStatus::Completed)
7375 .content(vec![acp::ToolCallContent::Diff(
7376 acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
7377 )]),
7378 )]);
7379
7380 thread
7381 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
7382 .await
7383 .unwrap();
7384 cx.run_until_parked();
7385
7386 thread.read_with(cx, |thread, _| {
7387 assert_eq!(thread.entries().len(), 2);
7388 });
7389
7390 thread_view.read_with(cx, |view, cx| {
7391 view.entry_view_state.read_with(cx, |entry_view_state, _| {
7392 assert!(
7393 entry_view_state
7394 .entry(0)
7395 .unwrap()
7396 .message_editor()
7397 .is_some()
7398 );
7399 assert!(entry_view_state.entry(1).unwrap().has_content());
7400 });
7401 });
7402
7403 // Second user message
7404 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
7405 acp::ToolCall::new("tool2", "Edit file 2")
7406 .kind(acp::ToolKind::Edit)
7407 .status(acp::ToolCallStatus::Completed)
7408 .content(vec![acp::ToolCallContent::Diff(
7409 acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
7410 )]),
7411 )]);
7412
7413 thread
7414 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
7415 .await
7416 .unwrap();
7417 cx.run_until_parked();
7418
7419 let second_user_message_id = thread.read_with(cx, |thread, _| {
7420 assert_eq!(thread.entries().len(), 4);
7421 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
7422 panic!();
7423 };
7424 user_message.id.clone().unwrap()
7425 });
7426
7427 thread_view.read_with(cx, |view, cx| {
7428 view.entry_view_state.read_with(cx, |entry_view_state, _| {
7429 assert!(
7430 entry_view_state
7431 .entry(0)
7432 .unwrap()
7433 .message_editor()
7434 .is_some()
7435 );
7436 assert!(entry_view_state.entry(1).unwrap().has_content());
7437 assert!(
7438 entry_view_state
7439 .entry(2)
7440 .unwrap()
7441 .message_editor()
7442 .is_some()
7443 );
7444 assert!(entry_view_state.entry(3).unwrap().has_content());
7445 });
7446 });
7447
7448 // Rewind to first message
7449 thread
7450 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
7451 .await
7452 .unwrap();
7453
7454 cx.run_until_parked();
7455
7456 thread.read_with(cx, |thread, _| {
7457 assert_eq!(thread.entries().len(), 2);
7458 });
7459
7460 thread_view.read_with(cx, |view, cx| {
7461 view.entry_view_state.read_with(cx, |entry_view_state, _| {
7462 assert!(
7463 entry_view_state
7464 .entry(0)
7465 .unwrap()
7466 .message_editor()
7467 .is_some()
7468 );
7469 assert!(entry_view_state.entry(1).unwrap().has_content());
7470
7471 // Old views should be dropped
7472 assert!(entry_view_state.entry(2).is_none());
7473 assert!(entry_view_state.entry(3).is_none());
7474 });
7475 });
7476 }
7477
7478 #[gpui::test]
7479 async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
7480 init_test(cx);
7481
7482 let connection = StubAgentConnection::new();
7483
7484 // Each user prompt will result in a user message entry plus an agent message entry.
7485 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7486 acp::ContentChunk::new("Response 1".into()),
7487 )]);
7488
7489 let (thread_view, cx) =
7490 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
7491
7492 let thread = thread_view
7493 .read_with(cx, |view, _| view.thread().cloned())
7494 .unwrap();
7495
7496 thread
7497 .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
7498 .await
7499 .unwrap();
7500 cx.run_until_parked();
7501
7502 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7503 acp::ContentChunk::new("Response 2".into()),
7504 )]);
7505
7506 thread
7507 .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
7508 .await
7509 .unwrap();
7510 cx.run_until_parked();
7511
7512 // Move somewhere else first so we're not trivially already on the last user prompt.
7513 thread_view.update(cx, |view, cx| {
7514 view.scroll_to_top(cx);
7515 });
7516 cx.run_until_parked();
7517
7518 thread_view.update(cx, |view, cx| {
7519 view.scroll_to_most_recent_user_prompt(cx);
7520 let scroll_top = view.list_state.logical_scroll_top();
7521 // Entries layout is: [User1, Assistant1, User2, Assistant2]
7522 assert_eq!(scroll_top.item_ix, 2);
7523 });
7524 }
7525
7526 #[gpui::test]
7527 async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
7528 cx: &mut TestAppContext,
7529 ) {
7530 init_test(cx);
7531
7532 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
7533
7534 // With no entries, scrolling should be a no-op and must not panic.
7535 thread_view.update(cx, |view, cx| {
7536 view.scroll_to_most_recent_user_prompt(cx);
7537 let scroll_top = view.list_state.logical_scroll_top();
7538 assert_eq!(scroll_top.item_ix, 0);
7539 });
7540 }
7541
7542 #[gpui::test]
7543 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
7544 init_test(cx);
7545
7546 let connection = StubAgentConnection::new();
7547
7548 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7549 acp::ContentChunk::new("Response".into()),
7550 )]);
7551
7552 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
7553 add_to_workspace(thread_view.clone(), cx);
7554
7555 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7556 message_editor.update_in(cx, |editor, window, cx| {
7557 editor.set_text("Original message to edit", window, cx);
7558 });
7559 thread_view.update_in(cx, |thread_view, window, cx| {
7560 thread_view.send(window, cx);
7561 });
7562
7563 cx.run_until_parked();
7564
7565 let user_message_editor = thread_view.read_with(cx, |view, cx| {
7566 assert_eq!(view.editing_message, None);
7567
7568 view.entry_view_state
7569 .read(cx)
7570 .entry(0)
7571 .unwrap()
7572 .message_editor()
7573 .unwrap()
7574 .clone()
7575 });
7576
7577 // Focus
7578 cx.focus(&user_message_editor);
7579 thread_view.read_with(cx, |view, _cx| {
7580 assert_eq!(view.editing_message, Some(0));
7581 });
7582
7583 // Edit
7584 user_message_editor.update_in(cx, |editor, window, cx| {
7585 editor.set_text("Edited message content", window, cx);
7586 });
7587
7588 // Cancel
7589 user_message_editor.update_in(cx, |_editor, window, cx| {
7590 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
7591 });
7592
7593 thread_view.read_with(cx, |view, _cx| {
7594 assert_eq!(view.editing_message, None);
7595 });
7596
7597 user_message_editor.read_with(cx, |editor, cx| {
7598 assert_eq!(editor.text(cx), "Original message to edit");
7599 });
7600 }
7601
7602 #[gpui::test]
7603 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
7604 init_test(cx);
7605
7606 let connection = StubAgentConnection::new();
7607
7608 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
7609 add_to_workspace(thread_view.clone(), cx);
7610
7611 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7612 let mut events = cx.events(&message_editor);
7613 message_editor.update_in(cx, |editor, window, cx| {
7614 editor.set_text("", window, cx);
7615 });
7616
7617 message_editor.update_in(cx, |_editor, window, cx| {
7618 window.dispatch_action(Box::new(Chat), cx);
7619 });
7620 cx.run_until_parked();
7621 // We shouldn't have received any messages
7622 assert!(matches!(
7623 events.try_next(),
7624 Err(futures::channel::mpsc::TryRecvError { .. })
7625 ));
7626 }
7627
7628 #[gpui::test]
7629 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
7630 init_test(cx);
7631
7632 let connection = StubAgentConnection::new();
7633
7634 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7635 acp::ContentChunk::new("Response".into()),
7636 )]);
7637
7638 let (thread_view, cx) =
7639 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
7640 add_to_workspace(thread_view.clone(), cx);
7641
7642 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7643 message_editor.update_in(cx, |editor, window, cx| {
7644 editor.set_text("Original message to edit", window, cx);
7645 });
7646 thread_view.update_in(cx, |thread_view, window, cx| {
7647 thread_view.send(window, cx);
7648 });
7649
7650 cx.run_until_parked();
7651
7652 let user_message_editor = thread_view.read_with(cx, |view, cx| {
7653 assert_eq!(view.editing_message, None);
7654 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
7655
7656 view.entry_view_state
7657 .read(cx)
7658 .entry(0)
7659 .unwrap()
7660 .message_editor()
7661 .unwrap()
7662 .clone()
7663 });
7664
7665 // Focus
7666 cx.focus(&user_message_editor);
7667
7668 // Edit
7669 user_message_editor.update_in(cx, |editor, window, cx| {
7670 editor.set_text("Edited message content", window, cx);
7671 });
7672
7673 // Send
7674 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7675 acp::ContentChunk::new("New Response".into()),
7676 )]);
7677
7678 user_message_editor.update_in(cx, |_editor, window, cx| {
7679 window.dispatch_action(Box::new(Chat), cx);
7680 });
7681
7682 cx.run_until_parked();
7683
7684 thread_view.read_with(cx, |view, cx| {
7685 assert_eq!(view.editing_message, None);
7686
7687 let entries = view.thread().unwrap().read(cx).entries();
7688 assert_eq!(entries.len(), 2);
7689 assert_eq!(
7690 entries[0].to_markdown(cx),
7691 "## User\n\nEdited message content\n\n"
7692 );
7693 assert_eq!(
7694 entries[1].to_markdown(cx),
7695 "## Assistant\n\nNew Response\n\n"
7696 );
7697
7698 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
7699 assert!(!state.entry(1).unwrap().has_content());
7700 state.entry(0).unwrap().message_editor().unwrap().clone()
7701 });
7702
7703 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
7704 })
7705 }
7706
7707 #[gpui::test]
7708 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
7709 init_test(cx);
7710
7711 let connection = StubAgentConnection::new();
7712
7713 let (thread_view, cx) =
7714 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
7715 add_to_workspace(thread_view.clone(), cx);
7716
7717 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7718 message_editor.update_in(cx, |editor, window, cx| {
7719 editor.set_text("Original message to edit", window, cx);
7720 });
7721 thread_view.update_in(cx, |thread_view, window, cx| {
7722 thread_view.send(window, cx);
7723 });
7724
7725 cx.run_until_parked();
7726
7727 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
7728 let thread = view.thread().unwrap().read(cx);
7729 assert_eq!(thread.entries().len(), 1);
7730
7731 let editor = view
7732 .entry_view_state
7733 .read(cx)
7734 .entry(0)
7735 .unwrap()
7736 .message_editor()
7737 .unwrap()
7738 .clone();
7739
7740 (editor, thread.session_id().clone())
7741 });
7742
7743 // Focus
7744 cx.focus(&user_message_editor);
7745
7746 thread_view.read_with(cx, |view, _cx| {
7747 assert_eq!(view.editing_message, Some(0));
7748 });
7749
7750 // Edit
7751 user_message_editor.update_in(cx, |editor, window, cx| {
7752 editor.set_text("Edited message content", window, cx);
7753 });
7754
7755 thread_view.read_with(cx, |view, _cx| {
7756 assert_eq!(view.editing_message, Some(0));
7757 });
7758
7759 // Finish streaming response
7760 cx.update(|_, cx| {
7761 connection.send_update(
7762 session_id.clone(),
7763 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
7764 cx,
7765 );
7766 connection.end_turn(session_id, acp::StopReason::EndTurn);
7767 });
7768
7769 thread_view.read_with(cx, |view, _cx| {
7770 assert_eq!(view.editing_message, Some(0));
7771 });
7772
7773 cx.run_until_parked();
7774
7775 // Should still be editing
7776 cx.update(|window, cx| {
7777 assert!(user_message_editor.focus_handle(cx).is_focused(window));
7778 assert_eq!(thread_view.read(cx).editing_message, Some(0));
7779 assert_eq!(
7780 user_message_editor.read(cx).text(cx),
7781 "Edited message content"
7782 );
7783 });
7784 }
7785
7786 #[gpui::test]
7787 async fn test_interrupt(cx: &mut TestAppContext) {
7788 init_test(cx);
7789
7790 let connection = StubAgentConnection::new();
7791
7792 let (thread_view, cx) =
7793 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
7794 add_to_workspace(thread_view.clone(), cx);
7795
7796 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7797 message_editor.update_in(cx, |editor, window, cx| {
7798 editor.set_text("Message 1", window, cx);
7799 });
7800 thread_view.update_in(cx, |thread_view, window, cx| {
7801 thread_view.send(window, cx);
7802 });
7803
7804 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
7805 let thread = view.thread().unwrap();
7806
7807 (thread.clone(), thread.read(cx).session_id().clone())
7808 });
7809
7810 cx.run_until_parked();
7811
7812 cx.update(|_, cx| {
7813 connection.send_update(
7814 session_id.clone(),
7815 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
7816 "Message 1 resp".into(),
7817 )),
7818 cx,
7819 );
7820 });
7821
7822 cx.run_until_parked();
7823
7824 thread.read_with(cx, |thread, cx| {
7825 assert_eq!(
7826 thread.to_markdown(cx),
7827 indoc::indoc! {"
7828 ## User
7829
7830 Message 1
7831
7832 ## Assistant
7833
7834 Message 1 resp
7835
7836 "}
7837 )
7838 });
7839
7840 message_editor.update_in(cx, |editor, window, cx| {
7841 editor.set_text("Message 2", window, cx);
7842 });
7843 thread_view.update_in(cx, |thread_view, window, cx| {
7844 thread_view.send(window, cx);
7845 });
7846
7847 cx.update(|_, cx| {
7848 // Simulate a response sent after beginning to cancel
7849 connection.send_update(
7850 session_id.clone(),
7851 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
7852 cx,
7853 );
7854 });
7855
7856 cx.run_until_parked();
7857
7858 // Last Message 1 response should appear before Message 2
7859 thread.read_with(cx, |thread, cx| {
7860 assert_eq!(
7861 thread.to_markdown(cx),
7862 indoc::indoc! {"
7863 ## User
7864
7865 Message 1
7866
7867 ## Assistant
7868
7869 Message 1 response
7870
7871 ## User
7872
7873 Message 2
7874
7875 "}
7876 )
7877 });
7878
7879 cx.update(|_, cx| {
7880 connection.send_update(
7881 session_id.clone(),
7882 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
7883 "Message 2 response".into(),
7884 )),
7885 cx,
7886 );
7887 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
7888 });
7889
7890 cx.run_until_parked();
7891
7892 thread.read_with(cx, |thread, cx| {
7893 assert_eq!(
7894 thread.to_markdown(cx),
7895 indoc::indoc! {"
7896 ## User
7897
7898 Message 1
7899
7900 ## Assistant
7901
7902 Message 1 response
7903
7904 ## User
7905
7906 Message 2
7907
7908 ## Assistant
7909
7910 Message 2 response
7911
7912 "}
7913 )
7914 });
7915 }
7916
7917 #[gpui::test]
7918 async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
7919 init_test(cx);
7920
7921 let connection = StubAgentConnection::new();
7922 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7923 acp::ContentChunk::new("Response".into()),
7924 )]);
7925
7926 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
7927 add_to_workspace(thread_view.clone(), cx);
7928
7929 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
7930 message_editor.update_in(cx, |editor, window, cx| {
7931 editor.set_text("Original message to edit", window, cx)
7932 });
7933 thread_view.update_in(cx, |thread_view, window, cx| thread_view.send(window, cx));
7934 cx.run_until_parked();
7935
7936 let user_message_editor = thread_view.read_with(cx, |thread_view, cx| {
7937 thread_view
7938 .entry_view_state
7939 .read(cx)
7940 .entry(0)
7941 .expect("Should have at least one entry")
7942 .message_editor()
7943 .expect("Should have message editor")
7944 .clone()
7945 });
7946
7947 cx.focus(&user_message_editor);
7948 thread_view.read_with(cx, |thread_view, _cx| {
7949 assert_eq!(thread_view.editing_message, Some(0));
7950 });
7951
7952 // Ensure to edit the focused message before proceeding otherwise, since
7953 // its content is not different from what was sent, focus will be lost.
7954 user_message_editor.update_in(cx, |editor, window, cx| {
7955 editor.set_text("Original message to edit with ", window, cx)
7956 });
7957
7958 // Create a simple buffer with some text so we can create a selection
7959 // that will then be added to the message being edited.
7960 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
7961 (thread_view.workspace.clone(), thread_view.project.clone())
7962 });
7963 let buffer = project.update(cx, |project, cx| {
7964 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
7965 });
7966
7967 workspace
7968 .update_in(cx, |workspace, window, cx| {
7969 let editor = cx.new(|cx| {
7970 let mut editor =
7971 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
7972
7973 editor.change_selections(Default::default(), window, cx, |selections| {
7974 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
7975 });
7976
7977 editor
7978 });
7979 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
7980 })
7981 .unwrap();
7982
7983 thread_view.update_in(cx, |thread_view, window, cx| {
7984 assert_eq!(thread_view.editing_message, Some(0));
7985 thread_view.insert_selections(window, cx);
7986 });
7987
7988 user_message_editor.read_with(cx, |editor, cx| {
7989 let text = editor.editor().read(cx).text(cx);
7990 let expected_text = String::from("Original message to edit with selection ");
7991
7992 assert_eq!(text, expected_text);
7993 });
7994 }
7995
7996 #[gpui::test]
7997 async fn test_insert_selections(cx: &mut TestAppContext) {
7998 init_test(cx);
7999
8000 let connection = StubAgentConnection::new();
8001 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8002 acp::ContentChunk::new("Response".into()),
8003 )]);
8004
8005 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
8006 add_to_workspace(thread_view.clone(), cx);
8007
8008 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
8009 message_editor.update_in(cx, |editor, window, cx| {
8010 editor.set_text("Can you review this snippet ", window, cx)
8011 });
8012
8013 // Create a simple buffer with some text so we can create a selection
8014 // that will then be added to the message being edited.
8015 let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
8016 (thread_view.workspace.clone(), thread_view.project.clone())
8017 });
8018 let buffer = project.update(cx, |project, cx| {
8019 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
8020 });
8021
8022 workspace
8023 .update_in(cx, |workspace, window, cx| {
8024 let editor = cx.new(|cx| {
8025 let mut editor =
8026 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
8027
8028 editor.change_selections(Default::default(), window, cx, |selections| {
8029 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
8030 });
8031
8032 editor
8033 });
8034 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
8035 })
8036 .unwrap();
8037
8038 thread_view.update_in(cx, |thread_view, window, cx| {
8039 assert_eq!(thread_view.editing_message, None);
8040 thread_view.insert_selections(window, cx);
8041 });
8042
8043 thread_view.read_with(cx, |thread_view, cx| {
8044 let text = thread_view.message_editor.read(cx).text(cx);
8045 let expected_txt = String::from("Can you review this snippet selection ");
8046
8047 assert_eq!(text, expected_txt);
8048 })
8049 }
8050}