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