1use crate::{
2 DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
3 agent_configuration::configure_context_server_modal::default_markdown_style,
4};
5use std::cell::RefCell;
6
7use acp_thread::{ContentBlock, PlanEntry};
8use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
9use editor::actions::OpenExcerpts;
10
11use crate::StartThreadIn;
12use crate::message_editor::SharedSessionCapabilities;
13use gpui::{Corner, List};
14use heapless::Vec as ArrayVec;
15use language_model::{LanguageModelEffortLevel, Speed};
16use settings::update_settings_file;
17use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab};
18use workspace::SERIALIZATION_THROTTLE_TIME;
19
20use super::*;
21
22#[derive(Default)]
23struct ThreadFeedbackState {
24 feedback: Option<ThreadFeedback>,
25 comments_editor: Option<Entity<Editor>>,
26}
27
28impl ThreadFeedbackState {
29 pub fn submit(
30 &mut self,
31 thread: Entity<AcpThread>,
32 feedback: ThreadFeedback,
33 window: &mut Window,
34 cx: &mut App,
35 ) {
36 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
37 return;
38 };
39
40 let project = thread.read(cx).project().read(cx);
41 let client = project.client();
42 let user_store = project.user_store();
43 let organization = user_store.read(cx).current_organization();
44
45 if self.feedback == Some(feedback) {
46 return;
47 }
48
49 self.feedback = Some(feedback);
50 match feedback {
51 ThreadFeedback::Positive => {
52 self.comments_editor = None;
53 }
54 ThreadFeedback::Negative => {
55 self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
56 }
57 }
58 let session_id = thread.read(cx).session_id().clone();
59 let parent_session_id = thread.read(cx).parent_session_id().cloned();
60 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
61 let task = telemetry.thread_data(&session_id, cx);
62 let rating = match feedback {
63 ThreadFeedback::Positive => "positive",
64 ThreadFeedback::Negative => "negative",
65 };
66 cx.background_spawn(async move {
67 let thread = task.await?;
68
69 client
70 .cloud_client()
71 .submit_agent_feedback(SubmitAgentThreadFeedbackBody {
72 organization_id: organization.map(|organization| organization.id.clone()),
73 agent: agent_telemetry_id.to_string(),
74 session_id: session_id.to_string(),
75 parent_session_id: parent_session_id.map(|id| id.to_string()),
76 rating: rating.to_string(),
77 thread,
78 })
79 .await?;
80
81 anyhow::Ok(())
82 })
83 .detach_and_log_err(cx);
84 }
85
86 pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
87 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
88 return;
89 };
90
91 let Some(comments) = self
92 .comments_editor
93 .as_ref()
94 .map(|editor| editor.read(cx).text(cx))
95 .filter(|text| !text.trim().is_empty())
96 else {
97 return;
98 };
99
100 self.comments_editor.take();
101
102 let project = thread.read(cx).project().read(cx);
103 let client = project.client();
104 let user_store = project.user_store();
105 let organization = user_store.read(cx).current_organization();
106
107 let session_id = thread.read(cx).session_id().clone();
108 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
109 let task = telemetry.thread_data(&session_id, cx);
110 cx.background_spawn(async move {
111 let thread = task.await?;
112
113 client
114 .cloud_client()
115 .submit_agent_feedback_comments(SubmitAgentThreadFeedbackCommentsBody {
116 organization_id: organization.map(|organization| organization.id.clone()),
117 agent: agent_telemetry_id.to_string(),
118 session_id: session_id.to_string(),
119 comments,
120 thread,
121 })
122 .await?;
123
124 anyhow::Ok(())
125 })
126 .detach_and_log_err(cx);
127 }
128
129 pub fn clear(&mut self) {
130 *self = Self::default()
131 }
132
133 pub fn dismiss_comments(&mut self) {
134 self.comments_editor.take();
135 }
136
137 fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
138 let buffer = cx.new(|cx| {
139 let empty_string = String::new();
140 MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
141 });
142
143 let editor = cx.new(|cx| {
144 let mut editor = Editor::new(
145 editor::EditorMode::AutoHeight {
146 min_lines: 1,
147 max_lines: Some(4),
148 },
149 buffer,
150 None,
151 window,
152 cx,
153 );
154 editor.set_placeholder_text(
155 "What went wrong? Share your feedback so we can improve.",
156 window,
157 cx,
158 );
159 editor
160 });
161
162 editor.read(cx).focus_handle(cx).focus(window, cx);
163 editor
164 }
165}
166
167struct GeneratingSpinner {
168 variant: SpinnerVariant,
169}
170
171impl GeneratingSpinner {
172 fn new(variant: SpinnerVariant) -> Self {
173 Self { variant }
174 }
175}
176
177impl Render for GeneratingSpinner {
178 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
179 SpinnerLabel::with_variant(self.variant).size(LabelSize::Small)
180 }
181}
182
183#[derive(IntoElement)]
184struct GeneratingSpinnerElement {
185 variant: SpinnerVariant,
186}
187
188impl GeneratingSpinnerElement {
189 fn new(variant: SpinnerVariant) -> Self {
190 Self { variant }
191 }
192}
193
194impl RenderOnce for GeneratingSpinnerElement {
195 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
196 let id = match self.variant {
197 SpinnerVariant::Dots => "generating-spinner-view",
198 SpinnerVariant::Sand => "confirmation-spinner-view",
199 _ => "spinner-view",
200 };
201 window.with_id(id, |window| {
202 window.use_state(cx, |_, _| GeneratingSpinner::new(self.variant))
203 })
204 }
205}
206
207pub enum AcpThreadViewEvent {
208 FirstSendRequested { content: Vec<acp::ContentBlock> },
209 MessageSentOrQueued,
210}
211
212impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
213
214/// Tracks the user's permission dropdown selection state for a specific tool call.
215///
216/// Default (no entry in the map) means the last dropdown choice is selected,
217/// which is typically "Only this time".
218#[derive(Clone)]
219pub(crate) enum PermissionSelection {
220 /// A specific choice from the dropdown (e.g., "Always for terminal", "Only this time").
221 /// The index corresponds to the position in the `choices` list from `PermissionOptions`.
222 Choice(usize),
223 /// "Select options…" mode where individual command patterns can be toggled.
224 /// Contains the indices of checked patterns in the `patterns` list.
225 /// All patterns start checked when this mode is first activated.
226 SelectedPatterns(Vec<usize>),
227}
228
229impl PermissionSelection {
230 /// Returns the choice index if a specific dropdown choice is selected,
231 /// or `None` if in per-command pattern mode.
232 pub(crate) fn choice_index(&self) -> Option<usize> {
233 match self {
234 Self::Choice(index) => Some(*index),
235 Self::SelectedPatterns(_) => None,
236 }
237 }
238
239 fn is_pattern_checked(&self, index: usize) -> bool {
240 match self {
241 Self::SelectedPatterns(checked) => checked.contains(&index),
242 _ => false,
243 }
244 }
245
246 fn has_any_checked_patterns(&self) -> bool {
247 match self {
248 Self::SelectedPatterns(checked) => !checked.is_empty(),
249 _ => false,
250 }
251 }
252
253 fn toggle_pattern(&mut self, index: usize) {
254 if let Self::SelectedPatterns(checked) = self {
255 if let Some(pos) = checked.iter().position(|&i| i == index) {
256 checked.swap_remove(pos);
257 } else {
258 checked.push(index);
259 }
260 }
261 }
262}
263
264pub struct ThreadView {
265 pub id: acp::SessionId,
266 pub parent_id: Option<acp::SessionId>,
267 pub thread: Entity<AcpThread>,
268 pub(crate) conversation: Entity<super::Conversation>,
269 pub server_view: WeakEntity<ConversationView>,
270 pub agent_icon: IconName,
271 pub agent_icon_from_external_svg: Option<SharedString>,
272 pub agent_id: AgentId,
273 pub focus_handle: FocusHandle,
274 pub workspace: WeakEntity<Workspace>,
275 pub entry_view_state: Entity<EntryViewState>,
276 pub title_editor: Entity<Editor>,
277 pub config_options_view: Option<Entity<ConfigOptionsView>>,
278 pub mode_selector: Option<Entity<ModeSelector>>,
279 pub model_selector: Option<Entity<ModelSelectorPopover>>,
280 pub profile_selector: Option<Entity<ProfileSelector>>,
281 pub permission_dropdown_handle: PopoverMenuHandle<ContextMenu>,
282 pub thread_retry_status: Option<RetryStatus>,
283 pub(super) thread_error: Option<ThreadError>,
284 pub thread_error_markdown: Option<Entity<Markdown>>,
285 pub token_limit_callout_dismissed: bool,
286 pub last_token_limit_telemetry: Option<acp_thread::TokenUsageRatio>,
287 thread_feedback: ThreadFeedbackState,
288 pub list_state: ListState,
289 pub session_capabilities: SharedSessionCapabilities,
290 /// Tracks which tool calls have their content/output expanded.
291 /// Used for showing/hiding tool call results, terminal output, etc.
292 pub expanded_tool_calls: HashSet<agent_client_protocol::ToolCallId>,
293 pub expanded_tool_call_raw_inputs: HashSet<agent_client_protocol::ToolCallId>,
294 pub expanded_thinking_blocks: HashSet<(usize, usize)>,
295 auto_expanded_thinking_block: Option<(usize, usize)>,
296 user_toggled_thinking_blocks: HashSet<(usize, usize)>,
297 pub subagent_scroll_handles: RefCell<HashMap<agent_client_protocol::SessionId, ScrollHandle>>,
298 pub edits_expanded: bool,
299 pub plan_expanded: bool,
300 pub queue_expanded: bool,
301 pub editor_expanded: bool,
302 pub should_be_following: bool,
303 pub editing_message: Option<usize>,
304 pub local_queued_messages: Vec<QueuedMessage>,
305 pub queued_message_editors: Vec<Entity<MessageEditor>>,
306 pub queued_message_editor_subscriptions: Vec<Subscription>,
307 pub last_synced_queue_length: usize,
308 pub turn_fields: TurnFields,
309 pub discarded_partial_edits: HashSet<agent_client_protocol::ToolCallId>,
310 pub is_loading_contents: bool,
311 pub new_server_version_available: Option<SharedString>,
312 pub resumed_without_history: bool,
313 pub(crate) permission_selections:
314 HashMap<agent_client_protocol::ToolCallId, PermissionSelection>,
315 pub resume_thread_metadata: Option<AgentSessionInfo>,
316 pub _cancel_task: Option<Task<()>>,
317 _save_task: Option<Task<()>>,
318 _draft_resolve_task: Option<Task<()>>,
319 pub skip_queue_processing_count: usize,
320 pub user_interrupted_generation: bool,
321 pub can_fast_track_queue: bool,
322 pub hovered_edited_file_buttons: Option<usize>,
323 pub in_flight_prompt: Option<Vec<acp::ContentBlock>>,
324 pub _subscriptions: Vec<Subscription>,
325 pub message_editor: Entity<MessageEditor>,
326 pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
327 pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
328 pub project: WeakEntity<Project>,
329 pub recent_history_entries: Vec<AgentSessionInfo>,
330 pub hovered_recent_history_item: Option<usize>,
331 pub show_external_source_prompt_warning: bool,
332 pub show_codex_windows_warning: bool,
333 pub generating_indicator_in_list: bool,
334 pub history: Option<Entity<ThreadHistory>>,
335 pub _history_subscription: Option<Subscription>,
336}
337impl Focusable for ThreadView {
338 fn focus_handle(&self, cx: &App) -> FocusHandle {
339 if self.parent_id.is_some() {
340 self.focus_handle.clone()
341 } else {
342 self.active_editor(cx).focus_handle(cx)
343 }
344 }
345}
346
347#[derive(Default)]
348pub struct TurnFields {
349 pub _turn_timer_task: Option<Task<()>>,
350 pub last_turn_duration: Option<Duration>,
351 pub last_turn_tokens: Option<u64>,
352 pub turn_generation: usize,
353 pub turn_started_at: Option<Instant>,
354 pub turn_tokens: Option<u64>,
355}
356
357impl ThreadView {
358 pub(crate) fn new(
359 parent_id: Option<acp::SessionId>,
360 thread: Entity<AcpThread>,
361 conversation: Entity<super::Conversation>,
362 server_view: WeakEntity<ConversationView>,
363 agent_icon: IconName,
364 agent_icon_from_external_svg: Option<SharedString>,
365 agent_id: AgentId,
366 agent_display_name: SharedString,
367 workspace: WeakEntity<Workspace>,
368 entry_view_state: Entity<EntryViewState>,
369 config_options_view: Option<Entity<ConfigOptionsView>>,
370 mode_selector: Option<Entity<ModeSelector>>,
371 model_selector: Option<Entity<ModelSelectorPopover>>,
372 profile_selector: Option<Entity<ProfileSelector>>,
373 list_state: ListState,
374 session_capabilities: SharedSessionCapabilities,
375 resumed_without_history: bool,
376 project: WeakEntity<Project>,
377 thread_store: Option<Entity<ThreadStore>>,
378 history: Option<Entity<ThreadHistory>>,
379 prompt_store: Option<Entity<PromptStore>>,
380 initial_content: Option<AgentInitialContent>,
381 mut subscriptions: Vec<Subscription>,
382 window: &mut Window,
383 cx: &mut Context<Self>,
384 ) -> Self {
385 let id = thread.read(cx).session_id().clone();
386
387 let has_commands = !session_capabilities.read().available_commands().is_empty();
388 let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
389
390 let history_subscription = history.as_ref().map(|h| {
391 cx.observe(h, |this, history, cx| {
392 this.update_recent_history_from_cache(&history, cx);
393 })
394 });
395
396 let mut should_auto_submit = false;
397 let mut show_external_source_prompt_warning = false;
398
399 let message_editor = cx.new(|cx| {
400 let mut editor = MessageEditor::new(
401 workspace.clone(),
402 project.clone(),
403 thread_store,
404 history.as_ref().map(|h| h.downgrade()),
405 prompt_store,
406 session_capabilities.clone(),
407 agent_id.clone(),
408 &placeholder,
409 editor::EditorMode::AutoHeight {
410 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
411 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
412 },
413 window,
414 cx,
415 );
416 if let Some(content) = initial_content {
417 match content {
418 AgentInitialContent::ThreadSummary { session_id, title } => {
419 editor.insert_thread_summary(session_id, title, window, cx);
420 }
421 AgentInitialContent::ContentBlock {
422 blocks,
423 auto_submit,
424 } => {
425 should_auto_submit = auto_submit;
426 editor.set_message(blocks, window, cx);
427 }
428 AgentInitialContent::FromExternalSource(prompt) => {
429 show_external_source_prompt_warning = true;
430 // SECURITY: Be explicit about not auto submitting prompt from external source.
431 should_auto_submit = false;
432 editor.set_message(
433 vec![acp::ContentBlock::Text(acp::TextContent::new(
434 prompt.into_string(),
435 ))],
436 window,
437 cx,
438 );
439 }
440 }
441 } else if let Some(draft) = thread.read(cx).draft_prompt() {
442 editor.set_message(draft.to_vec(), window, cx);
443 }
444 editor
445 });
446
447 let show_codex_windows_warning = cfg!(windows)
448 && project.upgrade().is_some_and(|p| p.read(cx).is_local())
449 && agent_id.as_ref() == "Codex";
450
451 let title_editor = {
452 let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx));
453 let editor = cx.new(|cx| {
454 let mut editor = Editor::single_line(window, cx);
455 if let Some(title) = thread.read(cx).title() {
456 editor.set_text(title, window, cx);
457 } else {
458 editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
459 }
460 editor.set_read_only(!can_edit);
461 editor
462 });
463 subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
464 editor
465 };
466
467 subscriptions.push(cx.subscribe_in(
468 &entry_view_state,
469 window,
470 Self::handle_entry_view_event,
471 ));
472
473 subscriptions.push(cx.subscribe_in(
474 &message_editor,
475 window,
476 Self::handle_message_editor_event,
477 ));
478
479 subscriptions.push(cx.observe(&message_editor, |this, editor, cx| {
480 let is_empty = editor.read(cx).text(cx).is_empty();
481 let draft_contents_task = if is_empty {
482 None
483 } else {
484 Some(editor.update(cx, |editor, cx| editor.draft_contents(cx)))
485 };
486 this._draft_resolve_task = Some(cx.spawn(async move |this, cx| {
487 let draft = if let Some(task) = draft_contents_task {
488 let blocks = task.await.ok().filter(|b| !b.is_empty());
489 blocks
490 } else {
491 None
492 };
493 this.update(cx, |this, cx| {
494 this.thread.update(cx, |thread, _cx| {
495 thread.set_draft_prompt(draft);
496 });
497 this.schedule_save(cx);
498 })
499 .ok();
500 }));
501 }));
502
503 let recent_history_entries = history
504 .as_ref()
505 .map(|h| h.read(cx).get_recent_sessions(3))
506 .unwrap_or_default();
507
508 let mut this = Self {
509 id,
510 parent_id,
511 focus_handle: cx.focus_handle(),
512 thread,
513 conversation,
514 server_view,
515 agent_icon,
516 agent_icon_from_external_svg,
517 agent_id,
518 workspace,
519 entry_view_state,
520 title_editor,
521 config_options_view,
522 mode_selector,
523 model_selector,
524 profile_selector,
525 list_state,
526 session_capabilities,
527 resumed_without_history,
528 _subscriptions: subscriptions,
529 permission_dropdown_handle: PopoverMenuHandle::default(),
530 thread_retry_status: None,
531 thread_error: None,
532 thread_error_markdown: None,
533 token_limit_callout_dismissed: false,
534 last_token_limit_telemetry: None,
535 thread_feedback: Default::default(),
536 expanded_tool_calls: HashSet::default(),
537 expanded_tool_call_raw_inputs: HashSet::default(),
538 expanded_thinking_blocks: HashSet::default(),
539 auto_expanded_thinking_block: None,
540 user_toggled_thinking_blocks: HashSet::default(),
541 subagent_scroll_handles: RefCell::new(HashMap::default()),
542 edits_expanded: false,
543 plan_expanded: false,
544 queue_expanded: true,
545 editor_expanded: false,
546 should_be_following: false,
547 editing_message: None,
548 local_queued_messages: Vec::new(),
549 queued_message_editors: Vec::new(),
550 queued_message_editor_subscriptions: Vec::new(),
551 last_synced_queue_length: 0,
552 turn_fields: TurnFields::default(),
553 discarded_partial_edits: HashSet::default(),
554 is_loading_contents: false,
555 new_server_version_available: None,
556 permission_selections: HashMap::default(),
557 resume_thread_metadata: None,
558 _cancel_task: None,
559 _save_task: None,
560 _draft_resolve_task: None,
561 skip_queue_processing_count: 0,
562 user_interrupted_generation: false,
563 can_fast_track_queue: false,
564 hovered_edited_file_buttons: None,
565 in_flight_prompt: None,
566 message_editor,
567 add_context_menu_handle: PopoverMenuHandle::default(),
568 thinking_effort_menu_handle: PopoverMenuHandle::default(),
569 project,
570 recent_history_entries,
571 hovered_recent_history_item: None,
572 show_external_source_prompt_warning,
573 history,
574 _history_subscription: history_subscription,
575 show_codex_windows_warning,
576 generating_indicator_in_list: false,
577 };
578
579 this.sync_generating_indicator(cx);
580 this.sync_editor_mode_for_empty_state(cx);
581 let list_state_for_scroll = this.list_state.clone();
582 let thread_view = cx.entity().downgrade();
583
584 this.list_state
585 .set_scroll_handler(move |_event, _window, cx| {
586 let list_state = list_state_for_scroll.clone();
587 let thread_view = thread_view.clone();
588 // N.B. We must defer because the scroll handler is called while the
589 // ListState's RefCell is mutably borrowed. Reading logical_scroll_top()
590 // directly would panic from a double borrow.
591 cx.defer(move |cx| {
592 let scroll_top = list_state.logical_scroll_top();
593 let _ = thread_view.update(cx, |this, cx| {
594 if let Some(thread) = this.as_native_thread(cx) {
595 thread.update(cx, |thread, _cx| {
596 thread.set_ui_scroll_position(Some(scroll_top));
597 });
598 }
599 this.schedule_save(cx);
600 });
601 });
602 });
603
604 if should_auto_submit {
605 this.send(window, cx);
606 }
607 this
608 }
609
610 /// Schedule a throttled save of the thread state (draft prompt, scroll position, etc.).
611 /// Multiple calls within `SERIALIZATION_THROTTLE_TIME` are coalesced into a single save.
612 fn schedule_save(&mut self, cx: &mut Context<Self>) {
613 self._save_task = Some(cx.spawn(async move |this, cx| {
614 cx.background_executor()
615 .timer(SERIALIZATION_THROTTLE_TIME)
616 .await;
617 this.update(cx, |this, cx| {
618 if let Some(thread) = this.as_native_thread(cx) {
619 thread.update(cx, |_thread, cx| cx.notify());
620 }
621 })
622 .ok();
623 }));
624 }
625
626 pub fn handle_message_editor_event(
627 &mut self,
628 _editor: &Entity<MessageEditor>,
629 event: &MessageEditorEvent,
630 window: &mut Window,
631 cx: &mut Context<Self>,
632 ) {
633 match event {
634 MessageEditorEvent::Send => self.send(window, cx),
635 MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
636 MessageEditorEvent::Cancel => self.cancel_generation(cx),
637 MessageEditorEvent::Focus => {
638 self.cancel_editing(&Default::default(), window, cx);
639 }
640 MessageEditorEvent::LostFocus => {}
641 MessageEditorEvent::InputAttempted { .. } => {}
642 }
643 }
644
645 pub(crate) fn as_native_connection(
646 &self,
647 cx: &App,
648 ) -> Option<Rc<agent::NativeAgentConnection>> {
649 let acp_thread = self.thread.read(cx);
650 acp_thread.connection().clone().downcast()
651 }
652
653 pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
654 let acp_thread = self.thread.read(cx);
655 self.as_native_connection(cx)?
656 .thread(acp_thread.session_id(), cx)
657 }
658
659 /// Resolves the message editor's contents into content blocks. For profiles
660 /// that do not enable any tools, directory mentions are expanded to inline
661 /// file contents since the agent can't read files on its own.
662 fn resolve_message_contents(
663 &self,
664 message_editor: &Entity<MessageEditor>,
665 cx: &mut App,
666 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
667 let expand = self.as_native_thread(cx).is_some_and(|thread| {
668 let thread = thread.read(cx);
669 AgentSettings::get_global(cx)
670 .profiles
671 .get(thread.profile())
672 .is_some_and(|profile| profile.tools.is_empty())
673 });
674 message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx))
675 }
676
677 pub fn current_model_id(&self, cx: &App) -> Option<String> {
678 let selector = self.model_selector.as_ref()?;
679 let model = selector.read(cx).active_model(cx)?;
680 Some(model.id.to_string())
681 }
682
683 pub fn current_mode_id(&self, cx: &App) -> Option<Arc<str>> {
684 if let Some(thread) = self.as_native_thread(cx) {
685 Some(thread.read(cx).profile().0.clone())
686 } else {
687 let mode_selector = self.mode_selector.as_ref()?;
688 Some(mode_selector.read(cx).mode().0)
689 }
690 }
691
692 fn is_subagent(&self) -> bool {
693 self.parent_id.is_some()
694 }
695
696 /// Returns the currently active editor, either for a message that is being
697 /// edited or the editor for a new message.
698 pub(crate) fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
699 if let Some(index) = self.editing_message
700 && let Some(editor) = self
701 .entry_view_state
702 .read(cx)
703 .entry(index)
704 .and_then(|entry| entry.message_editor())
705 .cloned()
706 {
707 editor
708 } else {
709 self.message_editor.clone()
710 }
711 }
712
713 pub fn has_queued_messages(&self) -> bool {
714 !self.local_queued_messages.is_empty()
715 }
716
717 pub fn is_imported_thread(&self, cx: &App) -> bool {
718 let Some(thread) = self.as_native_thread(cx) else {
719 return false;
720 };
721 thread.read(cx).is_imported()
722 }
723
724 // events
725
726 pub fn handle_entry_view_event(
727 &mut self,
728 _: &Entity<EntryViewState>,
729 event: &EntryViewEvent,
730 window: &mut Window,
731 cx: &mut Context<Self>,
732 ) {
733 match &event.view_event {
734 ViewEvent::NewDiff(tool_call_id) => {
735 if AgentSettings::get_global(cx).expand_edit_card {
736 self.expanded_tool_calls.insert(tool_call_id.clone());
737 }
738 }
739 ViewEvent::NewTerminal(tool_call_id) => {
740 if AgentSettings::get_global(cx).expand_terminal_card {
741 self.expanded_tool_calls.insert(tool_call_id.clone());
742 }
743 }
744 ViewEvent::TerminalMovedToBackground(tool_call_id) => {
745 self.expanded_tool_calls.remove(tool_call_id);
746 }
747 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
748 if let Some(AgentThreadEntry::UserMessage(user_message)) =
749 self.thread.read(cx).entries().get(event.entry_index)
750 && user_message.id.is_some()
751 && !self.is_subagent()
752 {
753 self.editing_message = Some(event.entry_index);
754 cx.notify();
755 }
756 }
757 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
758 if let Some(AgentThreadEntry::UserMessage(user_message)) =
759 self.thread.read(cx).entries().get(event.entry_index)
760 && user_message.id.is_some()
761 && !self.is_subagent()
762 {
763 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
764 self.editing_message = None;
765 cx.notify();
766 }
767 }
768 }
769 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {}
770 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
771 if !self.is_subagent() {
772 self.regenerate(event.entry_index, editor.clone(), window, cx);
773 }
774 }
775 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
776 self.cancel_editing(&Default::default(), window, cx);
777 }
778 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {}
779 ViewEvent::OpenDiffLocation {
780 path,
781 position,
782 split,
783 } => {
784 self.open_diff_location(path, *position, *split, window, cx);
785 }
786 }
787 }
788
789 fn open_diff_location(
790 &self,
791 path: &str,
792 position: Point,
793 split: bool,
794 window: &mut Window,
795 cx: &mut Context<Self>,
796 ) {
797 let Some(project) = self.project.upgrade() else {
798 return;
799 };
800 let Some(project_path) = project.read(cx).find_project_path(path, cx) else {
801 return;
802 };
803
804 let open_task = if split {
805 self.workspace
806 .update(cx, |workspace, cx| {
807 workspace.split_path(project_path, window, cx)
808 })
809 .log_err()
810 } else {
811 self.workspace
812 .update(cx, |workspace, cx| {
813 workspace.open_path(project_path, None, true, window, cx)
814 })
815 .log_err()
816 };
817
818 let Some(open_task) = open_task else {
819 return;
820 };
821
822 window
823 .spawn(cx, async move |cx| {
824 let item = open_task.await?;
825 let Some(editor) = item.downcast::<Editor>() else {
826 return anyhow::Ok(());
827 };
828 editor.update_in(cx, |editor, window, cx| {
829 editor.change_selections(
830 SelectionEffects::scroll(Autoscroll::center()),
831 window,
832 cx,
833 |selections| {
834 selections.select_ranges([position..position]);
835 },
836 );
837 })?;
838 anyhow::Ok(())
839 })
840 .detach_and_log_err(cx);
841 }
842
843 // turns
844
845 pub fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
846 self.turn_fields.turn_generation += 1;
847 let generation = self.turn_fields.turn_generation;
848 self.turn_fields.turn_started_at = Some(Instant::now());
849 self.turn_fields.last_turn_duration = None;
850 self.turn_fields.last_turn_tokens = None;
851 self.turn_fields.turn_tokens = Some(0);
852 self.turn_fields._turn_timer_task = Some(cx.spawn(async move |this, cx| {
853 loop {
854 cx.background_executor().timer(Duration::from_secs(1)).await;
855 if this.update(cx, |_, cx| cx.notify()).is_err() {
856 break;
857 }
858 }
859 }));
860 generation
861 }
862
863 pub fn stop_turn(&mut self, generation: usize, _cx: &mut Context<Self>) {
864 if self.turn_fields.turn_generation != generation {
865 return;
866 }
867 self.turn_fields.last_turn_duration = self
868 .turn_fields
869 .turn_started_at
870 .take()
871 .map(|started| started.elapsed());
872 self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take();
873 self.turn_fields._turn_timer_task = None;
874 }
875
876 pub fn update_turn_tokens(&mut self, cx: &App) {
877 if let Some(usage) = self.thread.read(cx).token_usage() {
878 if let Some(tokens) = &mut self.turn_fields.turn_tokens {
879 *tokens += usage.output_tokens;
880 }
881 }
882 }
883
884 // sending
885
886 fn clear_external_source_prompt_warning(&mut self, cx: &mut Context<Self>) {
887 if self.show_external_source_prompt_warning {
888 self.show_external_source_prompt_warning = false;
889 cx.notify();
890 }
891 }
892
893 pub fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
894 let thread = &self.thread;
895
896 if self.is_loading_contents {
897 return;
898 }
899
900 let message_editor = self.message_editor.clone();
901
902 // Intercept the first send so the agent panel can capture the full
903 // content blocks — needed for "Start thread in New Worktree",
904 // which must create a workspace before sending the message there.
905 let intercept_first_send = self.thread.read(cx).entries().is_empty()
906 && !message_editor.read(cx).is_empty(cx)
907 && self
908 .workspace
909 .upgrade()
910 .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
911 .is_some_and(|panel| {
912 !matches!(
913 panel.read(cx).start_thread_in(),
914 StartThreadIn::LocalProject
915 )
916 });
917
918 if intercept_first_send {
919 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
920 let content_task = self.resolve_message_contents(&message_editor, cx);
921
922 cx.spawn(async move |this, cx| match content_task.await {
923 Ok((content, _tracked_buffers)) => {
924 if content.is_empty() {
925 return;
926 }
927
928 this.update(cx, |_, cx| {
929 cx.emit(AcpThreadViewEvent::FirstSendRequested { content });
930 })
931 .ok();
932 }
933 Err(error) => {
934 this.update(cx, |this, cx| {
935 this.handle_thread_error(error, cx);
936 })
937 .ok();
938 }
939 })
940 .detach();
941
942 return;
943 }
944
945 let is_editor_empty = message_editor.read(cx).is_empty(cx);
946 let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
947
948 let has_queued = self.has_queued_messages();
949 if is_editor_empty && self.can_fast_track_queue && has_queued {
950 self.can_fast_track_queue = false;
951 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
952 self.send_queued_message_at_index(0, true, window, cx);
953 return;
954 }
955
956 if is_editor_empty {
957 return;
958 }
959
960 if is_generating {
961 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
962 self.queue_message(message_editor, window, cx);
963 return;
964 }
965
966 let text = message_editor.read(cx).text(cx);
967 let text = text.trim();
968 if text == "/login" || text == "/logout" {
969 let connection = thread.read(cx).connection().clone();
970 let can_login = !connection.auth_methods().is_empty();
971 // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
972 let logout_supported = text == "/logout"
973 && self
974 .session_capabilities
975 .read()
976 .available_commands()
977 .iter()
978 .any(|command| command.name == "logout");
979 if can_login && !logout_supported {
980 message_editor.update(cx, |editor, cx| editor.clear(window, cx));
981 self.clear_external_source_prompt_warning(cx);
982
983 let connection = self.thread.read(cx).connection().clone();
984 window.defer(cx, {
985 let agent_id = self.agent_id.clone();
986 let server_view = self.server_view.clone();
987 move |window, cx| {
988 ConversationView::handle_auth_required(
989 server_view.clone(),
990 AuthRequired::new(),
991 agent_id,
992 connection,
993 window,
994 cx,
995 );
996 }
997 });
998 cx.notify();
999 return;
1000 }
1001 }
1002
1003 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
1004 self.send_impl(message_editor, window, cx)
1005 }
1006
1007 pub fn send_impl(
1008 &mut self,
1009 message_editor: Entity<MessageEditor>,
1010 window: &mut Window,
1011 cx: &mut Context<Self>,
1012 ) {
1013 let contents = self.resolve_message_contents(&message_editor, cx);
1014
1015 self.thread_error.take();
1016 self.thread_feedback.clear();
1017 self.editing_message.take();
1018
1019 if self.should_be_following {
1020 self.workspace
1021 .update(cx, |workspace, cx| {
1022 workspace.follow(CollaboratorId::Agent, window, cx);
1023 })
1024 .ok();
1025 }
1026
1027 let contents_task = cx.spawn_in(window, async move |_this, cx| {
1028 let (contents, tracked_buffers) = contents.await?;
1029
1030 if contents.is_empty() {
1031 return Ok(None);
1032 }
1033
1034 let _ = cx.update(|window, cx| {
1035 message_editor.update(cx, |message_editor, cx| {
1036 message_editor.clear(window, cx);
1037 });
1038 });
1039
1040 Ok(Some((contents, tracked_buffers)))
1041 });
1042
1043 self.send_content(contents_task, window, cx);
1044 }
1045
1046 pub fn send_content(
1047 &mut self,
1048 contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
1049 window: &mut Window,
1050 cx: &mut Context<Self>,
1051 ) {
1052 let session_id = self.thread.read(cx).session_id().clone();
1053 let parent_session_id = self.thread.read(cx).parent_session_id().cloned();
1054 let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
1055 let is_first_message = self.thread.read(cx).entries().is_empty();
1056 let thread = self.thread.downgrade();
1057
1058 self.is_loading_contents = true;
1059
1060 let model_id = self.current_model_id(cx);
1061 let mode_id = self.current_mode_id(cx);
1062 let guard = cx.new(|_| ());
1063 cx.observe_release(&guard, |this, _guard, cx| {
1064 this.is_loading_contents = false;
1065 cx.notify();
1066 })
1067 .detach();
1068
1069 let task = cx.spawn_in(window, async move |this, cx| {
1070 let Some((contents, tracked_buffers)) = contents_task.await? else {
1071 return Ok(());
1072 };
1073
1074 let generation = this.update(cx, |this, cx| {
1075 this.clear_external_source_prompt_warning(cx);
1076 let generation = this.start_turn(cx);
1077 this.in_flight_prompt = Some(contents.clone());
1078 generation
1079 })?;
1080
1081 this.update_in(cx, |this, _window, cx| {
1082 this.set_editor_is_expanded(false, cx);
1083 })?;
1084
1085 let _ = this.update(cx, |this, cx| {
1086 this.list_state.scroll_to_end();
1087 cx.notify();
1088 });
1089
1090 let _stop_turn = defer({
1091 let this = this.clone();
1092 let mut cx = cx.clone();
1093 move || {
1094 this.update(&mut cx, |this, cx| {
1095 this.stop_turn(generation, cx);
1096 cx.notify();
1097 })
1098 .ok();
1099 }
1100 });
1101 if is_first_message && thread.read_with(cx, |thread, _cx| thread.title().is_none())? {
1102 let text: String = contents
1103 .iter()
1104 .filter_map(|block| match block {
1105 acp::ContentBlock::Text(text_content) => Some(text_content.text.clone()),
1106 acp::ContentBlock::ResourceLink(resource_link) => {
1107 Some(format!("@{}", resource_link.name))
1108 }
1109 _ => None,
1110 })
1111 .collect::<Vec<_>>()
1112 .join(" ");
1113 let text = text.lines().next().unwrap_or("").trim();
1114 if !text.is_empty() {
1115 let title: SharedString = util::truncate_and_trailoff(text, 200).into();
1116 thread.update(cx, |thread, cx| {
1117 thread.set_provisional_title(title, cx);
1118 })?;
1119 }
1120 }
1121
1122 let turn_start_time = Instant::now();
1123 let send = thread.update(cx, |thread, cx| {
1124 thread.action_log().update(cx, |action_log, cx| {
1125 for buffer in tracked_buffers {
1126 action_log.buffer_read(buffer, cx)
1127 }
1128 });
1129 drop(guard);
1130
1131 telemetry::event!(
1132 "Agent Message Sent",
1133 agent = agent_telemetry_id,
1134 session = session_id,
1135 parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()),
1136 model = model_id,
1137 mode = mode_id
1138 );
1139
1140 thread.send(contents, cx)
1141 })?;
1142
1143 let _ = this.update(cx, |this, cx| {
1144 this.sync_generating_indicator(cx);
1145 cx.notify();
1146 });
1147
1148 let res = send.await;
1149 let turn_time_ms = turn_start_time.elapsed().as_millis();
1150 drop(_stop_turn);
1151 let status = if res.is_ok() {
1152 let _ = this.update(cx, |this, _| this.in_flight_prompt.take());
1153 "success"
1154 } else {
1155 "failure"
1156 };
1157 telemetry::event!(
1158 "Agent Turn Completed",
1159 agent = agent_telemetry_id,
1160 session = session_id,
1161 parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()),
1162 model = model_id,
1163 mode = mode_id,
1164 status,
1165 turn_time_ms,
1166 );
1167 res.map(|_| ())
1168 });
1169
1170 cx.spawn(async move |this, cx| {
1171 if let Err(err) = task.await {
1172 this.update(cx, |this, cx| {
1173 this.handle_thread_error(err, cx);
1174 })
1175 .ok();
1176 } else {
1177 this.update(cx, |this, cx| {
1178 let should_be_following = this
1179 .workspace
1180 .update(cx, |workspace, _| {
1181 workspace.is_being_followed(CollaboratorId::Agent)
1182 })
1183 .unwrap_or_default();
1184 this.should_be_following = should_be_following;
1185 })
1186 .ok();
1187 }
1188 })
1189 .detach();
1190 }
1191
1192 pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1193 let thread = &self.thread;
1194
1195 if self.is_loading_contents {
1196 return;
1197 }
1198
1199 let message_editor = self.message_editor.clone();
1200 if thread.read(cx).status() == ThreadStatus::Idle {
1201 self.send_impl(message_editor, window, cx);
1202 return;
1203 }
1204
1205 self.stop_current_and_send_new_message(message_editor, window, cx);
1206 }
1207
1208 fn stop_current_and_send_new_message(
1209 &mut self,
1210 message_editor: Entity<MessageEditor>,
1211 window: &mut Window,
1212 cx: &mut Context<Self>,
1213 ) {
1214 let thread = self.thread.clone();
1215 self.skip_queue_processing_count = 0;
1216 self.user_interrupted_generation = true;
1217
1218 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1219
1220 cx.spawn_in(window, async move |this, cx| {
1221 cancelled.await;
1222
1223 this.update_in(cx, |this, window, cx| {
1224 this.send_impl(message_editor, window, cx);
1225 })
1226 .ok();
1227 })
1228 .detach();
1229 }
1230
1231 pub(crate) fn handle_thread_error(
1232 &mut self,
1233 error: impl Into<ThreadError>,
1234 cx: &mut Context<Self>,
1235 ) {
1236 let error = error.into();
1237 self.emit_thread_error_telemetry(&error, cx);
1238 self.thread_error = Some(error);
1239 cx.notify();
1240 }
1241
1242 fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context<Self>) {
1243 let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
1244 match error {
1245 ThreadError::PaymentRequired => (
1246 "payment_required",
1247 None,
1248 "You reached your free usage limit. Upgrade to Zed Pro for more prompts."
1249 .into(),
1250 ),
1251 ThreadError::Refusal => {
1252 let model_or_agent_name = self.current_model_name(cx);
1253 let message = format!(
1254 "{} 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.",
1255 model_or_agent_name
1256 );
1257 ("refusal", None, message.into())
1258 }
1259 ThreadError::AuthenticationRequired(message) => {
1260 ("authentication_required", None, message.clone())
1261 }
1262 ThreadError::Other {
1263 acp_error_code,
1264 message,
1265 } => ("other", acp_error_code.clone(), message.clone()),
1266 };
1267
1268 let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
1269 let session_id = self.thread.read(cx).session_id().clone();
1270 let parent_session_id = self
1271 .thread
1272 .read(cx)
1273 .parent_session_id()
1274 .map(|id| id.to_string());
1275
1276 telemetry::event!(
1277 "Agent Panel Error Shown",
1278 agent = agent_telemetry_id,
1279 session_id = session_id,
1280 parent_session_id = parent_session_id,
1281 kind = error_kind,
1282 acp_error_code = acp_error_code,
1283 message = message,
1284 );
1285 }
1286
1287 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1288 self.thread_retry_status.take();
1289 self.thread_error.take();
1290 self.user_interrupted_generation = true;
1291 self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx)));
1292 self.sync_generating_indicator(cx);
1293 cx.notify();
1294 }
1295
1296 pub fn retry_generation(&mut self, cx: &mut Context<Self>) {
1297 self.thread_error.take();
1298
1299 let thread = &self.thread;
1300 if !thread.read(cx).can_retry(cx) {
1301 return;
1302 }
1303
1304 let task = thread.update(cx, |thread, cx| thread.retry(cx));
1305 self.sync_generating_indicator(cx);
1306 cx.notify();
1307 cx.spawn(async move |this, cx| {
1308 let result = task.await;
1309
1310 this.update(cx, |this, cx| {
1311 if let Err(err) = result {
1312 this.handle_thread_error(err, cx);
1313 }
1314 })
1315 })
1316 .detach();
1317 }
1318
1319 pub fn regenerate(
1320 &mut self,
1321 entry_ix: usize,
1322 message_editor: Entity<MessageEditor>,
1323 window: &mut Window,
1324 cx: &mut Context<Self>,
1325 ) {
1326 if self.is_loading_contents {
1327 return;
1328 }
1329 let thread = self.thread.clone();
1330
1331 let Some(user_message_id) = thread.update(cx, |thread, _| {
1332 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1333 }) else {
1334 return;
1335 };
1336
1337 cx.spawn_in(window, async move |this, cx| {
1338 // Check if there are any edits from prompts before the one being regenerated.
1339 //
1340 // If there are, we keep/accept them since we're not regenerating the prompt that created them.
1341 //
1342 // If editing the prompt that generated the edits, they are auto-rejected
1343 // through the `rewind` function in the `acp_thread`.
1344 let has_earlier_edits = thread.read_with(cx, |thread, _| {
1345 thread
1346 .entries()
1347 .iter()
1348 .take(entry_ix)
1349 .any(|entry| entry.diffs().next().is_some())
1350 });
1351
1352 if has_earlier_edits {
1353 thread.update(cx, |thread, cx| {
1354 thread.action_log().update(cx, |action_log, cx| {
1355 action_log.keep_all_edits(None, cx);
1356 });
1357 });
1358 }
1359
1360 thread
1361 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))
1362 .await?;
1363 this.update_in(cx, |thread, window, cx| {
1364 thread.send_impl(message_editor, window, cx);
1365 thread.focus_handle(cx).focus(window, cx);
1366 })?;
1367 anyhow::Ok(())
1368 })
1369 .detach_and_log_err(cx);
1370 }
1371
1372 // message queueing
1373
1374 fn queue_message(
1375 &mut self,
1376 message_editor: Entity<MessageEditor>,
1377 window: &mut Window,
1378 cx: &mut Context<Self>,
1379 ) {
1380 let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle;
1381
1382 if is_idle {
1383 self.send_impl(message_editor, window, cx);
1384 return;
1385 }
1386
1387 let contents = self.resolve_message_contents(&message_editor, cx);
1388
1389 cx.spawn_in(window, async move |this, cx| {
1390 let (content, tracked_buffers) = contents.await?;
1391
1392 if content.is_empty() {
1393 return Ok::<(), anyhow::Error>(());
1394 }
1395
1396 this.update_in(cx, |this, window, cx| {
1397 this.add_to_queue(content, tracked_buffers, cx);
1398 this.can_fast_track_queue = true;
1399 message_editor.update(cx, |message_editor, cx| {
1400 message_editor.clear(window, cx);
1401 });
1402 cx.notify();
1403 })?;
1404 Ok(())
1405 })
1406 .detach_and_log_err(cx);
1407 }
1408
1409 pub fn add_to_queue(
1410 &mut self,
1411 content: Vec<acp::ContentBlock>,
1412 tracked_buffers: Vec<Entity<Buffer>>,
1413 cx: &mut Context<Self>,
1414 ) {
1415 self.local_queued_messages.push(QueuedMessage {
1416 content,
1417 tracked_buffers,
1418 });
1419 self.sync_queue_flag_to_native_thread(cx);
1420 }
1421
1422 pub fn remove_from_queue(
1423 &mut self,
1424 index: usize,
1425 cx: &mut Context<Self>,
1426 ) -> Option<QueuedMessage> {
1427 if index < self.local_queued_messages.len() {
1428 let removed = self.local_queued_messages.remove(index);
1429 self.sync_queue_flag_to_native_thread(cx);
1430 Some(removed)
1431 } else {
1432 None
1433 }
1434 }
1435
1436 pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<Self>) {
1437 if let Some(native_thread) = self.as_native_thread(cx) {
1438 let has_queued = self.has_queued_messages();
1439 native_thread.update(cx, |thread, _| {
1440 thread.set_has_queued_message(has_queued);
1441 });
1442 }
1443 }
1444
1445 pub fn send_queued_message_at_index(
1446 &mut self,
1447 index: usize,
1448 is_send_now: bool,
1449 window: &mut Window,
1450 cx: &mut Context<Self>,
1451 ) {
1452 let Some(queued) = self.remove_from_queue(index, cx) else {
1453 return;
1454 };
1455 let content = queued.content;
1456 let tracked_buffers = queued.tracked_buffers;
1457
1458 // Only increment skip count for "Send Now" operations (out-of-order sends)
1459 // Normal auto-processing from the Stopped handler doesn't need to skip.
1460 // We only skip the Stopped event from the cancelled generation, NOT the
1461 // Stopped event from the newly sent message (which should trigger queue processing).
1462 if is_send_now {
1463 let is_generating =
1464 self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
1465 self.skip_queue_processing_count += if is_generating { 1 } else { 0 };
1466 }
1467
1468 let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx));
1469
1470 let workspace = self.workspace.clone();
1471
1472 let should_be_following = self.should_be_following;
1473 let contents_task = cx.spawn_in(window, async move |_this, cx| {
1474 cancelled.await;
1475 if should_be_following {
1476 workspace
1477 .update_in(cx, |workspace, window, cx| {
1478 workspace.follow(CollaboratorId::Agent, window, cx);
1479 })
1480 .ok();
1481 }
1482
1483 Ok(Some((content, tracked_buffers)))
1484 });
1485
1486 self.send_content(contents_task, window, cx);
1487 }
1488
1489 pub fn move_queued_message_to_main_editor(
1490 &mut self,
1491 index: usize,
1492 inserted_text: Option<&str>,
1493 cursor_offset: Option<usize>,
1494 window: &mut Window,
1495 cx: &mut Context<Self>,
1496 ) -> bool {
1497 let Some(queued_message) = self.remove_from_queue(index, cx) else {
1498 return false;
1499 };
1500 let queued_content = queued_message.content;
1501 let message_editor = self.message_editor.clone();
1502 let inserted_text = inserted_text.map(ToOwned::to_owned);
1503
1504 window.focus(&message_editor.focus_handle(cx), cx);
1505
1506 if message_editor.read(cx).is_empty(cx) {
1507 message_editor.update(cx, |editor, cx| {
1508 editor.set_message(queued_content, window, cx);
1509 if let Some(offset) = cursor_offset {
1510 editor.set_cursor_offset(offset, window, cx);
1511 }
1512 if let Some(inserted_text) = inserted_text.as_deref() {
1513 editor.insert_text(inserted_text, window, cx);
1514 }
1515 });
1516 cx.notify();
1517 return true;
1518 }
1519
1520 // Adjust cursor offset accounting for existing content
1521 let existing_len = message_editor.read(cx).text(cx).len();
1522 let separator = "\n\n";
1523
1524 message_editor.update(cx, |editor, cx| {
1525 editor.append_message(queued_content, Some(separator), window, cx);
1526 if let Some(offset) = cursor_offset {
1527 let adjusted_offset = existing_len + separator.len() + offset;
1528 editor.set_cursor_offset(adjusted_offset, window, cx);
1529 }
1530 if let Some(inserted_text) = inserted_text.as_deref() {
1531 editor.insert_text(inserted_text, window, cx);
1532 }
1533 });
1534
1535 cx.notify();
1536 true
1537 }
1538
1539 // editor methods
1540
1541 pub fn expand_message_editor(
1542 &mut self,
1543 _: &ExpandMessageEditor,
1544 _window: &mut Window,
1545 cx: &mut Context<Self>,
1546 ) {
1547 if self.list_state.item_count() == 0 {
1548 return;
1549 }
1550 self.set_editor_is_expanded(!self.editor_expanded, cx);
1551 cx.stop_propagation();
1552 cx.notify();
1553 }
1554
1555 pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
1556 self.editor_expanded = is_expanded;
1557 self.message_editor.update(cx, |editor, cx| {
1558 if is_expanded {
1559 editor.set_mode(
1560 EditorMode::Full {
1561 scale_ui_elements_with_buffer_font_size: false,
1562 show_active_line_background: false,
1563 sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
1564 },
1565 cx,
1566 )
1567 } else {
1568 let agent_settings = AgentSettings::get_global(cx);
1569 editor.set_mode(
1570 EditorMode::AutoHeight {
1571 min_lines: agent_settings.message_editor_min_lines,
1572 max_lines: Some(agent_settings.set_message_editor_max_lines()),
1573 },
1574 cx,
1575 )
1576 }
1577 });
1578 cx.notify();
1579 }
1580
1581 pub fn handle_title_editor_event(
1582 &mut self,
1583 title_editor: &Entity<Editor>,
1584 event: &EditorEvent,
1585 window: &mut Window,
1586 cx: &mut Context<Self>,
1587 ) {
1588 let thread = &self.thread;
1589
1590 match event {
1591 EditorEvent::BufferEdited => {
1592 // We only want to set the title if the user has actively edited
1593 // it. If the title editor is not focused, we programmatically
1594 // changed the text, so we don't want to set the title again.
1595 if !title_editor.read(cx).is_focused(window) {
1596 return;
1597 }
1598
1599 let new_title = title_editor.read(cx).text(cx);
1600 thread.update(cx, |thread, cx| {
1601 thread
1602 .set_title(new_title.into(), cx)
1603 .detach_and_log_err(cx);
1604 })
1605 }
1606 EditorEvent::Blurred => {
1607 if title_editor.read(cx).text(cx).is_empty() {
1608 title_editor.update(cx, |editor, cx| {
1609 editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
1610 });
1611 }
1612 }
1613 _ => {}
1614 }
1615 }
1616
1617 pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1618 if let Some(index) = self.editing_message.take()
1619 && let Some(editor) = &self
1620 .entry_view_state
1621 .read(cx)
1622 .entry(index)
1623 .and_then(|e| e.message_editor())
1624 .cloned()
1625 {
1626 editor.update(cx, |editor, cx| {
1627 if let Some(user_message) = self
1628 .thread
1629 .read(cx)
1630 .entries()
1631 .get(index)
1632 .and_then(|e| e.user_message())
1633 {
1634 editor.set_message(user_message.chunks.clone(), window, cx);
1635 }
1636 })
1637 };
1638 self.message_editor.focus_handle(cx).focus(window, cx);
1639 cx.notify();
1640 }
1641
1642 pub fn authorize_tool_call(
1643 &mut self,
1644 session_id: acp::SessionId,
1645 tool_call_id: acp::ToolCallId,
1646 outcome: SelectedPermissionOutcome,
1647 window: &mut Window,
1648 cx: &mut Context<Self>,
1649 ) {
1650 self.conversation.update(cx, |conversation, cx| {
1651 conversation.authorize_tool_call(session_id, tool_call_id, outcome, cx);
1652 });
1653 if self.should_be_following {
1654 self.workspace
1655 .update(cx, |workspace, cx| {
1656 workspace.follow(CollaboratorId::Agent, window, cx);
1657 })
1658 .ok();
1659 }
1660 cx.notify();
1661 }
1662
1663 pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
1664 self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
1665 }
1666
1667 pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
1668 self.authorize_pending_with_granularity(true, window, cx);
1669 }
1670
1671 pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
1672 self.authorize_pending_with_granularity(false, window, cx);
1673 }
1674
1675 pub fn authorize_pending_tool_call(
1676 &mut self,
1677 kind: acp::PermissionOptionKind,
1678 window: &mut Window,
1679 cx: &mut Context<Self>,
1680 ) -> Option<()> {
1681 self.conversation.update(cx, |conversation, cx| {
1682 conversation.authorize_pending_tool_call(&self.id, kind, cx)
1683 })?;
1684 if self.should_be_following {
1685 self.workspace
1686 .update(cx, |workspace, cx| {
1687 workspace.follow(CollaboratorId::Agent, window, cx);
1688 })
1689 .ok();
1690 }
1691 cx.notify();
1692 Some(())
1693 }
1694
1695 fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool {
1696 if let AgentThreadEntry::ToolCall(tool_call) = entry {
1697 matches!(
1698 tool_call.status,
1699 ToolCallStatus::WaitingForConfirmation { .. }
1700 )
1701 } else {
1702 false
1703 }
1704 }
1705
1706 fn handle_authorize_tool_call(
1707 &mut self,
1708 action: &AuthorizeToolCall,
1709 window: &mut Window,
1710 cx: &mut Context<Self>,
1711 ) {
1712 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1713 let option_id = acp::PermissionOptionId::new(action.option_id.clone());
1714 let option_kind = match action.option_kind.as_str() {
1715 "AllowOnce" => acp::PermissionOptionKind::AllowOnce,
1716 "AllowAlways" => acp::PermissionOptionKind::AllowAlways,
1717 "RejectOnce" => acp::PermissionOptionKind::RejectOnce,
1718 "RejectAlways" => acp::PermissionOptionKind::RejectAlways,
1719 _ => acp::PermissionOptionKind::AllowOnce,
1720 };
1721
1722 self.authorize_tool_call(
1723 self.id.clone(),
1724 tool_call_id,
1725 SelectedPermissionOutcome::new(option_id, option_kind),
1726 window,
1727 cx,
1728 );
1729 }
1730
1731 pub fn handle_select_permission_granularity(
1732 &mut self,
1733 action: &SelectPermissionGranularity,
1734 _window: &mut Window,
1735 cx: &mut Context<Self>,
1736 ) {
1737 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1738 self.permission_selections
1739 .insert(tool_call_id, PermissionSelection::Choice(action.index));
1740
1741 cx.notify();
1742 }
1743
1744 pub fn handle_toggle_command_pattern(
1745 &mut self,
1746 action: &crate::ToggleCommandPattern,
1747 _window: &mut Window,
1748 cx: &mut Context<Self>,
1749 ) {
1750 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1751
1752 match self.permission_selections.get_mut(&tool_call_id) {
1753 Some(PermissionSelection::SelectedPatterns(checked)) => {
1754 // Already in pattern mode — toggle the individual pattern.
1755 if let Some(pos) = checked.iter().position(|&i| i == action.pattern_index) {
1756 checked.swap_remove(pos);
1757 } else {
1758 checked.push(action.pattern_index);
1759 }
1760 }
1761 _ => {
1762 // First click: activate "Select options" with all patterns checked.
1763 let thread = self.thread.read(cx);
1764 let pattern_count = thread
1765 .entries()
1766 .iter()
1767 .find_map(|entry| {
1768 if let AgentThreadEntry::ToolCall(call) = entry {
1769 if call.id == tool_call_id {
1770 if let ToolCallStatus::WaitingForConfirmation { options, .. } =
1771 &call.status
1772 {
1773 if let PermissionOptions::DropdownWithPatterns {
1774 patterns,
1775 ..
1776 } = options
1777 {
1778 return Some(patterns.len());
1779 }
1780 }
1781 }
1782 }
1783 None
1784 })
1785 .unwrap_or(0);
1786 self.permission_selections.insert(
1787 tool_call_id,
1788 PermissionSelection::SelectedPatterns((0..pattern_count).collect()),
1789 );
1790 }
1791 }
1792 cx.notify();
1793 }
1794
1795 fn authorize_pending_with_granularity(
1796 &mut self,
1797 is_allow: bool,
1798 window: &mut Window,
1799 cx: &mut Context<Self>,
1800 ) -> Option<()> {
1801 let (session_id, tool_call_id, options) =
1802 self.conversation.read(cx).pending_tool_call(&self.id, cx)?;
1803 let options = options.clone();
1804 self.authorize_with_granularity(session_id, tool_call_id, &options, is_allow, window, cx)
1805 }
1806
1807 fn authorize_with_granularity(
1808 &mut self,
1809 session_id: acp::SessionId,
1810 tool_call_id: acp::ToolCallId,
1811 options: &PermissionOptions,
1812 is_allow: bool,
1813 window: &mut Window,
1814 cx: &mut Context<Self>,
1815 ) -> Option<()> {
1816 let choices = match options {
1817 PermissionOptions::Dropdown(choices) => choices.as_slice(),
1818 PermissionOptions::DropdownWithPatterns { choices, .. } => choices.as_slice(),
1819 _ => {
1820 let kind = if is_allow {
1821 acp::PermissionOptionKind::AllowOnce
1822 } else {
1823 acp::PermissionOptionKind::RejectOnce
1824 };
1825 return self.authorize_pending_tool_call(kind, window, cx);
1826 }
1827 };
1828
1829 let selection = self.permission_selections.get(&tool_call_id);
1830
1831 // When in per-command pattern mode, use the checked patterns.
1832 if let Some(PermissionSelection::SelectedPatterns(checked)) = selection {
1833 if let Some(outcome) = options.build_outcome_for_checked_patterns(checked, is_allow) {
1834 self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx);
1835 return Some(());
1836 }
1837 }
1838
1839 // Use the selected granularity choice ("Always for terminal" or "Only this time")
1840 let selected_index = selection
1841 .and_then(|s| s.choice_index())
1842 .unwrap_or_else(|| choices.len().saturating_sub(1));
1843
1844 let selected_choice = choices.get(selected_index).or(choices.last())?;
1845 let outcome = selected_choice.build_outcome(is_allow);
1846
1847 self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx);
1848
1849 Some(())
1850 }
1851
1852 // edits
1853
1854 pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
1855 let thread = &self.thread;
1856 let telemetry = ActionLogTelemetry::from(thread.read(cx));
1857 let action_log = thread.read(cx).action_log().clone();
1858 action_log.update(cx, |action_log, cx| {
1859 action_log.keep_all_edits(Some(telemetry), cx)
1860 });
1861 }
1862
1863 pub fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
1864 let thread = &self.thread;
1865 let telemetry = ActionLogTelemetry::from(thread.read(cx));
1866 let action_log = thread.read(cx).action_log().clone();
1867 let has_changes = action_log.read(cx).changed_buffers(cx).len() > 0;
1868
1869 action_log
1870 .update(cx, |action_log, cx| {
1871 action_log.reject_all_edits(Some(telemetry), cx)
1872 })
1873 .detach();
1874
1875 if has_changes {
1876 if let Some(workspace) = self.workspace.upgrade() {
1877 workspace.update(cx, |workspace, cx| {
1878 crate::ui::show_undo_reject_toast(workspace, action_log, cx);
1879 });
1880 }
1881 }
1882 }
1883
1884 pub fn undo_last_reject(
1885 &mut self,
1886 _: &UndoLastReject,
1887 _window: &mut Window,
1888 cx: &mut Context<Self>,
1889 ) {
1890 let thread = &self.thread;
1891 let action_log = thread.read(cx).action_log().clone();
1892 action_log
1893 .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
1894 .detach()
1895 }
1896
1897 pub fn open_edited_buffer(
1898 &mut self,
1899 buffer: &Entity<Buffer>,
1900 window: &mut Window,
1901 cx: &mut Context<Self>,
1902 ) {
1903 let thread = &self.thread;
1904
1905 let Some(diff) =
1906 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1907 else {
1908 return;
1909 };
1910
1911 diff.update(cx, |diff, cx| {
1912 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1913 })
1914 }
1915
1916 // thread stuff
1917
1918 fn share_thread(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1919 let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else {
1920 return;
1921 };
1922
1923 let client = project.read(cx).client();
1924 let workspace = self.workspace.clone();
1925 let session_id = thread.read(cx).id().to_string();
1926
1927 let load_task = thread.read(cx).to_db(cx);
1928
1929 cx.spawn(async move |_this, cx| {
1930 let db_thread = load_task.await;
1931
1932 let shared_thread = SharedThread::from_db_thread(&db_thread);
1933 let thread_data = shared_thread.to_bytes()?;
1934 let title = shared_thread.title.to_string();
1935
1936 client
1937 .request(proto::ShareAgentThread {
1938 session_id: session_id.clone(),
1939 title,
1940 thread_data,
1941 })
1942 .await?;
1943
1944 let share_url = client::zed_urls::shared_agent_thread_url(&session_id);
1945
1946 cx.update(|cx| {
1947 if let Some(workspace) = workspace.upgrade() {
1948 workspace.update(cx, |workspace, cx| {
1949 struct ThreadSharedToast;
1950 workspace.show_toast(
1951 Toast::new(
1952 NotificationId::unique::<ThreadSharedToast>(),
1953 "Thread shared!",
1954 )
1955 .on_click(
1956 "Copy URL",
1957 move |_window, cx| {
1958 cx.write_to_clipboard(ClipboardItem::new_string(
1959 share_url.clone(),
1960 ));
1961 },
1962 ),
1963 cx,
1964 );
1965 });
1966 }
1967 });
1968
1969 anyhow::Ok(())
1970 })
1971 .detach_and_log_err(cx);
1972 }
1973
1974 pub fn sync_thread(
1975 &mut self,
1976 project: Entity<Project>,
1977 server_view: Entity<ConversationView>,
1978 window: &mut Window,
1979 cx: &mut Context<Self>,
1980 ) {
1981 if !self.is_imported_thread(cx) {
1982 return;
1983 }
1984
1985 let Some(session_list) = self
1986 .as_native_connection(cx)
1987 .and_then(|connection| connection.session_list(cx))
1988 .and_then(|list| list.downcast::<NativeAgentSessionList>())
1989 else {
1990 return;
1991 };
1992 let thread_store = session_list.thread_store().clone();
1993
1994 let client = project.read(cx).client();
1995 let session_id = self.thread.read(cx).session_id().clone();
1996 cx.spawn_in(window, async move |this, cx| {
1997 let response = client
1998 .request(proto::GetSharedAgentThread {
1999 session_id: session_id.to_string(),
2000 })
2001 .await?;
2002
2003 let shared_thread = SharedThread::from_bytes(&response.thread_data)?;
2004
2005 let db_thread = shared_thread.to_db_thread();
2006
2007 thread_store
2008 .update(&mut cx.clone(), |store, cx| {
2009 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
2010 })
2011 .await?;
2012
2013 server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?;
2014
2015 this.update_in(cx, |this, _window, cx| {
2016 if let Some(workspace) = this.workspace.upgrade() {
2017 workspace.update(cx, |workspace, cx| {
2018 struct ThreadSyncedToast;
2019 workspace.show_toast(
2020 Toast::new(
2021 NotificationId::unique::<ThreadSyncedToast>(),
2022 "Thread synced with latest version",
2023 )
2024 .autohide(),
2025 cx,
2026 );
2027 });
2028 }
2029 })?;
2030
2031 anyhow::Ok(())
2032 })
2033 .detach_and_log_err(cx);
2034 }
2035
2036 pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
2037 self.thread
2038 .update(cx, |thread, cx| {
2039 thread.restore_checkpoint(message_id.clone(), cx)
2040 })
2041 .detach_and_log_err(cx);
2042 }
2043
2044 pub fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
2045 self.thread_error = None;
2046 self.thread_error_markdown = None;
2047 self.token_limit_callout_dismissed = true;
2048 cx.notify();
2049 }
2050
2051 fn is_following(&self, cx: &App) -> bool {
2052 match self.thread.read(cx).status() {
2053 ThreadStatus::Generating => self
2054 .workspace
2055 .read_with(cx, |workspace, _| {
2056 workspace.is_being_followed(CollaboratorId::Agent)
2057 })
2058 .unwrap_or(false),
2059 _ => self.should_be_following,
2060 }
2061 }
2062
2063 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2064 let following = self.is_following(cx);
2065
2066 self.should_be_following = !following;
2067 if self.thread.read(cx).status() == ThreadStatus::Generating {
2068 self.workspace
2069 .update(cx, |workspace, cx| {
2070 if following {
2071 workspace.unfollow(CollaboratorId::Agent, window, cx);
2072 } else {
2073 workspace.follow(CollaboratorId::Agent, window, cx);
2074 }
2075 })
2076 .ok();
2077 }
2078
2079 telemetry::event!("Follow Agent Selected", following = !following);
2080 }
2081
2082 // other
2083
2084 pub fn render_thread_retry_status_callout(&self) -> Option<Callout> {
2085 let state = self.thread_retry_status.as_ref()?;
2086
2087 let next_attempt_in = state
2088 .duration
2089 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
2090 if next_attempt_in.is_zero() {
2091 return None;
2092 }
2093
2094 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
2095
2096 let retry_message = if state.max_attempts == 1 {
2097 if next_attempt_in_secs == 1 {
2098 "Retrying. Next attempt in 1 second.".to_string()
2099 } else {
2100 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
2101 }
2102 } else if next_attempt_in_secs == 1 {
2103 format!(
2104 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
2105 state.attempt, state.max_attempts,
2106 )
2107 } else {
2108 format!(
2109 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
2110 state.attempt, state.max_attempts,
2111 )
2112 };
2113
2114 Some(
2115 Callout::new()
2116 .icon(IconName::Warning)
2117 .severity(Severity::Warning)
2118 .title(state.last_error.clone())
2119 .description(retry_message),
2120 )
2121 }
2122
2123 pub fn handle_open_rules(
2124 &mut self,
2125 _: &ClickEvent,
2126 window: &mut Window,
2127 cx: &mut Context<Self>,
2128 ) {
2129 let Some(thread) = self.as_native_thread(cx) else {
2130 return;
2131 };
2132 let project_context = thread.read(cx).project_context().read(cx);
2133
2134 let project_entry_ids = project_context
2135 .worktrees
2136 .iter()
2137 .flat_map(|worktree| worktree.rules_file.as_ref())
2138 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
2139 .collect::<Vec<_>>();
2140
2141 self.workspace
2142 .update(cx, move |workspace, cx| {
2143 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
2144 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
2145 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
2146 let project = workspace.project().read(cx);
2147 let project_paths = project_entry_ids
2148 .into_iter()
2149 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
2150 .collect::<Vec<_>>();
2151 for project_path in project_paths {
2152 workspace
2153 .open_path(project_path, None, true, window, cx)
2154 .detach_and_log_err(cx);
2155 }
2156 })
2157 .ok();
2158 }
2159
2160 fn activity_bar_bg(&self, cx: &Context<Self>) -> Hsla {
2161 let editor_bg_color = cx.theme().colors().editor_background;
2162 let active_color = cx.theme().colors().element_selected;
2163 editor_bg_color.blend(active_color.opacity(0.3))
2164 }
2165
2166 pub fn render_activity_bar(
2167 &self,
2168 window: &mut Window,
2169 cx: &Context<Self>,
2170 ) -> Option<AnyElement> {
2171 let thread = self.thread.read(cx);
2172 let action_log = thread.action_log();
2173 let telemetry = ActionLogTelemetry::from(thread);
2174 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2175 let plan = thread.plan();
2176 let queue_is_empty = !self.has_queued_messages();
2177
2178 let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx);
2179 let has_subagents_awaiting = subagents_awaiting_permission.is_some();
2180
2181 if changed_buffers.is_empty()
2182 && plan.is_empty()
2183 && queue_is_empty
2184 && !has_subagents_awaiting
2185 {
2186 return None;
2187 }
2188
2189 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
2190 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
2191 // be, which blocks you from being able to accept or reject edits. This switches the
2192 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
2193 // block you from using the panel.
2194 let pending_edits = false;
2195
2196 let plan_expanded = self.plan_expanded;
2197 let edits_expanded = self.edits_expanded;
2198 let queue_expanded = self.queue_expanded;
2199
2200 let max_content_width = AgentSettings::get_global(cx).max_content_width;
2201
2202 div()
2203 .w_full()
2204 .max_w(max_content_width)
2205 .mx_auto()
2206 .child(
2207 v_flex()
2208 .mx_2()
2209 .bg(self.activity_bar_bg(cx))
2210 .border_1()
2211 .border_b_0()
2212 .border_color(cx.theme().colors().border)
2213 .rounded_t_md()
2214 .shadow(vec![gpui::BoxShadow {
2215 color: gpui::black().opacity(0.12),
2216 offset: point(px(1.), px(-1.)),
2217 blur_radius: px(2.),
2218 spread_radius: px(0.),
2219 }])
2220 .when_some(subagents_awaiting_permission, |this, element| {
2221 this.child(element)
2222 })
2223 .when(
2224 has_subagents_awaiting
2225 && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty),
2226 |this| this.child(Divider::horizontal().color(DividerColor::Border)),
2227 )
2228 .when(!plan.is_empty(), |this| {
2229 this.child(self.render_plan_summary(plan, window, cx))
2230 .when(plan_expanded, |parent| {
2231 parent.child(self.render_plan_entries(plan, window, cx))
2232 })
2233 })
2234 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2235 this.child(Divider::horizontal().color(DividerColor::Border))
2236 })
2237 .when(
2238 !changed_buffers.is_empty() && thread.parent_session_id().is_none(),
2239 |this| {
2240 this.child(self.render_edits_summary(
2241 &changed_buffers,
2242 edits_expanded,
2243 pending_edits,
2244 cx,
2245 ))
2246 .when(edits_expanded, |parent| {
2247 parent.child(self.render_edited_files(
2248 action_log,
2249 telemetry.clone(),
2250 &changed_buffers,
2251 pending_edits,
2252 cx,
2253 ))
2254 })
2255 },
2256 )
2257 .when(!queue_is_empty, |this| {
2258 this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| {
2259 this.child(Divider::horizontal().color(DividerColor::Border))
2260 })
2261 .child(self.render_message_queue_summary(window, cx))
2262 .when(queue_expanded, |parent| {
2263 parent.child(self.render_message_queue_entries(window, cx))
2264 })
2265 }),
2266 )
2267 .into_any()
2268 .into()
2269 }
2270
2271 fn render_edited_files(
2272 &self,
2273 action_log: &Entity<ActionLog>,
2274 telemetry: ActionLogTelemetry,
2275 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2276 pending_edits: bool,
2277 cx: &Context<Self>,
2278 ) -> impl IntoElement {
2279 let editor_bg_color = cx.theme().colors().editor_background;
2280
2281 // Sort edited files alphabetically for consistency with Git diff view
2282 let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect();
2283 sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| {
2284 let path_a = buffer_a.read(cx).file().map(|f| f.path().clone());
2285 let path_b = buffer_b.read(cx).file().map(|f| f.path().clone());
2286 path_a.cmp(&path_b)
2287 });
2288
2289 v_flex()
2290 .id("edited_files_list")
2291 .max_h_40()
2292 .overflow_y_scroll()
2293 .children(
2294 sorted_buffers
2295 .into_iter()
2296 .enumerate()
2297 .flat_map(|(index, (buffer, diff))| {
2298 let file = buffer.read(cx).file()?;
2299 let path = file.path();
2300 let path_style = file.path_style(cx);
2301 let separator = file.path_style(cx).primary_separator();
2302
2303 let file_path = path.parent().and_then(|parent| {
2304 if parent.is_empty() {
2305 None
2306 } else {
2307 Some(
2308 Label::new(format!(
2309 "{}{separator}",
2310 parent.display(path_style)
2311 ))
2312 .color(Color::Muted)
2313 .size(LabelSize::XSmall)
2314 .buffer_font(cx),
2315 )
2316 }
2317 });
2318
2319 let file_name = path.file_name().map(|name| {
2320 Label::new(name.to_string())
2321 .size(LabelSize::XSmall)
2322 .buffer_font(cx)
2323 .ml_1()
2324 });
2325
2326 let full_path = path.display(path_style).to_string();
2327
2328 let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
2329 .map(Icon::from_path)
2330 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2331 .unwrap_or_else(|| {
2332 Icon::new(IconName::File)
2333 .color(Color::Muted)
2334 .size(IconSize::Small)
2335 });
2336
2337 let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
2338
2339 let buttons = self.render_edited_files_buttons(
2340 index,
2341 buffer,
2342 action_log,
2343 &telemetry,
2344 pending_edits,
2345 editor_bg_color,
2346 cx,
2347 );
2348
2349 let element = h_flex()
2350 .group("edited-code")
2351 .id(("file-container", index))
2352 .relative()
2353 .min_w_0()
2354 .p_1p5()
2355 .gap_2()
2356 .justify_between()
2357 .bg(editor_bg_color)
2358 .when(index < changed_buffers.len() - 1, |parent| {
2359 parent.border_color(cx.theme().colors().border).border_b_1()
2360 })
2361 .child(
2362 h_flex()
2363 .id(("file-name-path", index))
2364 .cursor_pointer()
2365 .pr_0p5()
2366 .gap_0p5()
2367 .rounded_xs()
2368 .child(file_icon)
2369 .children(file_name)
2370 .children(file_path)
2371 .child(
2372 DiffStat::new(
2373 "file",
2374 file_stats.lines_added as usize,
2375 file_stats.lines_removed as usize,
2376 )
2377 .label_size(LabelSize::XSmall),
2378 )
2379 .hover(|s| s.bg(cx.theme().colors().element_hover))
2380 .tooltip({
2381 move |_, cx| {
2382 Tooltip::with_meta(
2383 "Go to File",
2384 None,
2385 full_path.clone(),
2386 cx,
2387 )
2388 }
2389 })
2390 .on_click({
2391 let buffer = buffer.clone();
2392 cx.listener(move |this, _, window, cx| {
2393 this.open_edited_buffer(&buffer, window, cx);
2394 })
2395 }),
2396 )
2397 .child(buttons);
2398
2399 Some(element)
2400 }),
2401 )
2402 .into_any_element()
2403 }
2404
2405 fn render_edited_files_buttons(
2406 &self,
2407 index: usize,
2408 buffer: &Entity<Buffer>,
2409 action_log: &Entity<ActionLog>,
2410 telemetry: &ActionLogTelemetry,
2411 pending_edits: bool,
2412 editor_bg_color: Hsla,
2413 cx: &Context<Self>,
2414 ) -> impl IntoElement {
2415 h_flex()
2416 .id("edited-buttons-container")
2417 .visible_on_hover("edited-code")
2418 .absolute()
2419 .right_0()
2420 .px_1()
2421 .gap_1()
2422 .bg(editor_bg_color)
2423 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
2424 if *is_hovered {
2425 this.hovered_edited_file_buttons = Some(index);
2426 } else if this.hovered_edited_file_buttons == Some(index) {
2427 this.hovered_edited_file_buttons = None;
2428 }
2429 cx.notify();
2430 }))
2431 .child(
2432 Button::new("review", "Review")
2433 .label_size(LabelSize::Small)
2434 .on_click({
2435 let buffer = buffer.clone();
2436 cx.listener(move |this, _, window, cx| {
2437 this.open_edited_buffer(&buffer, window, cx);
2438 })
2439 }),
2440 )
2441 .child(
2442 Button::new(("reject-file", index), "Reject")
2443 .label_size(LabelSize::Small)
2444 .disabled(pending_edits)
2445 .on_click({
2446 let buffer = buffer.clone();
2447 let action_log = action_log.clone();
2448 let telemetry = telemetry.clone();
2449 move |_, _, cx| {
2450 action_log.update(cx, |action_log, cx| {
2451 action_log
2452 .reject_edits_in_ranges(
2453 buffer.clone(),
2454 vec![Anchor::min_max_range_for_buffer(
2455 buffer.read(cx).remote_id(),
2456 )],
2457 Some(telemetry.clone()),
2458 cx,
2459 )
2460 .0
2461 .detach_and_log_err(cx);
2462 })
2463 }
2464 }),
2465 )
2466 .child(
2467 Button::new(("keep-file", index), "Keep")
2468 .label_size(LabelSize::Small)
2469 .disabled(pending_edits)
2470 .on_click({
2471 let buffer = buffer.clone();
2472 let action_log = action_log.clone();
2473 let telemetry = telemetry.clone();
2474 move |_, _, cx| {
2475 action_log.update(cx, |action_log, cx| {
2476 action_log.keep_edits_in_range(
2477 buffer.clone(),
2478 Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()),
2479 Some(telemetry.clone()),
2480 cx,
2481 );
2482 })
2483 }
2484 }),
2485 )
2486 }
2487
2488 fn render_subagents_awaiting_permission(&self, cx: &Context<Self>) -> Option<AnyElement> {
2489 let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx);
2490
2491 if awaiting.is_empty() {
2492 return None;
2493 }
2494
2495 let thread = self.thread.read(cx);
2496 let entries = thread.entries();
2497 let mut subagent_items: Vec<(SharedString, usize)> = Vec::new();
2498
2499 for (session_id, _) in &awaiting {
2500 for (entry_ix, entry) in entries.iter().enumerate() {
2501 if let AgentThreadEntry::ToolCall(tool_call) = entry {
2502 if let Some(info) = &tool_call.subagent_session_info {
2503 if &info.session_id == session_id {
2504 let subagent_summary: SharedString = {
2505 let summary_text = tool_call.label.read(cx).source().to_string();
2506 if !summary_text.is_empty() {
2507 summary_text.into()
2508 } else {
2509 "Subagent".into()
2510 }
2511 };
2512 subagent_items.push((subagent_summary, entry_ix));
2513 break;
2514 }
2515 }
2516 }
2517 }
2518 }
2519
2520 if subagent_items.is_empty() {
2521 return None;
2522 }
2523
2524 let item_count = subagent_items.len();
2525
2526 Some(
2527 v_flex()
2528 .child(
2529 h_flex()
2530 .py_1()
2531 .px_2()
2532 .w_full()
2533 .gap_1()
2534 .border_b_1()
2535 .border_color(cx.theme().colors().border)
2536 .child(
2537 Label::new("Subagents Awaiting Permission:")
2538 .size(LabelSize::Small)
2539 .color(Color::Muted),
2540 )
2541 .child(Label::new(item_count.to_string()).size(LabelSize::Small)),
2542 )
2543 .child(
2544 v_flex().children(subagent_items.into_iter().enumerate().map(
2545 |(ix, (label, entry_ix))| {
2546 let is_last = ix == item_count - 1;
2547 let group = format!("group-{}", entry_ix);
2548
2549 h_flex()
2550 .cursor_pointer()
2551 .id(format!("subagent-permission-{}", entry_ix))
2552 .group(&group)
2553 .p_1()
2554 .pl_2()
2555 .min_w_0()
2556 .w_full()
2557 .gap_1()
2558 .justify_between()
2559 .bg(cx.theme().colors().editor_background)
2560 .hover(|s| s.bg(cx.theme().colors().element_hover))
2561 .when(!is_last, |this| {
2562 this.border_b_1().border_color(cx.theme().colors().border)
2563 })
2564 .child(
2565 h_flex()
2566 .gap_1p5()
2567 .child(
2568 Icon::new(IconName::Circle)
2569 .size(IconSize::XSmall)
2570 .color(Color::Warning),
2571 )
2572 .child(
2573 Label::new(label)
2574 .size(LabelSize::Small)
2575 .color(Color::Muted)
2576 .truncate(),
2577 ),
2578 )
2579 .child(
2580 div().visible_on_hover(&group).child(
2581 Label::new("Scroll to Subagent")
2582 .size(LabelSize::Small)
2583 .color(Color::Muted)
2584 .truncate(),
2585 ),
2586 )
2587 .on_click(cx.listener(move |this, _, _, cx| {
2588 this.list_state.scroll_to(ListOffset {
2589 item_ix: entry_ix,
2590 offset_in_item: px(0.0),
2591 });
2592 cx.notify();
2593 }))
2594 },
2595 )),
2596 )
2597 .into_any(),
2598 )
2599 }
2600
2601 fn render_message_queue_summary(
2602 &self,
2603 _window: &mut Window,
2604 cx: &Context<Self>,
2605 ) -> impl IntoElement {
2606 let queue_count = self.local_queued_messages.len();
2607 let title: SharedString = if queue_count == 1 {
2608 "1 Queued Message".into()
2609 } else {
2610 format!("{} Queued Messages", queue_count).into()
2611 };
2612
2613 h_flex()
2614 .p_1()
2615 .w_full()
2616 .gap_1()
2617 .justify_between()
2618 .when(self.queue_expanded, |this| {
2619 this.border_b_1().border_color(cx.theme().colors().border)
2620 })
2621 .child(
2622 h_flex()
2623 .id("queue_summary")
2624 .gap_1()
2625 .child(Disclosure::new("queue_disclosure", self.queue_expanded))
2626 .child(Label::new(title).size(LabelSize::Small).color(Color::Muted))
2627 .on_click(cx.listener(|this, _, _, cx| {
2628 this.queue_expanded = !this.queue_expanded;
2629 cx.notify();
2630 })),
2631 )
2632 .child(
2633 Button::new("clear_queue", "Clear All")
2634 .label_size(LabelSize::Small)
2635 .key_binding(
2636 KeyBinding::for_action(&ClearMessageQueue, cx)
2637 .map(|kb| kb.size(rems_from_px(12.))),
2638 )
2639 .on_click(cx.listener(|this, _, _, cx| {
2640 this.clear_queue(cx);
2641 this.can_fast_track_queue = false;
2642 cx.notify();
2643 })),
2644 )
2645 .into_any_element()
2646 }
2647
2648 fn clear_queue(&mut self, cx: &mut Context<Self>) {
2649 self.local_queued_messages.clear();
2650 self.sync_queue_flag_to_native_thread(cx);
2651 }
2652
2653 fn render_plan_summary(
2654 &self,
2655 plan: &Plan,
2656 window: &mut Window,
2657 cx: &Context<Self>,
2658 ) -> impl IntoElement {
2659 let plan_expanded = self.plan_expanded;
2660 let stats = plan.stats();
2661
2662 let title = if let Some(entry) = stats.in_progress_entry
2663 && !plan_expanded
2664 {
2665 h_flex()
2666 .cursor_default()
2667 .relative()
2668 .w_full()
2669 .gap_1()
2670 .truncate()
2671 .child(
2672 Label::new("Current:")
2673 .size(LabelSize::Small)
2674 .color(Color::Muted),
2675 )
2676 .child(
2677 div()
2678 .text_xs()
2679 .text_color(cx.theme().colors().text_muted)
2680 .line_clamp(1)
2681 .child(MarkdownElement::new(
2682 entry.content.clone(),
2683 plan_label_markdown_style(&entry.status, window, cx),
2684 )),
2685 )
2686 .when(stats.pending > 0, |this| {
2687 this.child(
2688 h_flex()
2689 .absolute()
2690 .top_0()
2691 .right_0()
2692 .h_full()
2693 .child(div().min_w_8().h_full().bg(linear_gradient(
2694 90.,
2695 linear_color_stop(self.activity_bar_bg(cx), 1.),
2696 linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.),
2697 )))
2698 .child(
2699 div().pr_0p5().bg(self.activity_bar_bg(cx)).child(
2700 Label::new(format!("{} left", stats.pending))
2701 .size(LabelSize::Small)
2702 .color(Color::Muted),
2703 ),
2704 ),
2705 )
2706 })
2707 } else {
2708 let status_label = if stats.pending == 0 {
2709 "All Done".to_string()
2710 } else if stats.completed == 0 {
2711 format!("{} Tasks", plan.entries.len())
2712 } else {
2713 format!("{}/{}", stats.completed, plan.entries.len())
2714 };
2715
2716 h_flex()
2717 .w_full()
2718 .gap_1()
2719 .justify_between()
2720 .child(
2721 Label::new("Plan")
2722 .size(LabelSize::Small)
2723 .color(Color::Muted),
2724 )
2725 .child(
2726 Label::new(status_label)
2727 .size(LabelSize::Small)
2728 .color(Color::Muted)
2729 .mr_1(),
2730 )
2731 };
2732
2733 h_flex()
2734 .id("plan_summary")
2735 .p_1()
2736 .w_full()
2737 .gap_1()
2738 .when(plan_expanded, |this| {
2739 this.border_b_1().border_color(cx.theme().colors().border)
2740 })
2741 .child(Disclosure::new("plan_disclosure", plan_expanded))
2742 .child(title.flex_1())
2743 .child(
2744 IconButton::new("dismiss-plan", IconName::Close)
2745 .icon_size(IconSize::XSmall)
2746 .shape(ui::IconButtonShape::Square)
2747 .tooltip(Tooltip::text("Clear plan"))
2748 .on_click(cx.listener(|this, _, _, cx| {
2749 this.thread.update(cx, |thread, cx| thread.clear_plan(cx));
2750 cx.stop_propagation();
2751 })),
2752 )
2753 .on_click(cx.listener(|this, _, _, cx| {
2754 this.plan_expanded = !this.plan_expanded;
2755 cx.notify();
2756 }))
2757 .into_any_element()
2758 }
2759
2760 fn render_plan_entries(
2761 &self,
2762 plan: &Plan,
2763 window: &mut Window,
2764 cx: &Context<Self>,
2765 ) -> impl IntoElement {
2766 v_flex()
2767 .id("plan_items_list")
2768 .max_h_40()
2769 .overflow_y_scroll()
2770 .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
2771 let element = h_flex()
2772 .py_1()
2773 .px_2()
2774 .gap_2()
2775 .justify_between()
2776 .bg(cx.theme().colors().editor_background)
2777 .when(index < plan.entries.len() - 1, |parent| {
2778 parent.border_color(cx.theme().colors().border).border_b_1()
2779 })
2780 .child(
2781 h_flex()
2782 .id(("plan_entry", index))
2783 .gap_1p5()
2784 .max_w_full()
2785 .overflow_x_scroll()
2786 .text_xs()
2787 .text_color(cx.theme().colors().text_muted)
2788 .child(match entry.status {
2789 acp::PlanEntryStatus::InProgress => {
2790 Icon::new(IconName::TodoProgress)
2791 .size(IconSize::Small)
2792 .color(Color::Accent)
2793 .with_rotate_animation(2)
2794 .into_any_element()
2795 }
2796 acp::PlanEntryStatus::Completed => {
2797 Icon::new(IconName::TodoComplete)
2798 .size(IconSize::Small)
2799 .color(Color::Success)
2800 .into_any_element()
2801 }
2802 acp::PlanEntryStatus::Pending | _ => {
2803 Icon::new(IconName::TodoPending)
2804 .size(IconSize::Small)
2805 .color(Color::Muted)
2806 .into_any_element()
2807 }
2808 })
2809 .child(MarkdownElement::new(
2810 entry.content.clone(),
2811 plan_label_markdown_style(&entry.status, window, cx),
2812 )),
2813 );
2814
2815 Some(element)
2816 }))
2817 .into_any_element()
2818 }
2819
2820 fn render_completed_plan(
2821 &self,
2822 entries: &[PlanEntry],
2823 window: &Window,
2824 cx: &Context<Self>,
2825 ) -> AnyElement {
2826 v_flex()
2827 .px_5()
2828 .py_1p5()
2829 .w_full()
2830 .child(
2831 v_flex()
2832 .w_full()
2833 .rounded_md()
2834 .border_1()
2835 .border_color(self.tool_card_border_color(cx))
2836 .child(
2837 h_flex()
2838 .px_2()
2839 .py_1()
2840 .gap_1()
2841 .bg(self.tool_card_header_bg(cx))
2842 .border_b_1()
2843 .border_color(self.tool_card_border_color(cx))
2844 .child(
2845 Label::new("Completed Plan")
2846 .size(LabelSize::Small)
2847 .color(Color::Muted),
2848 )
2849 .child(
2850 Label::new(format!(
2851 "— {} {}",
2852 entries.len(),
2853 if entries.len() == 1 { "step" } else { "steps" }
2854 ))
2855 .size(LabelSize::Small)
2856 .color(Color::Muted),
2857 ),
2858 )
2859 .child(
2860 v_flex().children(entries.iter().enumerate().map(|(index, entry)| {
2861 h_flex()
2862 .py_1()
2863 .px_2()
2864 .gap_1p5()
2865 .when(index < entries.len() - 1, |this| {
2866 this.border_b_1().border_color(cx.theme().colors().border)
2867 })
2868 .child(
2869 Icon::new(IconName::TodoComplete)
2870 .size(IconSize::Small)
2871 .color(Color::Success),
2872 )
2873 .child(
2874 div()
2875 .max_w_full()
2876 .overflow_x_hidden()
2877 .text_xs()
2878 .text_color(cx.theme().colors().text_muted)
2879 .child(MarkdownElement::new(
2880 entry.content.clone(),
2881 default_markdown_style(window, cx),
2882 )),
2883 )
2884 })),
2885 ),
2886 )
2887 .into_any()
2888 }
2889
2890 fn render_edits_summary(
2891 &self,
2892 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2893 expanded: bool,
2894 pending_edits: bool,
2895 cx: &Context<Self>,
2896 ) -> Div {
2897 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
2898
2899 let focus_handle = self.focus_handle(cx);
2900
2901 h_flex()
2902 .p_1()
2903 .justify_between()
2904 .flex_wrap()
2905 .when(expanded, |this| {
2906 this.border_b_1().border_color(cx.theme().colors().border)
2907 })
2908 .child(
2909 h_flex()
2910 .id("edits-container")
2911 .cursor_pointer()
2912 .gap_1()
2913 .child(Disclosure::new("edits-disclosure", expanded))
2914 .map(|this| {
2915 if pending_edits {
2916 this.child(
2917 Label::new(format!(
2918 "Editing {} {}…",
2919 changed_buffers.len(),
2920 if changed_buffers.len() == 1 {
2921 "file"
2922 } else {
2923 "files"
2924 }
2925 ))
2926 .color(Color::Muted)
2927 .size(LabelSize::Small)
2928 .with_animation(
2929 "edit-label",
2930 Animation::new(Duration::from_secs(2))
2931 .repeat()
2932 .with_easing(pulsating_between(0.3, 0.7)),
2933 |label, delta| label.alpha(delta),
2934 ),
2935 )
2936 } else {
2937 let stats = DiffStats::all_files(changed_buffers, cx);
2938 let dot_divider = || {
2939 Label::new("•")
2940 .size(LabelSize::XSmall)
2941 .color(Color::Disabled)
2942 };
2943
2944 this.child(
2945 Label::new("Edits")
2946 .size(LabelSize::Small)
2947 .color(Color::Muted),
2948 )
2949 .child(dot_divider())
2950 .child(
2951 Label::new(format!(
2952 "{} {}",
2953 changed_buffers.len(),
2954 if changed_buffers.len() == 1 {
2955 "file"
2956 } else {
2957 "files"
2958 }
2959 ))
2960 .size(LabelSize::Small)
2961 .color(Color::Muted),
2962 )
2963 .child(dot_divider())
2964 .child(DiffStat::new(
2965 "total",
2966 stats.lines_added as usize,
2967 stats.lines_removed as usize,
2968 ))
2969 }
2970 })
2971 .on_click(cx.listener(|this, _, _, cx| {
2972 this.edits_expanded = !this.edits_expanded;
2973 cx.notify();
2974 })),
2975 )
2976 .child(
2977 h_flex()
2978 .gap_1()
2979 .child(
2980 IconButton::new("review-changes", IconName::ListTodo)
2981 .icon_size(IconSize::Small)
2982 .tooltip({
2983 let focus_handle = focus_handle.clone();
2984 move |_window, cx| {
2985 Tooltip::for_action_in(
2986 "Review Changes",
2987 &OpenAgentDiff,
2988 &focus_handle,
2989 cx,
2990 )
2991 }
2992 })
2993 .on_click(cx.listener(|_, _, window, cx| {
2994 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
2995 })),
2996 )
2997 .child(Divider::vertical().color(DividerColor::Border))
2998 .child(
2999 Button::new("reject-all-changes", "Reject All")
3000 .label_size(LabelSize::Small)
3001 .disabled(pending_edits)
3002 .when(pending_edits, |this| {
3003 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3004 })
3005 .key_binding(
3006 KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx)
3007 .map(|kb| kb.size(rems_from_px(12.))),
3008 )
3009 .on_click(cx.listener(move |this, _, window, cx| {
3010 this.reject_all(&RejectAll, window, cx);
3011 })),
3012 )
3013 .child(
3014 Button::new("keep-all-changes", "Keep All")
3015 .label_size(LabelSize::Small)
3016 .disabled(pending_edits)
3017 .when(pending_edits, |this| {
3018 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3019 })
3020 .key_binding(
3021 KeyBinding::for_action_in(&KeepAll, &focus_handle, cx)
3022 .map(|kb| kb.size(rems_from_px(12.))),
3023 )
3024 .on_click(cx.listener(move |this, _, window, cx| {
3025 this.keep_all(&KeepAll, window, cx);
3026 })),
3027 ),
3028 )
3029 }
3030
3031 fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool {
3032 let Some(parent_session_id) = self.parent_id.as_ref() else {
3033 return false;
3034 };
3035
3036 let my_session_id = self.thread.read(cx).session_id().clone();
3037
3038 self.server_view
3039 .upgrade()
3040 .and_then(|sv| sv.read(cx).thread_view(parent_session_id))
3041 .is_some_and(|parent_view| {
3042 parent_view
3043 .read(cx)
3044 .thread
3045 .read(cx)
3046 .tool_call_for_subagent(&my_session_id)
3047 .is_some_and(|tc| {
3048 matches!(
3049 tc.status,
3050 ToolCallStatus::Canceled
3051 | ToolCallStatus::Failed
3052 | ToolCallStatus::Rejected
3053 )
3054 })
3055 })
3056 }
3057
3058 pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
3059 let Some(parent_session_id) = self.parent_id.clone() else {
3060 return None;
3061 };
3062
3063 let server_view = self.server_view.clone();
3064 let thread = self.thread.clone();
3065 let is_done = thread.read(cx).status() == ThreadStatus::Idle;
3066 let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx);
3067
3068 let max_content_width = AgentSettings::get_global(cx).max_content_width;
3069
3070 Some(
3071 h_flex()
3072 .w_full()
3073 .h(Tab::container_height(cx))
3074 .border_b_1()
3075 .when(is_done && is_canceled_or_failed, |this| {
3076 this.border_dashed()
3077 })
3078 .border_color(cx.theme().colors().border)
3079 .bg(cx.theme().colors().editor_background.opacity(0.2))
3080 .child(
3081 h_flex()
3082 .size_full()
3083 .max_w(max_content_width)
3084 .mx_auto()
3085 .pl_2()
3086 .pr_1()
3087 .flex_shrink_0()
3088 .justify_between()
3089 .gap_1()
3090 .child(
3091 h_flex()
3092 .flex_1()
3093 .gap_2()
3094 .child(
3095 Icon::new(IconName::ForwardArrowUp)
3096 .size(IconSize::Small)
3097 .color(Color::Muted),
3098 )
3099 .child(self.title_editor.clone())
3100 .when(is_done && is_canceled_or_failed, |this| {
3101 this.child(Icon::new(IconName::Close).color(Color::Error))
3102 })
3103 .when(is_done && !is_canceled_or_failed, |this| {
3104 this.child(Icon::new(IconName::Check).color(Color::Success))
3105 }),
3106 )
3107 .child(
3108 h_flex()
3109 .gap_0p5()
3110 .when(!is_done, |this| {
3111 this.child(
3112 IconButton::new("stop_subagent", IconName::Stop)
3113 .icon_size(IconSize::Small)
3114 .icon_color(Color::Error)
3115 .tooltip(Tooltip::text("Stop Subagent"))
3116 .on_click(move |_, _, cx| {
3117 thread.update(cx, |thread, cx| {
3118 thread.cancel(cx).detach();
3119 });
3120 }),
3121 )
3122 })
3123 .child(
3124 IconButton::new("minimize_subagent", IconName::Dash)
3125 .icon_size(IconSize::Small)
3126 .tooltip(Tooltip::text("Minimize Subagent"))
3127 .on_click(move |_, window, cx| {
3128 let _ = server_view.update(cx, |server_view, cx| {
3129 server_view.navigate_to_session(
3130 parent_session_id.clone(),
3131 window,
3132 cx,
3133 );
3134 });
3135 }),
3136 ),
3137 ),
3138 ),
3139 )
3140 }
3141
3142 pub(crate) fn render_message_editor(
3143 &mut self,
3144 window: &mut Window,
3145 cx: &mut Context<Self>,
3146 ) -> AnyElement {
3147 if self.is_subagent() {
3148 return div().into_any_element();
3149 }
3150
3151 let focus_handle = self.message_editor.focus_handle(cx);
3152 let editor_bg_color = cx.theme().colors().editor_background;
3153
3154 let editor_expanded = self.editor_expanded;
3155 let (expand_icon, expand_tooltip) = if editor_expanded {
3156 (IconName::Minimize, "Minimize Message Editor")
3157 } else {
3158 (IconName::Maximize, "Expand Message Editor")
3159 };
3160
3161 let max_content_width = AgentSettings::get_global(cx).max_content_width;
3162 let has_messages = self.list_state.item_count() > 0;
3163 let fills_container = !has_messages || editor_expanded;
3164
3165 h_flex()
3166 .p_2()
3167 .bg(editor_bg_color)
3168 .justify_center()
3169 .map(|this| {
3170 if has_messages {
3171 this.on_action(cx.listener(Self::expand_message_editor))
3172 .border_t_1()
3173 .border_color(cx.theme().colors().border)
3174 .when(editor_expanded, |this| this.h(vh(0.8, window)))
3175 } else {
3176 this.flex_1().size_full()
3177 }
3178 })
3179 .child(
3180 v_flex()
3181 .flex_basis(max_content_width)
3182 .flex_shrink()
3183 .flex_grow_0()
3184 .when(fills_container, |this| this.h_full())
3185 .justify_between()
3186 .gap_2()
3187 .child(
3188 v_flex()
3189 .relative()
3190 .w_full()
3191 .min_h_0()
3192 .when(fills_container, |this| this.flex_1())
3193 .pt_1()
3194 .pr_2p5()
3195 .child(self.message_editor.clone())
3196 .when(has_messages, |this| {
3197 this.child(
3198 h_flex()
3199 .absolute()
3200 .top_0()
3201 .right_0()
3202 .opacity(0.5)
3203 .hover(|s| s.opacity(1.0))
3204 .child(
3205 IconButton::new("toggle-height", expand_icon)
3206 .icon_size(IconSize::Small)
3207 .icon_color(Color::Muted)
3208 .tooltip({
3209 move |_window, cx| {
3210 Tooltip::for_action_in(
3211 expand_tooltip,
3212 &ExpandMessageEditor,
3213 &focus_handle,
3214 cx,
3215 )
3216 }
3217 })
3218 .on_click(cx.listener(|this, _, window, cx| {
3219 this.expand_message_editor(
3220 &ExpandMessageEditor,
3221 window,
3222 cx,
3223 );
3224 })),
3225 ),
3226 )
3227 }),
3228 )
3229 .child(
3230 h_flex()
3231 .w_full()
3232 .flex_none()
3233 .flex_wrap()
3234 .justify_between()
3235 .child(
3236 h_flex()
3237 .gap_0p5()
3238 .child(self.render_add_context_button(cx))
3239 .child(self.render_follow_toggle(cx))
3240 .children(self.render_fast_mode_control(cx))
3241 .children(self.render_thinking_control(cx)),
3242 )
3243 .child(
3244 h_flex()
3245 .gap_1()
3246 .children(self.render_token_usage(cx))
3247 .children(self.profile_selector.clone())
3248 .map(|this| match self.config_options_view.clone() {
3249 Some(config_view) => this.child(config_view),
3250 None => this
3251 .children(self.mode_selector.clone())
3252 .children(self.model_selector.clone()),
3253 })
3254 .child(self.render_send_button(cx)),
3255 ),
3256 ),
3257 )
3258 .into_any()
3259 }
3260
3261 fn render_message_queue_entries(
3262 &self,
3263 _window: &mut Window,
3264 cx: &Context<Self>,
3265 ) -> impl IntoElement {
3266 let message_editor = self.message_editor.read(cx);
3267 let focus_handle = message_editor.focus_handle(cx);
3268
3269 let queued_message_editors = &self.queued_message_editors;
3270 let queue_len = queued_message_editors.len();
3271 let can_fast_track = self.can_fast_track_queue && queue_len > 0;
3272
3273 v_flex()
3274 .id("message_queue_list")
3275 .max_h_40()
3276 .overflow_y_scroll()
3277 .children(
3278 queued_message_editors
3279 .iter()
3280 .enumerate()
3281 .map(|(index, editor)| {
3282 let is_next = index == 0;
3283 let (icon_color, tooltip_text) = if is_next {
3284 (Color::Accent, "Next in Queue")
3285 } else {
3286 (Color::Muted, "In Queue")
3287 };
3288
3289 let editor_focused = editor.focus_handle(cx).is_focused(_window);
3290 let keybinding_size = rems_from_px(12.);
3291
3292 h_flex()
3293 .group("queue_entry")
3294 .w_full()
3295 .p_1p5()
3296 .gap_1()
3297 .bg(cx.theme().colors().editor_background)
3298 .when(index < queue_len - 1, |this| {
3299 this.border_b_1()
3300 .border_color(cx.theme().colors().border_variant)
3301 })
3302 .child(
3303 div()
3304 .id("next_in_queue")
3305 .child(
3306 Icon::new(IconName::Circle)
3307 .size(IconSize::Small)
3308 .color(icon_color),
3309 )
3310 .tooltip(Tooltip::text(tooltip_text)),
3311 )
3312 .child(editor.clone())
3313 .child(if editor_focused {
3314 h_flex()
3315 .gap_1()
3316 .min_w(rems_from_px(150.))
3317 .justify_end()
3318 .child(
3319 IconButton::new(("edit", index), IconName::Pencil)
3320 .icon_size(IconSize::Small)
3321 .tooltip(|_window, cx| {
3322 Tooltip::with_meta(
3323 "Edit Queued Message",
3324 None,
3325 "Type anything to edit",
3326 cx,
3327 )
3328 })
3329 .on_click(cx.listener(move |this, _, window, cx| {
3330 this.move_queued_message_to_main_editor(
3331 index, None, None, window, cx,
3332 );
3333 })),
3334 )
3335 .child(
3336 Button::new(("send_now_focused", index), "Send Now")
3337 .label_size(LabelSize::Small)
3338 .style(ButtonStyle::Outlined)
3339 .key_binding(
3340 KeyBinding::for_action_in(
3341 &SendImmediately,
3342 &editor.focus_handle(cx),
3343 cx,
3344 )
3345 .map(|kb| kb.size(keybinding_size)),
3346 )
3347 .on_click(cx.listener(move |this, _, window, cx| {
3348 this.send_queued_message_at_index(
3349 index, true, window, cx,
3350 );
3351 })),
3352 )
3353 } else {
3354 h_flex()
3355 .when(!is_next, |this| this.visible_on_hover("queue_entry"))
3356 .gap_1()
3357 .min_w(rems_from_px(150.))
3358 .justify_end()
3359 .child(
3360 IconButton::new(("delete", index), IconName::Trash)
3361 .icon_size(IconSize::Small)
3362 .tooltip({
3363 let focus_handle = focus_handle.clone();
3364 move |_window, cx| {
3365 if is_next {
3366 Tooltip::for_action_in(
3367 "Remove Message from Queue",
3368 &RemoveFirstQueuedMessage,
3369 &focus_handle,
3370 cx,
3371 )
3372 } else {
3373 Tooltip::simple(
3374 "Remove Message from Queue",
3375 cx,
3376 )
3377 }
3378 }
3379 })
3380 .on_click(cx.listener(move |this, _, _, cx| {
3381 this.remove_from_queue(index, cx);
3382 cx.notify();
3383 })),
3384 )
3385 .child(
3386 IconButton::new(("edit", index), IconName::Pencil)
3387 .icon_size(IconSize::Small)
3388 .tooltip({
3389 let focus_handle = focus_handle.clone();
3390 move |_window, cx| {
3391 if is_next {
3392 Tooltip::for_action_in(
3393 "Edit",
3394 &EditFirstQueuedMessage,
3395 &focus_handle,
3396 cx,
3397 )
3398 } else {
3399 Tooltip::simple("Edit", cx)
3400 }
3401 }
3402 })
3403 .on_click(cx.listener(move |this, _, window, cx| {
3404 this.move_queued_message_to_main_editor(
3405 index, None, None, window, cx,
3406 );
3407 })),
3408 )
3409 .child(
3410 Button::new(("send_now", index), "Send Now")
3411 .label_size(LabelSize::Small)
3412 .when(is_next, |this| this.style(ButtonStyle::Outlined))
3413 .when(is_next && message_editor.is_empty(cx), |this| {
3414 let action: Box<dyn gpui::Action> =
3415 if can_fast_track {
3416 Box::new(Chat)
3417 } else {
3418 Box::new(SendNextQueuedMessage)
3419 };
3420
3421 this.key_binding(
3422 KeyBinding::for_action_in(
3423 action.as_ref(),
3424 &focus_handle.clone(),
3425 cx,
3426 )
3427 .map(|kb| kb.size(keybinding_size)),
3428 )
3429 })
3430 .on_click(cx.listener(move |this, _, window, cx| {
3431 this.send_queued_message_at_index(
3432 index, true, window, cx,
3433 );
3434 })),
3435 )
3436 })
3437 }),
3438 )
3439 .into_any_element()
3440 }
3441
3442 fn supports_split_token_display(&self, cx: &App) -> bool {
3443 self.as_native_thread(cx)
3444 .and_then(|thread| thread.read(cx).model())
3445 .is_some_and(|model| model.supports_split_token_display())
3446 }
3447
3448 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3449 let thread = self.thread.read(cx);
3450 let usage = thread.token_usage()?;
3451 let show_split = self.supports_split_token_display(cx);
3452
3453 let progress_color = |ratio: f32| -> Hsla {
3454 if ratio >= 0.85 {
3455 cx.theme().status().warning
3456 } else {
3457 cx.theme().colors().text_muted
3458 }
3459 };
3460
3461 let used = crate::humanize_token_count(usage.used_tokens);
3462 let max = crate::humanize_token_count(usage.max_tokens);
3463 let input_tokens_label = crate::humanize_token_count(usage.input_tokens);
3464 let output_tokens_label = crate::humanize_token_count(usage.output_tokens);
3465
3466 let progress_ratio = if usage.max_tokens > 0 {
3467 usage.used_tokens as f32 / usage.max_tokens as f32
3468 } else {
3469 0.0
3470 };
3471
3472 let ring_size = px(16.0);
3473 let stroke_width = px(2.);
3474
3475 let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32);
3476
3477 let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6));
3478
3479 let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self
3480 .as_native_thread(cx)
3481 .map(|thread| {
3482 let project_context = thread.read(cx).project_context().read(cx);
3483 let user_rules_count = project_context.user_rules.len();
3484 let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0);
3485 let project_entry_ids = project_context
3486 .worktrees
3487 .iter()
3488 .filter_map(|wt| wt.rules_file.as_ref())
3489 .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id))
3490 .collect::<Vec<_>>();
3491 let project_rules_count = project_entry_ids.len();
3492 (
3493 user_rules_count,
3494 first_user_rules_id,
3495 project_rules_count,
3496 project_entry_ids,
3497 )
3498 })
3499 .unwrap_or_default();
3500
3501 let workspace = self.workspace.clone();
3502
3503 let max_output_tokens = self
3504 .as_native_thread(cx)
3505 .and_then(|thread| thread.read(cx).model())
3506 .and_then(|model| model.max_output_tokens())
3507 .unwrap_or(0);
3508 let input_max_label =
3509 crate::humanize_token_count(usage.max_tokens.saturating_sub(max_output_tokens));
3510 let output_max_label = crate::humanize_token_count(max_output_tokens);
3511
3512 let build_tooltip = {
3513 move |_window: &mut Window, cx: &mut App| {
3514 let percentage = percentage.clone();
3515 let used = used.clone();
3516 let max = max.clone();
3517 let input_tokens_label = input_tokens_label.clone();
3518 let output_tokens_label = output_tokens_label.clone();
3519 let input_max_label = input_max_label.clone();
3520 let output_max_label = output_max_label.clone();
3521 let project_entry_ids = project_entry_ids.clone();
3522 let workspace = workspace.clone();
3523 cx.new(move |_cx| TokenUsageTooltip {
3524 percentage,
3525 used,
3526 max,
3527 input_tokens: input_tokens_label,
3528 output_tokens: output_tokens_label,
3529 input_max: input_max_label,
3530 output_max: output_max_label,
3531 show_split,
3532 separator_color: tooltip_separator_color,
3533 user_rules_count,
3534 first_user_rules_id,
3535 project_rules_count,
3536 project_entry_ids,
3537 workspace,
3538 })
3539 .into()
3540 }
3541 };
3542
3543 if show_split {
3544 let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens);
3545 let output_max_raw = max_output_tokens;
3546
3547 let input_ratio = if input_max_raw > 0 {
3548 usage.input_tokens as f32 / input_max_raw as f32
3549 } else {
3550 0.0
3551 };
3552 let output_ratio = if output_max_raw > 0 {
3553 usage.output_tokens as f32 / output_max_raw as f32
3554 } else {
3555 0.0
3556 };
3557
3558 Some(
3559 h_flex()
3560 .id("split_token_usage")
3561 .flex_shrink_0()
3562 .gap_1p5()
3563 .mr_1()
3564 .child(
3565 h_flex()
3566 .gap_0p5()
3567 .child(
3568 Icon::new(IconName::ArrowUp)
3569 .size(IconSize::XSmall)
3570 .color(Color::Muted),
3571 )
3572 .child(
3573 CircularProgress::new(
3574 usage.input_tokens as f32,
3575 input_max_raw as f32,
3576 ring_size,
3577 cx,
3578 )
3579 .stroke_width(stroke_width)
3580 .progress_color(progress_color(input_ratio)),
3581 ),
3582 )
3583 .child(
3584 h_flex()
3585 .gap_0p5()
3586 .child(
3587 Icon::new(IconName::ArrowDown)
3588 .size(IconSize::XSmall)
3589 .color(Color::Muted),
3590 )
3591 .child(
3592 CircularProgress::new(
3593 usage.output_tokens as f32,
3594 output_max_raw as f32,
3595 ring_size,
3596 cx,
3597 )
3598 .stroke_width(stroke_width)
3599 .progress_color(progress_color(output_ratio)),
3600 ),
3601 )
3602 .hoverable_tooltip(build_tooltip)
3603 .into_any_element(),
3604 )
3605 } else {
3606 Some(
3607 h_flex()
3608 .id("circular_progress_tokens")
3609 .mt_px()
3610 .mr_1()
3611 .child(
3612 CircularProgress::new(
3613 usage.used_tokens as f32,
3614 usage.max_tokens as f32,
3615 ring_size,
3616 cx,
3617 )
3618 .stroke_width(stroke_width)
3619 .progress_color(progress_color(progress_ratio)),
3620 )
3621 .hoverable_tooltip(build_tooltip)
3622 .into_any_element(),
3623 )
3624 }
3625 }
3626
3627 fn fast_mode_available(&self, cx: &Context<Self>) -> bool {
3628 if !cx.is_staff() {
3629 return false;
3630 }
3631 self.as_native_thread(cx)
3632 .and_then(|thread| thread.read(cx).model())
3633 .map(|model| model.supports_fast_mode())
3634 .unwrap_or(false)
3635 }
3636
3637 fn render_fast_mode_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3638 if !self.fast_mode_available(cx) {
3639 return None;
3640 }
3641
3642 let thread = self.as_native_thread(cx)?.read(cx);
3643
3644 let (tooltip_label, color, icon) = if matches!(thread.speed(), Some(Speed::Fast)) {
3645 ("Disable Fast Mode", Color::Muted, IconName::FastForward)
3646 } else {
3647 (
3648 "Enable Fast Mode",
3649 Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
3650 IconName::FastForwardOff,
3651 )
3652 };
3653
3654 let focus_handle = self.message_editor.focus_handle(cx);
3655
3656 Some(
3657 IconButton::new("fast-mode", icon)
3658 .icon_size(IconSize::Small)
3659 .icon_color(color)
3660 .tooltip(move |_, cx| {
3661 Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &focus_handle, cx)
3662 })
3663 .on_click(cx.listener(move |this, _, _window, cx| {
3664 this.toggle_fast_mode(cx);
3665 }))
3666 .into_any_element(),
3667 )
3668 }
3669
3670 fn render_thinking_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3671 let thread = self.as_native_thread(cx)?.read(cx);
3672 let model = thread.model()?;
3673
3674 let supports_thinking = model.supports_thinking();
3675 if !supports_thinking {
3676 return None;
3677 }
3678
3679 let thinking = thread.thinking_enabled();
3680
3681 let (tooltip_label, icon, color) = if thinking {
3682 (
3683 "Disable Thinking Mode",
3684 IconName::ThinkingMode,
3685 Color::Muted,
3686 )
3687 } else {
3688 (
3689 "Enable Thinking Mode",
3690 IconName::ThinkingModeOff,
3691 Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
3692 )
3693 };
3694
3695 let focus_handle = self.message_editor.focus_handle(cx);
3696
3697 let thinking_toggle = IconButton::new("thinking-mode", icon)
3698 .icon_size(IconSize::Small)
3699 .icon_color(color)
3700 .tooltip(move |_, cx| {
3701 Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
3702 })
3703 .on_click(cx.listener(move |this, _, _window, cx| {
3704 if let Some(thread) = this.as_native_thread(cx) {
3705 thread.update(cx, |thread, cx| {
3706 let enable_thinking = !thread.thinking_enabled();
3707 thread.set_thinking_enabled(enable_thinking, cx);
3708
3709 let fs = thread.project().read(cx).fs().clone();
3710 update_settings_file(fs, cx, move |settings, _| {
3711 if let Some(agent) = settings.agent.as_mut()
3712 && let Some(default_model) = agent.default_model.as_mut()
3713 {
3714 default_model.enable_thinking = enable_thinking;
3715 }
3716 });
3717 });
3718 }
3719 }));
3720
3721 if model.supported_effort_levels().is_empty() {
3722 return Some(thinking_toggle.into_any_element());
3723 }
3724
3725 if !model.supported_effort_levels().is_empty() && !thinking {
3726 return Some(thinking_toggle.into_any_element());
3727 }
3728
3729 let left_btn = thinking_toggle;
3730 let right_btn = self.render_effort_selector(
3731 model.supported_effort_levels(),
3732 thread.thinking_effort().cloned(),
3733 cx,
3734 );
3735
3736 Some(
3737 SplitButton::new(left_btn, right_btn.into_any_element())
3738 .style(SplitButtonStyle::Transparent)
3739 .into_any_element(),
3740 )
3741 }
3742
3743 fn render_effort_selector(
3744 &self,
3745 supported_effort_levels: Vec<LanguageModelEffortLevel>,
3746 selected_effort: Option<String>,
3747 cx: &Context<Self>,
3748 ) -> impl IntoElement {
3749 let weak_self = cx.weak_entity();
3750
3751 let default_effort_level = supported_effort_levels
3752 .iter()
3753 .find(|effort_level| effort_level.is_default)
3754 .cloned();
3755
3756 let selected = selected_effort.and_then(|effort| {
3757 supported_effort_levels
3758 .iter()
3759 .find(|level| level.value == effort)
3760 .cloned()
3761 });
3762
3763 let label = selected
3764 .clone()
3765 .or(default_effort_level)
3766 .map_or("Select Effort".into(), |effort| effort.name);
3767
3768 let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() {
3769 (Color::Accent, IconName::ChevronUp)
3770 } else {
3771 (Color::Muted, IconName::ChevronDown)
3772 };
3773
3774 let focus_handle = self.message_editor.focus_handle(cx);
3775 let show_cycle_row = supported_effort_levels.len() > 1;
3776
3777 let tooltip = Tooltip::element({
3778 move |_, cx| {
3779 let mut content = v_flex().gap_1().child(
3780 h_flex()
3781 .gap_2()
3782 .justify_between()
3783 .child(Label::new("Change Thinking Effort"))
3784 .child(KeyBinding::for_action_in(
3785 &ToggleThinkingEffortMenu,
3786 &focus_handle,
3787 cx,
3788 )),
3789 );
3790
3791 if show_cycle_row {
3792 content = content.child(
3793 h_flex()
3794 .pt_1()
3795 .gap_2()
3796 .justify_between()
3797 .border_t_1()
3798 .border_color(cx.theme().colors().border_variant)
3799 .child(Label::new("Cycle Thinking Effort"))
3800 .child(KeyBinding::for_action_in(
3801 &CycleThinkingEffort,
3802 &focus_handle,
3803 cx,
3804 )),
3805 );
3806 }
3807
3808 content.into_any_element()
3809 }
3810 });
3811
3812 PopoverMenu::new("effort-selector")
3813 .trigger_with_tooltip(
3814 ButtonLike::new_rounded_right("effort-selector-trigger")
3815 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
3816 .child(Label::new(label).size(LabelSize::Small).color(label_color))
3817 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
3818 tooltip,
3819 )
3820 .menu(move |window, cx| {
3821 Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
3822 menu = menu.header("Change Thinking Effort");
3823
3824 for effort_level in supported_effort_levels.clone() {
3825 let is_selected = selected
3826 .as_ref()
3827 .is_some_and(|selected| selected.value == effort_level.value);
3828 let entry = ContextMenuEntry::new(effort_level.name)
3829 .toggleable(IconPosition::End, is_selected);
3830
3831 menu.push_item(entry.handler({
3832 let effort = effort_level.value.clone();
3833 let weak_self = weak_self.clone();
3834 move |_window, cx| {
3835 let effort = effort.clone();
3836 weak_self
3837 .update(cx, |this, cx| {
3838 if let Some(thread) = this.as_native_thread(cx) {
3839 thread.update(cx, |thread, cx| {
3840 thread.set_thinking_effort(
3841 Some(effort.to_string()),
3842 cx,
3843 );
3844
3845 let fs = thread.project().read(cx).fs().clone();
3846 update_settings_file(fs, cx, move |settings, _| {
3847 if let Some(agent) = settings.agent.as_mut()
3848 && let Some(default_model) =
3849 agent.default_model.as_mut()
3850 {
3851 default_model.effort =
3852 Some(effort.to_string());
3853 }
3854 });
3855 });
3856 }
3857 })
3858 .ok();
3859 }
3860 }));
3861 }
3862
3863 menu
3864 }))
3865 })
3866 .with_handle(self.thinking_effort_menu_handle.clone())
3867 .offset(gpui::Point {
3868 x: px(0.0),
3869 y: px(-2.0),
3870 })
3871 .anchor(Corner::BottomLeft)
3872 }
3873
3874 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3875 let message_editor = self.message_editor.read(cx);
3876 let is_editor_empty = message_editor.is_empty(cx);
3877 let focus_handle = message_editor.focus_handle(cx);
3878
3879 let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle;
3880
3881 if self.is_loading_contents {
3882 div()
3883 .id("loading-message-content")
3884 .px_1()
3885 .tooltip(Tooltip::text("Loading Added Context…"))
3886 .child(loading_contents_spinner(IconSize::default()))
3887 .into_any_element()
3888 } else if is_generating && is_editor_empty {
3889 IconButton::new("stop-generation", IconName::Stop)
3890 .icon_color(Color::Error)
3891 .style(ButtonStyle::Tinted(TintColor::Error))
3892 .tooltip(move |_window, cx| {
3893 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx)
3894 })
3895 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3896 .into_any_element()
3897 } else {
3898 let send_icon = if is_generating {
3899 IconName::QueueMessage
3900 } else {
3901 IconName::Send
3902 };
3903 IconButton::new("send-message", send_icon)
3904 .style(ButtonStyle::Filled)
3905 .map(|this| {
3906 if is_editor_empty && !is_generating {
3907 this.disabled(true).icon_color(Color::Muted)
3908 } else {
3909 this.icon_color(Color::Accent)
3910 }
3911 })
3912 .tooltip(move |_window, cx| {
3913 if is_editor_empty && !is_generating {
3914 Tooltip::for_action("Type to Send", &Chat, cx)
3915 } else if is_generating {
3916 let focus_handle = focus_handle.clone();
3917
3918 Tooltip::element(move |_window, cx| {
3919 v_flex()
3920 .gap_1()
3921 .child(
3922 h_flex()
3923 .gap_2()
3924 .justify_between()
3925 .child(Label::new("Queue and Send"))
3926 .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)),
3927 )
3928 .child(
3929 h_flex()
3930 .pt_1()
3931 .gap_2()
3932 .justify_between()
3933 .border_t_1()
3934 .border_color(cx.theme().colors().border_variant)
3935 .child(Label::new("Send Immediately"))
3936 .child(KeyBinding::for_action_in(
3937 &SendImmediately,
3938 &focus_handle,
3939 cx,
3940 )),
3941 )
3942 .into_any_element()
3943 })(_window, cx)
3944 } else {
3945 Tooltip::for_action("Send Message", &Chat, cx)
3946 }
3947 })
3948 .on_click(cx.listener(|this, _, window, cx| {
3949 this.send(window, cx);
3950 }))
3951 .into_any_element()
3952 }
3953 }
3954
3955 fn render_add_context_button(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3956 let focus_handle = self.message_editor.focus_handle(cx);
3957 let weak_self = cx.weak_entity();
3958
3959 PopoverMenu::new("add-context-menu")
3960 .trigger_with_tooltip(
3961 IconButton::new("add-context", IconName::Plus)
3962 .icon_size(IconSize::Small)
3963 .icon_color(Color::Muted),
3964 {
3965 move |_window, cx| {
3966 Tooltip::for_action_in(
3967 "Add Context",
3968 &OpenAddContextMenu,
3969 &focus_handle,
3970 cx,
3971 )
3972 }
3973 },
3974 )
3975 .anchor(Corner::BottomLeft)
3976 .with_handle(self.add_context_menu_handle.clone())
3977 .offset(gpui::Point {
3978 x: px(0.0),
3979 y: px(-2.0),
3980 })
3981 .menu(move |window, cx| {
3982 weak_self
3983 .update(cx, |this, cx| this.build_add_context_menu(window, cx))
3984 .ok()
3985 })
3986 }
3987
3988 fn build_add_context_menu(
3989 &self,
3990 window: &mut Window,
3991 cx: &mut Context<Self>,
3992 ) -> Entity<ContextMenu> {
3993 let message_editor = self.message_editor.clone();
3994 let workspace = self.workspace.clone();
3995 let session_capabilities = self.session_capabilities.read();
3996 let supports_images = session_capabilities.supports_images();
3997 let supports_embedded_context = session_capabilities.supports_embedded_context();
3998
3999 let has_editor_selection = workspace
4000 .upgrade()
4001 .and_then(|ws| {
4002 ws.read(cx)
4003 .active_item(cx)
4004 .and_then(|item| item.downcast::<Editor>())
4005 })
4006 .is_some_and(|editor| {
4007 editor.update(cx, |editor, cx| {
4008 editor.has_non_empty_selection(&editor.display_snapshot(cx))
4009 })
4010 });
4011
4012 let has_terminal_selection = workspace
4013 .upgrade()
4014 .and_then(|ws| ws.read(cx).panel::<TerminalPanel>(cx))
4015 .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty());
4016
4017 let has_selection = has_editor_selection || has_terminal_selection;
4018
4019 ContextMenu::build(window, cx, move |menu, _window, _cx| {
4020 menu.key_context("AddContextMenu")
4021 .header("Context")
4022 .item(
4023 ContextMenuEntry::new("Files & Directories")
4024 .icon(IconName::File)
4025 .icon_color(Color::Muted)
4026 .icon_size(IconSize::XSmall)
4027 .handler({
4028 let message_editor = message_editor.clone();
4029 move |window, cx| {
4030 message_editor.focus_handle(cx).focus(window, cx);
4031 message_editor.update(cx, |editor, cx| {
4032 editor.insert_context_type("file", window, cx);
4033 });
4034 }
4035 }),
4036 )
4037 .item(
4038 ContextMenuEntry::new("Symbols")
4039 .icon(IconName::Code)
4040 .icon_color(Color::Muted)
4041 .icon_size(IconSize::XSmall)
4042 .handler({
4043 let message_editor = message_editor.clone();
4044 move |window, cx| {
4045 message_editor.focus_handle(cx).focus(window, cx);
4046 message_editor.update(cx, |editor, cx| {
4047 editor.insert_context_type("symbol", window, cx);
4048 });
4049 }
4050 }),
4051 )
4052 .item(
4053 ContextMenuEntry::new("Threads")
4054 .icon(IconName::Thread)
4055 .icon_color(Color::Muted)
4056 .icon_size(IconSize::XSmall)
4057 .handler({
4058 let message_editor = message_editor.clone();
4059 move |window, cx| {
4060 message_editor.focus_handle(cx).focus(window, cx);
4061 message_editor.update(cx, |editor, cx| {
4062 editor.insert_context_type("thread", window, cx);
4063 });
4064 }
4065 }),
4066 )
4067 .item(
4068 ContextMenuEntry::new("Rules")
4069 .icon(IconName::Reader)
4070 .icon_color(Color::Muted)
4071 .icon_size(IconSize::XSmall)
4072 .handler({
4073 let message_editor = message_editor.clone();
4074 move |window, cx| {
4075 message_editor.focus_handle(cx).focus(window, cx);
4076 message_editor.update(cx, |editor, cx| {
4077 editor.insert_context_type("rule", window, cx);
4078 });
4079 }
4080 }),
4081 )
4082 .item(
4083 ContextMenuEntry::new("Image")
4084 .icon(IconName::Image)
4085 .icon_color(Color::Muted)
4086 .icon_size(IconSize::XSmall)
4087 .disabled(!supports_images)
4088 .handler({
4089 let message_editor = message_editor.clone();
4090 move |window, cx| {
4091 message_editor.focus_handle(cx).focus(window, cx);
4092 message_editor.update(cx, |editor, cx| {
4093 editor.add_images_from_picker(window, cx);
4094 });
4095 }
4096 }),
4097 )
4098 .item(
4099 ContextMenuEntry::new("Selection")
4100 .icon(IconName::CursorIBeam)
4101 .icon_color(Color::Muted)
4102 .icon_size(IconSize::XSmall)
4103 .disabled(!has_selection)
4104 .handler({
4105 move |window, cx| {
4106 window.dispatch_action(
4107 zed_actions::agent::AddSelectionToThread.boxed_clone(),
4108 cx,
4109 );
4110 }
4111 }),
4112 )
4113 .item(
4114 ContextMenuEntry::new("Branch Diff")
4115 .icon(IconName::GitBranch)
4116 .icon_color(Color::Muted)
4117 .icon_size(IconSize::XSmall)
4118 .disabled(!supports_embedded_context)
4119 .handler({
4120 move |window, cx| {
4121 message_editor.update(cx, |editor, cx| {
4122 editor.insert_branch_diff_crease(window, cx);
4123 });
4124 }
4125 }),
4126 )
4127 })
4128 }
4129
4130 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
4131 let following = self.is_following(cx);
4132
4133 let tooltip_label = if following {
4134 if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
4135 format!("Stop Following the {}", self.agent_id)
4136 } else {
4137 format!("Stop Following {}", self.agent_id)
4138 }
4139 } else {
4140 if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
4141 format!("Follow the {}", self.agent_id)
4142 } else {
4143 format!("Follow {}", self.agent_id)
4144 }
4145 };
4146
4147 IconButton::new("follow-agent", IconName::Crosshair)
4148 .icon_size(IconSize::Small)
4149 .icon_color(Color::Muted)
4150 .toggle_state(following)
4151 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
4152 .tooltip(move |_window, cx| {
4153 if following {
4154 Tooltip::for_action(tooltip_label.clone(), &Follow, cx)
4155 } else {
4156 Tooltip::with_meta(
4157 tooltip_label.clone(),
4158 Some(&Follow),
4159 "Track the agent's location as it reads and edits files.",
4160 cx,
4161 )
4162 }
4163 })
4164 .on_click(cx.listener(move |this, _, window, cx| {
4165 this.toggle_following(window, cx);
4166 }))
4167 }
4168}
4169
4170struct TokenUsageTooltip {
4171 percentage: String,
4172 used: String,
4173 max: String,
4174 input_tokens: String,
4175 output_tokens: String,
4176 input_max: String,
4177 output_max: String,
4178 show_split: bool,
4179 separator_color: Color,
4180 user_rules_count: usize,
4181 first_user_rules_id: Option<uuid::Uuid>,
4182 project_rules_count: usize,
4183 project_entry_ids: Vec<ProjectEntryId>,
4184 workspace: WeakEntity<Workspace>,
4185}
4186
4187impl Render for TokenUsageTooltip {
4188 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4189 let separator_color = self.separator_color;
4190 let percentage = self.percentage.clone();
4191 let used = self.used.clone();
4192 let max = self.max.clone();
4193 let input_tokens = self.input_tokens.clone();
4194 let output_tokens = self.output_tokens.clone();
4195 let input_max = self.input_max.clone();
4196 let output_max = self.output_max.clone();
4197 let show_split = self.show_split;
4198 let user_rules_count = self.user_rules_count;
4199 let first_user_rules_id = self.first_user_rules_id;
4200 let project_rules_count = self.project_rules_count;
4201 let project_entry_ids = self.project_entry_ids.clone();
4202 let workspace = self.workspace.clone();
4203
4204 ui::tooltip_container(cx, move |container, cx| {
4205 container
4206 .min_w_40()
4207 .child(
4208 Label::new("Context")
4209 .color(Color::Muted)
4210 .size(LabelSize::Small),
4211 )
4212 .when(!show_split, |this| {
4213 this.child(
4214 h_flex()
4215 .gap_0p5()
4216 .child(Label::new(percentage.clone()))
4217 .child(Label::new("\u{2022}").color(separator_color).mx_1())
4218 .child(Label::new(used.clone()))
4219 .child(Label::new("/").color(separator_color))
4220 .child(Label::new(max.clone()).color(Color::Muted)),
4221 )
4222 })
4223 .when(show_split, |this| {
4224 this.child(
4225 v_flex()
4226 .gap_0p5()
4227 .child(
4228 h_flex()
4229 .gap_0p5()
4230 .child(Label::new("Input:").color(Color::Muted).mr_0p5())
4231 .child(Label::new(input_tokens))
4232 .child(Label::new("/").color(separator_color))
4233 .child(Label::new(input_max).color(Color::Muted)),
4234 )
4235 .child(
4236 h_flex()
4237 .gap_0p5()
4238 .child(Label::new("Output:").color(Color::Muted).mr_0p5())
4239 .child(Label::new(output_tokens))
4240 .child(Label::new("/").color(separator_color))
4241 .child(Label::new(output_max).color(Color::Muted)),
4242 ),
4243 )
4244 })
4245 .when(
4246 user_rules_count > 0 || project_rules_count > 0,
4247 move |this| {
4248 this.child(
4249 v_flex()
4250 .mt_1p5()
4251 .pt_1p5()
4252 .pb_0p5()
4253 .gap_0p5()
4254 .border_t_1()
4255 .border_color(cx.theme().colors().border_variant)
4256 .child(
4257 Label::new("Rules")
4258 .color(Color::Muted)
4259 .size(LabelSize::Small),
4260 )
4261 .child(
4262 v_flex()
4263 .mx_neg_1()
4264 .when(user_rules_count > 0, move |this| {
4265 this.child(
4266 Button::new(
4267 "open-user-rules",
4268 format!("{} user rules", user_rules_count),
4269 )
4270 .end_icon(
4271 Icon::new(IconName::ArrowUpRight)
4272 .color(Color::Muted)
4273 .size(IconSize::XSmall),
4274 )
4275 .on_click(move |_, window, cx| {
4276 window.dispatch_action(
4277 Box::new(OpenRulesLibrary {
4278 prompt_to_select: first_user_rules_id,
4279 }),
4280 cx,
4281 );
4282 }),
4283 )
4284 })
4285 .when(project_rules_count > 0, move |this| {
4286 let workspace = workspace.clone();
4287 let project_entry_ids = project_entry_ids.clone();
4288 this.child(
4289 Button::new(
4290 "open-project-rules",
4291 format!(
4292 "{} project rules",
4293 project_rules_count
4294 ),
4295 )
4296 .end_icon(
4297 Icon::new(IconName::ArrowUpRight)
4298 .color(Color::Muted)
4299 .size(IconSize::XSmall),
4300 )
4301 .on_click(move |_, window, cx| {
4302 let _ =
4303 workspace.update(cx, |workspace, cx| {
4304 let project =
4305 workspace.project().read(cx);
4306 let paths = project_entry_ids
4307 .iter()
4308 .flat_map(|id| {
4309 project.path_for_entry(*id, cx)
4310 })
4311 .collect::<Vec<_>>();
4312 for path in paths {
4313 workspace
4314 .open_path(
4315 path, None, true, window,
4316 cx,
4317 )
4318 .detach_and_log_err(cx);
4319 }
4320 });
4321 }),
4322 )
4323 }),
4324 ),
4325 )
4326 },
4327 )
4328 })
4329 }
4330}
4331
4332impl ThreadView {
4333 fn render_entries(&mut self, cx: &mut Context<Self>) -> List {
4334 list(
4335 self.list_state.clone(),
4336 cx.processor(move |this, index: usize, window, cx| {
4337 let entries = this.thread.read(cx).entries();
4338 if let Some(entry) = entries.get(index) {
4339 this.render_entry(index, entries.len(), entry, window, cx)
4340 } else if this.generating_indicator_in_list {
4341 let confirmation = entries
4342 .last()
4343 .is_some_and(|entry| Self::is_waiting_for_confirmation(entry));
4344 this.render_generating(confirmation, cx).into_any_element()
4345 } else {
4346 Empty.into_any()
4347 }
4348 }),
4349 )
4350 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
4351 .flex_grow()
4352 }
4353
4354 fn render_entry(
4355 &self,
4356 entry_ix: usize,
4357 total_entries: usize,
4358 entry: &AgentThreadEntry,
4359 window: &Window,
4360 cx: &Context<Self>,
4361 ) -> AnyElement {
4362 let is_indented = entry.is_indented();
4363 let is_first_indented = is_indented
4364 && self
4365 .thread
4366 .read(cx)
4367 .entries()
4368 .get(entry_ix.saturating_sub(1))
4369 .is_none_or(|entry| !entry.is_indented());
4370
4371 let primary = match &entry {
4372 AgentThreadEntry::UserMessage(message) => {
4373 let Some(editor) = self
4374 .entry_view_state
4375 .read(cx)
4376 .entry(entry_ix)
4377 .and_then(|entry| entry.message_editor())
4378 .cloned()
4379 else {
4380 return Empty.into_any_element();
4381 };
4382
4383 let editing = self.editing_message == Some(entry_ix);
4384 let editor_focus = editor.focus_handle(cx).is_focused(window);
4385 let focus_border = cx.theme().colors().border_focused;
4386
4387 let has_checkpoint_button = message
4388 .checkpoint
4389 .as_ref()
4390 .is_some_and(|checkpoint| checkpoint.show);
4391
4392 let is_subagent = self.is_subagent();
4393 let is_editable = message.id.is_some() && !is_subagent;
4394 let agent_name = if is_subagent {
4395 "subagents".into()
4396 } else {
4397 self.agent_id.clone()
4398 };
4399
4400 v_flex()
4401 .id(("user_message", entry_ix))
4402 .map(|this| {
4403 if is_first_indented {
4404 this.pt_0p5()
4405 } else {
4406 this.pt_2()
4407 }
4408 })
4409 .pb_3()
4410 .px_2()
4411 .gap_1p5()
4412 .w_full()
4413 .when(is_editable && has_checkpoint_button, |this| {
4414 this.children(message.id.clone().map(|message_id| {
4415 h_flex()
4416 .px_3()
4417 .gap_2()
4418 .child(Divider::horizontal())
4419 .child(
4420 Button::new("restore-checkpoint", "Restore Checkpoint")
4421 .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted))
4422 .label_size(LabelSize::XSmall)
4423 .color(Color::Muted)
4424 .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
4425 .on_click(cx.listener(move |this, _, _window, cx| {
4426 this.restore_checkpoint(&message_id, cx);
4427 }))
4428 )
4429 .child(Divider::horizontal())
4430 }))
4431 })
4432 .child(
4433 div()
4434 .relative()
4435 .child(
4436 div()
4437 .py_3()
4438 .px_2()
4439 .rounded_md()
4440 .bg(cx.theme().colors().editor_background)
4441 .border_1()
4442 .when(is_indented, |this| {
4443 this.py_2().px_2().shadow_sm()
4444 })
4445 .border_color(cx.theme().colors().border)
4446 .map(|this| {
4447 if !is_editable {
4448 if is_subagent {
4449 return this.border_dashed();
4450 }
4451 return this;
4452 }
4453 if editing && editor_focus {
4454 return this.border_color(focus_border);
4455 }
4456 if editing && !editor_focus {
4457 return this.border_dashed()
4458 }
4459 this.shadow_md().hover(|s| {
4460 s.border_color(focus_border.opacity(0.8))
4461 })
4462 })
4463 .text_xs()
4464 .child(editor.clone().into_any_element())
4465 )
4466 .when(editor_focus, |this| {
4467 let base_container = h_flex()
4468 .absolute()
4469 .top_neg_3p5()
4470 .right_3()
4471 .gap_1()
4472 .rounded_sm()
4473 .border_1()
4474 .border_color(cx.theme().colors().border)
4475 .bg(cx.theme().colors().editor_background)
4476 .overflow_hidden();
4477
4478 let is_loading_contents = self.is_loading_contents;
4479 if is_editable {
4480 this.child(
4481 base_container
4482 .child(
4483 IconButton::new("cancel", IconName::Close)
4484 .disabled(is_loading_contents)
4485 .icon_color(Color::Error)
4486 .icon_size(IconSize::XSmall)
4487 .on_click(cx.listener(Self::cancel_editing))
4488 )
4489 .child(
4490 if is_loading_contents {
4491 div()
4492 .id("loading-edited-message-content")
4493 .tooltip(Tooltip::text("Loading Added Context…"))
4494 .child(loading_contents_spinner(IconSize::XSmall))
4495 .into_any_element()
4496 } else {
4497 IconButton::new("regenerate", IconName::Return)
4498 .icon_color(Color::Muted)
4499 .icon_size(IconSize::XSmall)
4500 .tooltip(Tooltip::text(
4501 "Editing will restart the thread from this point."
4502 ))
4503 .on_click(cx.listener({
4504 let editor = editor.clone();
4505 move |this, _, window, cx| {
4506 this.regenerate(
4507 entry_ix, editor.clone(), window, cx,
4508 );
4509 }
4510 })).into_any_element()
4511 }
4512 )
4513 )
4514 } else {
4515 this.child(
4516 base_container
4517 .border_dashed()
4518 .child(IconButton::new("non_editable", IconName::PencilUnavailable)
4519 .icon_size(IconSize::Small)
4520 .icon_color(Color::Muted)
4521 .style(ButtonStyle::Transparent)
4522 .tooltip(Tooltip::element({
4523 let agent_name = agent_name.clone();
4524 move |_, _| {
4525 v_flex()
4526 .gap_1()
4527 .child(Label::new("Unavailable Editing"))
4528 .child(
4529 div().max_w_64().child(
4530 Label::new(format!(
4531 "Editing previous messages is not available for {} yet.",
4532 agent_name
4533 ))
4534 .size(LabelSize::Small)
4535 .color(Color::Muted),
4536 ),
4537 )
4538 .into_any_element()
4539 }
4540 }))),
4541 )
4542 }
4543 }),
4544 )
4545 .into_any()
4546 }
4547 AgentThreadEntry::AssistantMessage(AssistantMessage {
4548 chunks,
4549 indented: _,
4550 is_subagent_output: _,
4551 }) => {
4552 let mut is_blank = true;
4553 let is_last = entry_ix + 1 == total_entries;
4554
4555 let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
4556 let message_body = v_flex()
4557 .w_full()
4558 .gap_3()
4559 .children(chunks.iter().enumerate().filter_map(
4560 |(chunk_ix, chunk)| match chunk {
4561 AssistantMessageChunk::Message { block } => {
4562 block.markdown().and_then(|md| {
4563 let this_is_blank = md.read(cx).source().trim().is_empty();
4564 is_blank = is_blank && this_is_blank;
4565 if this_is_blank {
4566 return None;
4567 }
4568
4569 Some(
4570 self.render_markdown(md.clone(), style.clone())
4571 .into_any_element(),
4572 )
4573 })
4574 }
4575 AssistantMessageChunk::Thought { block } => {
4576 block.markdown().and_then(|md| {
4577 let this_is_blank = md.read(cx).source().trim().is_empty();
4578 is_blank = is_blank && this_is_blank;
4579 if this_is_blank {
4580 return None;
4581 }
4582 Some(
4583 self.render_thinking_block(
4584 entry_ix,
4585 chunk_ix,
4586 md.clone(),
4587 window,
4588 cx,
4589 )
4590 .into_any_element(),
4591 )
4592 })
4593 }
4594 },
4595 ))
4596 .into_any();
4597
4598 if is_blank {
4599 Empty.into_any()
4600 } else {
4601 v_flex()
4602 .px_5()
4603 .py_1p5()
4604 .when(is_last, |this| this.pb_4())
4605 .w_full()
4606 .text_ui(cx)
4607 .child(self.render_message_context_menu(entry_ix, message_body, cx))
4608 .when_some(
4609 self.entry_view_state
4610 .read(cx)
4611 .entry(entry_ix)
4612 .and_then(|entry| entry.focus_handle(cx)),
4613 |this, handle| this.track_focus(&handle),
4614 )
4615 .into_any()
4616 }
4617 }
4618 AgentThreadEntry::ToolCall(tool_call) => self
4619 .render_any_tool_call(
4620 &self.id,
4621 entry_ix,
4622 tool_call,
4623 &self.focus_handle(cx),
4624 false,
4625 window,
4626 cx,
4627 )
4628 .into_any(),
4629 AgentThreadEntry::CompletedPlan(entries) => {
4630 self.render_completed_plan(entries, window, cx)
4631 }
4632 };
4633
4634 let is_subagent_output = self.is_subagent()
4635 && matches!(entry, AgentThreadEntry::AssistantMessage(msg) if msg.is_subagent_output);
4636
4637 let primary = if is_subagent_output {
4638 v_flex()
4639 .w_full()
4640 .child(
4641 h_flex()
4642 .id("subagent_output")
4643 .px_5()
4644 .py_1()
4645 .gap_2()
4646 .child(Divider::horizontal())
4647 .child(
4648 h_flex()
4649 .gap_1()
4650 .child(
4651 Icon::new(IconName::ForwardArrowUp)
4652 .color(Color::Muted)
4653 .size(IconSize::Small),
4654 )
4655 .child(
4656 Label::new("Subagent Output")
4657 .size(LabelSize::Custom(self.tool_name_font_size()))
4658 .color(Color::Muted),
4659 ),
4660 )
4661 .child(Divider::horizontal())
4662 .tooltip(Tooltip::text("Everything below this line was sent as output from this subagent to the main agent.")),
4663 )
4664 .child(primary)
4665 .into_any_element()
4666 } else {
4667 primary
4668 };
4669
4670 let thread = self.thread.clone();
4671
4672 let primary = if is_indented {
4673 let line_top = if is_first_indented {
4674 rems_from_px(-12.0)
4675 } else {
4676 rems_from_px(0.0)
4677 };
4678
4679 div()
4680 .relative()
4681 .w_full()
4682 .pl_5()
4683 .bg(cx.theme().colors().panel_background.opacity(0.2))
4684 .child(
4685 div()
4686 .absolute()
4687 .left(rems_from_px(18.0))
4688 .top(line_top)
4689 .bottom_0()
4690 .w_px()
4691 .bg(cx.theme().colors().border.opacity(0.6)),
4692 )
4693 .child(primary)
4694 .into_any_element()
4695 } else {
4696 primary
4697 };
4698
4699 let needs_confirmation = Self::is_waiting_for_confirmation(entry);
4700
4701 let comments_editor = self.thread_feedback.comments_editor.clone();
4702
4703 let primary = if entry_ix + 1 == total_entries {
4704 v_flex()
4705 .w_full()
4706 .child(primary)
4707 .when(!needs_confirmation, |this| {
4708 this.child(self.render_thread_controls(&thread, cx))
4709 })
4710 .when_some(comments_editor, |this, editor| {
4711 this.child(Self::render_feedback_feedback_editor(editor, cx))
4712 })
4713 .into_any_element()
4714 } else {
4715 primary
4716 };
4717
4718 if let Some(editing_index) = self.editing_message
4719 && editing_index < entry_ix
4720 {
4721 let is_subagent = self.is_subagent();
4722
4723 let backdrop = div()
4724 .id(("backdrop", entry_ix))
4725 .size_full()
4726 .absolute()
4727 .inset_0()
4728 .bg(cx.theme().colors().panel_background)
4729 .opacity(0.8)
4730 .block_mouse_except_scroll()
4731 .on_click(cx.listener(Self::cancel_editing));
4732
4733 div()
4734 .relative()
4735 .child(primary)
4736 .when(!is_subagent, |this| this.child(backdrop))
4737 .into_any_element()
4738 } else {
4739 primary
4740 }
4741 }
4742
4743 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
4744 h_flex()
4745 .key_context("AgentFeedbackMessageEditor")
4746 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4747 this.thread_feedback.dismiss_comments();
4748 cx.notify();
4749 }))
4750 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4751 this.submit_feedback_message(cx);
4752 }))
4753 .p_2()
4754 .mb_2()
4755 .mx_5()
4756 .gap_1()
4757 .rounded_md()
4758 .border_1()
4759 .border_color(cx.theme().colors().border)
4760 .bg(cx.theme().colors().editor_background)
4761 .child(div().w_full().child(editor))
4762 .child(
4763 h_flex()
4764 .child(
4765 IconButton::new("dismiss-feedback-message", IconName::Close)
4766 .icon_color(Color::Error)
4767 .icon_size(IconSize::XSmall)
4768 .shape(ui::IconButtonShape::Square)
4769 .on_click(cx.listener(move |this, _, _window, cx| {
4770 this.thread_feedback.dismiss_comments();
4771 cx.notify();
4772 })),
4773 )
4774 .child(
4775 IconButton::new("submit-feedback-message", IconName::Return)
4776 .icon_size(IconSize::XSmall)
4777 .shape(ui::IconButtonShape::Square)
4778 .on_click(cx.listener(move |this, _, _window, cx| {
4779 this.submit_feedback_message(cx);
4780 })),
4781 ),
4782 )
4783 }
4784
4785 fn render_thread_controls(
4786 &self,
4787 thread: &Entity<AcpThread>,
4788 cx: &Context<Self>,
4789 ) -> impl IntoElement {
4790 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
4791 if is_generating {
4792 return Empty.into_any_element();
4793 }
4794
4795 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4796 .shape(ui::IconButtonShape::Square)
4797 .icon_size(IconSize::Small)
4798 .icon_color(Color::Ignored)
4799 .tooltip(Tooltip::text("Open Thread as Markdown"))
4800 .on_click(cx.listener(move |this, _, window, cx| {
4801 if let Some(workspace) = this.workspace.upgrade() {
4802 this.open_thread_as_markdown(workspace, window, cx)
4803 .detach_and_log_err(cx);
4804 }
4805 }));
4806
4807 let scroll_to_recent_user_prompt =
4808 IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
4809 .shape(ui::IconButtonShape::Square)
4810 .icon_size(IconSize::Small)
4811 .icon_color(Color::Ignored)
4812 .tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
4813 .on_click(cx.listener(move |this, _, _, cx| {
4814 this.scroll_to_most_recent_user_prompt(cx);
4815 }));
4816
4817 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4818 .shape(ui::IconButtonShape::Square)
4819 .icon_size(IconSize::Small)
4820 .icon_color(Color::Ignored)
4821 .tooltip(Tooltip::text("Scroll To Top"))
4822 .on_click(cx.listener(move |this, _, _, cx| {
4823 this.scroll_to_top(cx);
4824 }));
4825
4826 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
4827 let last_turn_clock = show_stats
4828 .then(|| {
4829 self.turn_fields
4830 .last_turn_duration
4831 .filter(|&duration| duration > STOPWATCH_THRESHOLD)
4832 .map(|duration| {
4833 Label::new(duration_alt_display(duration))
4834 .size(LabelSize::Small)
4835 .color(Color::Muted)
4836 })
4837 })
4838 .flatten();
4839
4840 let last_turn_tokens_label = last_turn_clock
4841 .is_some()
4842 .then(|| {
4843 self.turn_fields
4844 .last_turn_tokens
4845 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
4846 .map(|tokens| {
4847 Label::new(format!("{} tokens", crate::humanize_token_count(tokens)))
4848 .size(LabelSize::Small)
4849 .color(Color::Muted)
4850 })
4851 })
4852 .flatten();
4853
4854 let mut container = h_flex()
4855 .w_full()
4856 .py_2()
4857 .px_5()
4858 .gap_px()
4859 .opacity(0.6)
4860 .hover(|s| s.opacity(1.))
4861 .justify_end()
4862 .when(
4863 last_turn_tokens_label.is_some() || last_turn_clock.is_some(),
4864 |this| {
4865 this.child(
4866 h_flex()
4867 .gap_1()
4868 .px_1()
4869 .when_some(last_turn_tokens_label, |this, label| this.child(label))
4870 .when_some(last_turn_clock, |this, label| this.child(label)),
4871 )
4872 },
4873 );
4874
4875 let enable_thread_feedback = util::maybe!({
4876 let project = thread.read(cx).project().read(cx);
4877 let user_store = project.user_store();
4878 if let Some(configuration) = user_store.read(cx).current_organization_configuration() {
4879 if !configuration.is_agent_thread_feedback_enabled {
4880 return false;
4881 }
4882 }
4883
4884 AgentSettings::get_global(cx).enable_feedback
4885 && self.thread.read(cx).connection().telemetry().is_some()
4886 });
4887
4888 if enable_thread_feedback {
4889 let feedback = self.thread_feedback.feedback;
4890
4891 let tooltip_meta = || {
4892 SharedString::new(
4893 "Rating the thread sends all of your current conversation to the Zed team.",
4894 )
4895 };
4896
4897 container = container
4898 .child(
4899 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4900 .shape(ui::IconButtonShape::Square)
4901 .icon_size(IconSize::Small)
4902 .icon_color(match feedback {
4903 Some(ThreadFeedback::Positive) => Color::Accent,
4904 _ => Color::Ignored,
4905 })
4906 .tooltip(move |window, cx| match feedback {
4907 Some(ThreadFeedback::Positive) => {
4908 Tooltip::text("Thanks for your feedback!")(window, cx)
4909 }
4910 _ => {
4911 Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx)
4912 }
4913 })
4914 .on_click(cx.listener(move |this, _, window, cx| {
4915 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
4916 })),
4917 )
4918 .child(
4919 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4920 .shape(ui::IconButtonShape::Square)
4921 .icon_size(IconSize::Small)
4922 .icon_color(match feedback {
4923 Some(ThreadFeedback::Negative) => Color::Accent,
4924 _ => Color::Ignored,
4925 })
4926 .tooltip(move |window, cx| match feedback {
4927 Some(ThreadFeedback::Negative) => {
4928 Tooltip::text(
4929 "We appreciate your feedback and will use it to improve in the future.",
4930 )(window, cx)
4931 }
4932 _ => {
4933 Tooltip::with_meta(
4934 "Not Helpful Response",
4935 None,
4936 tooltip_meta(),
4937 cx,
4938 )
4939 }
4940 })
4941 .on_click(cx.listener(move |this, _, window, cx| {
4942 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
4943 })),
4944 );
4945 }
4946
4947 if let Some(project) = self.project.upgrade()
4948 && let Some(server_view) = self.server_view.upgrade()
4949 && cx.has_flag::<AgentSharingFeatureFlag>()
4950 && project.read(cx).client().status().borrow().is_connected()
4951 {
4952 let button = if self.is_imported_thread(cx) {
4953 IconButton::new("sync-thread", IconName::ArrowCircle)
4954 .shape(ui::IconButtonShape::Square)
4955 .icon_size(IconSize::Small)
4956 .icon_color(Color::Ignored)
4957 .tooltip(Tooltip::text("Sync with source thread"))
4958 .on_click(cx.listener(move |this, _, window, cx| {
4959 this.sync_thread(project.clone(), server_view.clone(), window, cx);
4960 }))
4961 } else {
4962 IconButton::new("share-thread", IconName::ArrowUpRight)
4963 .shape(ui::IconButtonShape::Square)
4964 .icon_size(IconSize::Small)
4965 .icon_color(Color::Ignored)
4966 .tooltip(Tooltip::text("Share Thread"))
4967 .on_click(cx.listener(move |this, _, window, cx| {
4968 this.share_thread(window, cx);
4969 }))
4970 };
4971
4972 container = container.child(button);
4973 }
4974
4975 container
4976 .child(open_as_markdown)
4977 .child(scroll_to_recent_user_prompt)
4978 .child(scroll_to_top)
4979 .into_any_element()
4980 }
4981
4982 pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
4983 let entries = self.thread.read(cx).entries();
4984 if entries.is_empty() {
4985 return;
4986 }
4987
4988 // Find the most recent user message and scroll it to the top of the viewport.
4989 // (Fallback: if no user message exists, scroll to the bottom.)
4990 if let Some(ix) = entries
4991 .iter()
4992 .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
4993 {
4994 self.list_state.scroll_to(ListOffset {
4995 item_ix: ix,
4996 offset_in_item: px(0.0),
4997 });
4998 cx.notify();
4999 } else {
5000 self.scroll_to_end(cx);
5001 }
5002 }
5003
5004 pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
5005 self.list_state.scroll_to_end();
5006 cx.notify();
5007 }
5008
5009 fn handle_feedback_click(
5010 &mut self,
5011 feedback: ThreadFeedback,
5012 window: &mut Window,
5013 cx: &mut Context<Self>,
5014 ) {
5015 self.thread_feedback
5016 .submit(self.thread.clone(), feedback, window, cx);
5017 cx.notify();
5018 }
5019
5020 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
5021 let thread = self.thread.clone();
5022 self.thread_feedback.submit_comments(thread, cx);
5023 cx.notify();
5024 }
5025
5026 pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
5027 self.list_state.scroll_to(ListOffset::default());
5028 cx.notify();
5029 }
5030
5031 fn scroll_output_page_up(
5032 &mut self,
5033 _: &ScrollOutputPageUp,
5034 _window: &mut Window,
5035 cx: &mut Context<Self>,
5036 ) {
5037 let page_height = self.list_state.viewport_bounds().size.height;
5038 self.list_state.scroll_by(-page_height * 0.9);
5039 cx.notify();
5040 }
5041
5042 fn scroll_output_page_down(
5043 &mut self,
5044 _: &ScrollOutputPageDown,
5045 _window: &mut Window,
5046 cx: &mut Context<Self>,
5047 ) {
5048 let page_height = self.list_state.viewport_bounds().size.height;
5049 self.list_state.scroll_by(page_height * 0.9);
5050 cx.notify();
5051 }
5052
5053 fn scroll_output_line_up(
5054 &mut self,
5055 _: &ScrollOutputLineUp,
5056 window: &mut Window,
5057 cx: &mut Context<Self>,
5058 ) {
5059 self.list_state.scroll_by(-window.line_height() * 3.);
5060 cx.notify();
5061 }
5062
5063 fn scroll_output_line_down(
5064 &mut self,
5065 _: &ScrollOutputLineDown,
5066 window: &mut Window,
5067 cx: &mut Context<Self>,
5068 ) {
5069 self.list_state.scroll_by(window.line_height() * 3.);
5070 cx.notify();
5071 }
5072
5073 fn scroll_output_to_top(
5074 &mut self,
5075 _: &ScrollOutputToTop,
5076 _window: &mut Window,
5077 cx: &mut Context<Self>,
5078 ) {
5079 self.scroll_to_top(cx);
5080 }
5081
5082 fn scroll_output_to_bottom(
5083 &mut self,
5084 _: &ScrollOutputToBottom,
5085 _window: &mut Window,
5086 cx: &mut Context<Self>,
5087 ) {
5088 self.scroll_to_end(cx);
5089 }
5090
5091 fn scroll_output_to_previous_message(
5092 &mut self,
5093 _: &ScrollOutputToPreviousMessage,
5094 _window: &mut Window,
5095 cx: &mut Context<Self>,
5096 ) {
5097 let entries = self.thread.read(cx).entries();
5098 let current_ix = self.list_state.logical_scroll_top().item_ix;
5099 if let Some(target_ix) = (0..current_ix)
5100 .rev()
5101 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5102 {
5103 self.list_state.scroll_to(ListOffset {
5104 item_ix: target_ix,
5105 offset_in_item: px(0.),
5106 });
5107 cx.notify();
5108 }
5109 }
5110
5111 fn scroll_output_to_next_message(
5112 &mut self,
5113 _: &ScrollOutputToNextMessage,
5114 _window: &mut Window,
5115 cx: &mut Context<Self>,
5116 ) {
5117 let entries = self.thread.read(cx).entries();
5118 let current_ix = self.list_state.logical_scroll_top().item_ix;
5119 if let Some(target_ix) = (current_ix + 1..entries.len())
5120 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5121 {
5122 self.list_state.scroll_to(ListOffset {
5123 item_ix: target_ix,
5124 offset_in_item: px(0.),
5125 });
5126 cx.notify();
5127 }
5128 }
5129
5130 pub fn open_thread_as_markdown(
5131 &self,
5132 workspace: Entity<Workspace>,
5133 window: &mut Window,
5134 cx: &mut App,
5135 ) -> Task<Result<()>> {
5136 let markdown_language_task = workspace
5137 .read(cx)
5138 .app_state()
5139 .languages
5140 .language_for_name("Markdown");
5141
5142 let thread = self.thread.read(cx);
5143 let thread_title = thread
5144 .title()
5145 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
5146 .to_string();
5147 let markdown = thread.to_markdown(cx);
5148
5149 let project = workspace.read(cx).project().clone();
5150 window.spawn(cx, async move |cx| {
5151 let markdown_language = markdown_language_task.await?;
5152
5153 let buffer = project
5154 .update(cx, |project, cx| {
5155 project.create_buffer(Some(markdown_language), false, cx)
5156 })
5157 .await?;
5158
5159 buffer.update(cx, |buffer, cx| {
5160 buffer.set_text(markdown, cx);
5161 buffer.set_capability(language::Capability::ReadWrite, cx);
5162 });
5163
5164 workspace.update_in(cx, |workspace, window, cx| {
5165 let buffer = cx
5166 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
5167
5168 workspace.add_item_to_active_pane(
5169 Box::new(cx.new(|cx| {
5170 let mut editor =
5171 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5172 editor.set_breadcrumb_header(thread_title);
5173 editor
5174 })),
5175 None,
5176 true,
5177 window,
5178 cx,
5179 );
5180 })?;
5181 anyhow::Ok(())
5182 })
5183 }
5184
5185 pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context<Self>) {
5186 let has_messages = self.list_state.item_count() > 0;
5187 let v2_empty_state = !has_messages;
5188
5189 let mode = if v2_empty_state {
5190 EditorMode::Full {
5191 scale_ui_elements_with_buffer_font_size: false,
5192 show_active_line_background: false,
5193 sizing_behavior: SizingBehavior::Default,
5194 }
5195 } else {
5196 EditorMode::AutoHeight {
5197 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
5198 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
5199 }
5200 };
5201 self.message_editor.update(cx, |editor, cx| {
5202 editor.set_mode(mode, cx);
5203 });
5204 }
5205
5206 /// Ensures the list item count includes (or excludes) an extra item for the generating indicator
5207 pub(crate) fn sync_generating_indicator(&mut self, cx: &App) {
5208 let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating);
5209
5210 if is_generating && !self.generating_indicator_in_list {
5211 let entries_count = self.thread.read(cx).entries().len();
5212 self.list_state.splice(entries_count..entries_count, 1);
5213 self.generating_indicator_in_list = true;
5214 } else if !is_generating && self.generating_indicator_in_list {
5215 let entries_count = self.thread.read(cx).entries().len();
5216 self.list_state.splice(entries_count..entries_count + 1, 0);
5217 self.generating_indicator_in_list = false;
5218 }
5219 }
5220
5221 fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
5222 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
5223 let elapsed_label = show_stats
5224 .then(|| {
5225 self.turn_fields.turn_started_at.and_then(|started_at| {
5226 let elapsed = started_at.elapsed();
5227 (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
5228 })
5229 })
5230 .flatten();
5231
5232 let is_blocked_on_terminal_command =
5233 !confirmation && self.is_blocked_on_terminal_command(cx);
5234 let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls();
5235
5236 let turn_tokens_label = elapsed_label
5237 .is_some()
5238 .then(|| {
5239 self.turn_fields
5240 .turn_tokens
5241 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
5242 .map(|tokens| crate::humanize_token_count(tokens))
5243 })
5244 .flatten();
5245
5246 let arrow_icon = if is_waiting {
5247 IconName::ArrowUp
5248 } else {
5249 IconName::ArrowDown
5250 };
5251
5252 h_flex()
5253 .id("generating-spinner")
5254 .py_2()
5255 .px(rems_from_px(22.))
5256 .gap_2()
5257 .map(|this| {
5258 if confirmation {
5259 this.child(
5260 h_flex()
5261 .w_2()
5262 .justify_center()
5263 .child(GeneratingSpinnerElement::new(SpinnerVariant::Sand)),
5264 )
5265 .child(
5266 div().min_w(rems(8.)).child(
5267 LoadingLabel::new("Awaiting Confirmation")
5268 .size(LabelSize::Small)
5269 .color(Color::Muted),
5270 ),
5271 )
5272 } else if is_blocked_on_terminal_command {
5273 this
5274 } else {
5275 this.child(
5276 h_flex()
5277 .w_2()
5278 .justify_center()
5279 .child(GeneratingSpinnerElement::new(SpinnerVariant::Dots)),
5280 )
5281 }
5282 })
5283 .when_some(elapsed_label, |this, elapsed| {
5284 this.child(
5285 Label::new(elapsed)
5286 .size(LabelSize::Small)
5287 .color(Color::Muted),
5288 )
5289 })
5290 .when_some(turn_tokens_label, |this, tokens| {
5291 this.child(
5292 h_flex()
5293 .gap_0p5()
5294 .child(
5295 Icon::new(arrow_icon)
5296 .size(IconSize::XSmall)
5297 .color(Color::Muted),
5298 )
5299 .child(
5300 Label::new(format!("{} tokens", tokens))
5301 .size(LabelSize::Small)
5302 .color(Color::Muted),
5303 ),
5304 )
5305 })
5306 .into_any_element()
5307 }
5308
5309 pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context<Self>) {
5310 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5311
5312 if !matches!(
5313 thinking_display,
5314 ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview
5315 ) {
5316 return;
5317 }
5318
5319 let key = {
5320 let thread = self.thread.read(cx);
5321 if thread.status() != ThreadStatus::Generating {
5322 return;
5323 }
5324 let entries = thread.entries();
5325 let last_ix = entries.len().saturating_sub(1);
5326 match entries.get(last_ix) {
5327 Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() {
5328 Some(AssistantMessageChunk::Thought { .. }) => {
5329 Some((last_ix, msg.chunks.len() - 1))
5330 }
5331 _ => None,
5332 },
5333 _ => None,
5334 }
5335 };
5336
5337 if let Some(key) = key {
5338 if self.auto_expanded_thinking_block != Some(key) {
5339 self.auto_expanded_thinking_block = Some(key);
5340 self.expanded_thinking_blocks.insert(key);
5341 cx.notify();
5342 }
5343 } else if self.auto_expanded_thinking_block.is_some() {
5344 if thinking_display == ThinkingBlockDisplay::Auto {
5345 if let Some(key) = self.auto_expanded_thinking_block {
5346 if !self.user_toggled_thinking_blocks.contains(&key) {
5347 self.expanded_thinking_blocks.remove(&key);
5348 }
5349 }
5350 }
5351 self.auto_expanded_thinking_block = None;
5352 cx.notify();
5353 }
5354 }
5355
5356 pub(crate) fn clear_auto_expand_tracking(&mut self) {
5357 self.auto_expanded_thinking_block = None;
5358 }
5359
5360 fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context<Self>) {
5361 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5362
5363 match thinking_display {
5364 ThinkingBlockDisplay::Auto => {
5365 let is_open = self.expanded_thinking_blocks.contains(&key)
5366 || self.user_toggled_thinking_blocks.contains(&key);
5367
5368 if is_open {
5369 self.expanded_thinking_blocks.remove(&key);
5370 self.user_toggled_thinking_blocks.remove(&key);
5371 } else {
5372 self.expanded_thinking_blocks.insert(key);
5373 self.user_toggled_thinking_blocks.insert(key);
5374 }
5375 }
5376 ThinkingBlockDisplay::Preview => {
5377 let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key);
5378 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5379
5380 if is_user_expanded {
5381 self.user_toggled_thinking_blocks.remove(&key);
5382 self.expanded_thinking_blocks.remove(&key);
5383 } else if is_in_expanded_set {
5384 self.user_toggled_thinking_blocks.insert(key);
5385 } else {
5386 self.expanded_thinking_blocks.insert(key);
5387 self.user_toggled_thinking_blocks.insert(key);
5388 }
5389 }
5390 ThinkingBlockDisplay::AlwaysExpanded => {
5391 if self.user_toggled_thinking_blocks.contains(&key) {
5392 self.user_toggled_thinking_blocks.remove(&key);
5393 } else {
5394 self.user_toggled_thinking_blocks.insert(key);
5395 }
5396 }
5397 ThinkingBlockDisplay::AlwaysCollapsed => {
5398 if self.user_toggled_thinking_blocks.contains(&key) {
5399 self.user_toggled_thinking_blocks.remove(&key);
5400 self.expanded_thinking_blocks.remove(&key);
5401 } else {
5402 self.expanded_thinking_blocks.insert(key);
5403 self.user_toggled_thinking_blocks.insert(key);
5404 }
5405 }
5406 }
5407
5408 cx.notify();
5409 }
5410
5411 fn render_thinking_block(
5412 &self,
5413 entry_ix: usize,
5414 chunk_ix: usize,
5415 chunk: Entity<Markdown>,
5416 window: &Window,
5417 cx: &Context<Self>,
5418 ) -> AnyElement {
5419 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
5420 let card_header_id = SharedString::from("inner-card-header");
5421
5422 let key = (entry_ix, chunk_ix);
5423
5424 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5425 let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key);
5426 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5427
5428 let (is_open, is_constrained) = match thinking_display {
5429 ThinkingBlockDisplay::Auto => {
5430 let is_open = is_user_toggled || is_in_expanded_set;
5431 (is_open, false)
5432 }
5433 ThinkingBlockDisplay::Preview => {
5434 let is_open = is_user_toggled || is_in_expanded_set;
5435 let is_constrained = is_in_expanded_set && !is_user_toggled;
5436 (is_open, is_constrained)
5437 }
5438 ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false),
5439 ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false),
5440 };
5441
5442 let should_auto_scroll = self.auto_expanded_thinking_block == Some(key);
5443
5444 let scroll_handle = self
5445 .entry_view_state
5446 .read(cx)
5447 .entry(entry_ix)
5448 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
5449
5450 if should_auto_scroll {
5451 if let Some(ref handle) = scroll_handle {
5452 handle.scroll_to_bottom();
5453 }
5454 }
5455
5456 let panel_bg = cx.theme().colors().panel_background;
5457
5458 v_flex()
5459 .gap_1()
5460 .child(
5461 h_flex()
5462 .id(header_id)
5463 .group(&card_header_id)
5464 .relative()
5465 .w_full()
5466 .pr_1()
5467 .justify_between()
5468 .child(
5469 h_flex()
5470 .h(window.line_height() - px(2.))
5471 .gap_1p5()
5472 .overflow_hidden()
5473 .child(
5474 Icon::new(IconName::ToolThink)
5475 .size(IconSize::Small)
5476 .color(Color::Muted),
5477 )
5478 .child(
5479 div()
5480 .text_size(self.tool_name_font_size())
5481 .text_color(cx.theme().colors().text_muted)
5482 .child("Thinking"),
5483 ),
5484 )
5485 .child(
5486 Disclosure::new(("expand", entry_ix), is_open)
5487 .opened_icon(IconName::ChevronUp)
5488 .closed_icon(IconName::ChevronDown)
5489 .visible_on_hover(&card_header_id)
5490 .on_click(cx.listener(
5491 move |this, _event: &ClickEvent, _window, cx| {
5492 this.toggle_thinking_block_expansion(key, cx);
5493 },
5494 )),
5495 )
5496 .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
5497 this.toggle_thinking_block_expansion(key, cx);
5498 })),
5499 )
5500 .when(is_open, |this| {
5501 this.child(
5502 div()
5503 .when(is_constrained, |this| this.relative())
5504 .child(
5505 div()
5506 .id(("thinking-content", chunk_ix))
5507 .ml_1p5()
5508 .pl_3p5()
5509 .border_l_1()
5510 .border_color(self.tool_card_border_color(cx))
5511 .when(is_constrained, |this| this.max_h_64())
5512 .when_some(scroll_handle, |this, scroll_handle| {
5513 this.track_scroll(&scroll_handle)
5514 })
5515 .overflow_hidden()
5516 .child(self.render_markdown(
5517 chunk,
5518 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
5519 )),
5520 )
5521 .when(is_constrained, |this| {
5522 this.child(
5523 div()
5524 .absolute()
5525 .inset_0()
5526 .size_full()
5527 .bg(linear_gradient(
5528 180.,
5529 linear_color_stop(panel_bg.opacity(0.8), 0.),
5530 linear_color_stop(panel_bg.opacity(0.), 0.1),
5531 ))
5532 .block_mouse_except_scroll(),
5533 )
5534 }),
5535 )
5536 })
5537 .into_any_element()
5538 }
5539
5540 fn render_message_context_menu(
5541 &self,
5542 entry_ix: usize,
5543 message_body: AnyElement,
5544 cx: &Context<Self>,
5545 ) -> AnyElement {
5546 let entity = cx.entity();
5547 let workspace = self.workspace.clone();
5548
5549 right_click_menu(format!("agent_context_menu-{}", entry_ix))
5550 .trigger(move |_, _, _| message_body)
5551 .menu(move |window, cx| {
5552 let focus = window.focused(cx);
5553 let entity = entity.clone();
5554 let workspace = workspace.clone();
5555
5556 ContextMenu::build(window, cx, move |menu, _, cx| {
5557 let this = entity.read(cx);
5558 let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
5559
5560 let has_selection = this
5561 .thread
5562 .read(cx)
5563 .entries()
5564 .get(entry_ix)
5565 .and_then(|entry| match &entry {
5566 AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
5567 _ => None,
5568 })
5569 .map(|chunks| {
5570 chunks.iter().any(|chunk| {
5571 let md = match chunk {
5572 AssistantMessageChunk::Message { block } => block.markdown(),
5573 AssistantMessageChunk::Thought { block } => block.markdown(),
5574 };
5575 md.map_or(false, |m| m.read(cx).selected_text().is_some())
5576 })
5577 })
5578 .unwrap_or(false);
5579
5580 let copy_this_agent_response =
5581 ContextMenuEntry::new("Copy This Agent Response").handler({
5582 let entity = entity.clone();
5583 move |_, cx| {
5584 entity.update(cx, |this, cx| {
5585 let entries = this.thread.read(cx).entries();
5586 if let Some(text) =
5587 Self::get_agent_message_content(entries, entry_ix, cx)
5588 {
5589 cx.write_to_clipboard(ClipboardItem::new_string(text));
5590 }
5591 });
5592 }
5593 });
5594
5595 let scroll_item = if is_at_top {
5596 ContextMenuEntry::new("Scroll to Bottom").handler({
5597 let entity = entity.clone();
5598 move |_, cx| {
5599 entity.update(cx, |this, cx| {
5600 this.scroll_to_end(cx);
5601 });
5602 }
5603 })
5604 } else {
5605 ContextMenuEntry::new("Scroll to Top").handler({
5606 let entity = entity.clone();
5607 move |_, cx| {
5608 entity.update(cx, |this, cx| {
5609 this.scroll_to_top(cx);
5610 });
5611 }
5612 })
5613 };
5614
5615 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
5616 .handler({
5617 let entity = entity.clone();
5618 let workspace = workspace.clone();
5619 move |window, cx| {
5620 if let Some(workspace) = workspace.upgrade() {
5621 entity
5622 .update(cx, |this, cx| {
5623 this.open_thread_as_markdown(workspace, window, cx)
5624 })
5625 .detach_and_log_err(cx);
5626 }
5627 }
5628 });
5629
5630 menu.when_some(focus, |menu, focus| menu.context(focus))
5631 .action_disabled_when(
5632 !has_selection,
5633 "Copy Selection",
5634 Box::new(markdown::CopyAsMarkdown),
5635 )
5636 .item(copy_this_agent_response)
5637 .separator()
5638 .item(scroll_item)
5639 .item(open_thread_as_markdown)
5640 })
5641 })
5642 .into_any_element()
5643 }
5644
5645 fn get_agent_message_content(
5646 entries: &[AgentThreadEntry],
5647 entry_index: usize,
5648 cx: &App,
5649 ) -> Option<String> {
5650 let entry = entries.get(entry_index)?;
5651 if matches!(entry, AgentThreadEntry::UserMessage(_)) {
5652 return None;
5653 }
5654
5655 let start_index = (0..entry_index)
5656 .rev()
5657 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5658 .map(|i| i + 1)
5659 .unwrap_or(0);
5660
5661 let end_index = (entry_index + 1..entries.len())
5662 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5663 .map(|i| i - 1)
5664 .unwrap_or(entries.len() - 1);
5665
5666 let parts: Vec<String> = (start_index..=end_index)
5667 .filter_map(|i| entries.get(i))
5668 .filter_map(|entry| {
5669 if let AgentThreadEntry::AssistantMessage(message) = entry {
5670 let text: String = message
5671 .chunks
5672 .iter()
5673 .filter_map(|chunk| match chunk {
5674 AssistantMessageChunk::Message { block } => {
5675 let markdown = block.to_markdown(cx);
5676 if markdown.trim().is_empty() {
5677 None
5678 } else {
5679 Some(markdown.to_string())
5680 }
5681 }
5682 AssistantMessageChunk::Thought { .. } => None,
5683 })
5684 .collect::<Vec<_>>()
5685 .join("\n\n");
5686
5687 if text.is_empty() { None } else { Some(text) }
5688 } else {
5689 None
5690 }
5691 })
5692 .collect();
5693
5694 let text = parts.join("\n\n");
5695 if text.is_empty() { None } else { Some(text) }
5696 }
5697
5698 fn is_blocked_on_terminal_command(&self, cx: &App) -> bool {
5699 let thread = self.thread.read(cx);
5700 if !matches!(thread.status(), ThreadStatus::Generating) {
5701 return false;
5702 }
5703
5704 let mut has_running_terminal_call = false;
5705
5706 for entry in thread.entries().iter().rev() {
5707 match entry {
5708 AgentThreadEntry::UserMessage(_) => break,
5709 AgentThreadEntry::ToolCall(tool_call)
5710 if matches!(
5711 tool_call.status,
5712 ToolCallStatus::InProgress | ToolCallStatus::Pending
5713 ) =>
5714 {
5715 if matches!(tool_call.kind, acp::ToolKind::Execute) {
5716 has_running_terminal_call = true;
5717 } else {
5718 return false;
5719 }
5720 }
5721 AgentThreadEntry::ToolCall(_)
5722 | AgentThreadEntry::AssistantMessage(_)
5723 | AgentThreadEntry::CompletedPlan(_) => {}
5724 }
5725 }
5726
5727 has_running_terminal_call
5728 }
5729
5730 fn render_collapsible_command(
5731 &self,
5732 group: SharedString,
5733 is_preview: bool,
5734 command_source: &str,
5735 cx: &Context<Self>,
5736 ) -> Div {
5737 v_flex()
5738 .group(group.clone())
5739 .p_1p5()
5740 .bg(self.tool_card_header_bg(cx))
5741 .when(is_preview, |this| {
5742 this.pt_1().child(
5743 // Wrapping this label on a container with 24px height to avoid
5744 // layout shift when it changes from being a preview label
5745 // to the actual path where the command will run in
5746 h_flex().h_6().child(
5747 Label::new("Run Command")
5748 .buffer_font(cx)
5749 .size(LabelSize::XSmall)
5750 .color(Color::Muted),
5751 ),
5752 )
5753 })
5754 .children(command_source.lines().map(|line| {
5755 let text: SharedString = if line.is_empty() {
5756 " ".into()
5757 } else {
5758 line.to_string().into()
5759 };
5760
5761 Label::new(text).buffer_font(cx).size(LabelSize::Small)
5762 }))
5763 .child(
5764 div().absolute().top_1().right_1().child(
5765 CopyButton::new("copy-command", command_source.to_string())
5766 .tooltip_label("Copy Command")
5767 .visible_on_hover(group),
5768 ),
5769 )
5770 }
5771
5772 fn render_terminal_tool_call(
5773 &self,
5774 active_session_id: &acp::SessionId,
5775 entry_ix: usize,
5776 terminal: &Entity<acp_thread::Terminal>,
5777 tool_call: &ToolCall,
5778 focus_handle: &FocusHandle,
5779 is_subagent: bool,
5780 window: &Window,
5781 cx: &Context<Self>,
5782 ) -> AnyElement {
5783 let terminal_data = terminal.read(cx);
5784 let working_dir = terminal_data.working_dir();
5785 let command = terminal_data.command();
5786 let started_at = terminal_data.started_at();
5787
5788 let tool_failed = matches!(
5789 &tool_call.status,
5790 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
5791 );
5792
5793 let confirmation_options = match &tool_call.status {
5794 ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options),
5795 _ => None,
5796 };
5797 let needs_confirmation = confirmation_options.is_some();
5798
5799 let output = terminal_data.output();
5800 let command_finished = output.is_some()
5801 && !matches!(
5802 tool_call.status,
5803 ToolCallStatus::InProgress | ToolCallStatus::Pending
5804 );
5805 let truncated_output =
5806 output.is_some_and(|output| output.original_content_len > output.content.len());
5807 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
5808
5809 let command_failed = command_finished
5810 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
5811
5812 let time_elapsed = if let Some(output) = output {
5813 output.ended_at.duration_since(started_at)
5814 } else {
5815 started_at.elapsed()
5816 };
5817
5818 let header_id =
5819 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
5820 let header_group = SharedString::from(format!(
5821 "terminal-tool-header-group-{}",
5822 terminal.entity_id()
5823 ));
5824 let header_bg = cx
5825 .theme()
5826 .colors()
5827 .element_background
5828 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
5829 let border_color = cx.theme().colors().border.opacity(0.6);
5830
5831 let working_dir = working_dir
5832 .as_ref()
5833 .map(|path| path.display().to_string())
5834 .unwrap_or_else(|| "current directory".to_string());
5835
5836 // Since the command's source is wrapped in a markdown code block
5837 // (```\n...\n```), we need to strip that so we're left with only the
5838 // command's content.
5839 let command_source = command.read(cx).source();
5840 let command_content = command_source
5841 .strip_prefix("```\n")
5842 .and_then(|s| s.strip_suffix("\n```"))
5843 .unwrap_or(&command_source);
5844
5845 let command_element =
5846 self.render_collapsible_command(header_group.clone(), false, command_content, cx);
5847
5848 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
5849
5850 let header = h_flex()
5851 .id(header_id)
5852 .pt_1()
5853 .pl_1p5()
5854 .pr_1()
5855 .flex_none()
5856 .gap_1()
5857 .justify_between()
5858 .rounded_t_md()
5859 .child(
5860 div()
5861 .id(("command-target-path", terminal.entity_id()))
5862 .w_full()
5863 .max_w_full()
5864 .overflow_x_scroll()
5865 .child(
5866 Label::new(working_dir)
5867 .buffer_font(cx)
5868 .size(LabelSize::XSmall)
5869 .color(Color::Muted),
5870 ),
5871 )
5872 .child(
5873 Disclosure::new(
5874 SharedString::from(format!(
5875 "terminal-tool-disclosure-{}",
5876 terminal.entity_id()
5877 )),
5878 is_expanded,
5879 )
5880 .opened_icon(IconName::ChevronUp)
5881 .closed_icon(IconName::ChevronDown)
5882 .visible_on_hover(&header_group)
5883 .on_click(cx.listener({
5884 let id = tool_call.id.clone();
5885 move |this, _event, _window, cx| {
5886 if is_expanded {
5887 this.expanded_tool_calls.remove(&id);
5888 } else {
5889 this.expanded_tool_calls.insert(id.clone());
5890 }
5891 cx.notify();
5892 }
5893 })),
5894 )
5895 .when(time_elapsed > Duration::from_secs(10), |header| {
5896 header.child(
5897 Label::new(format!("({})", duration_alt_display(time_elapsed)))
5898 .buffer_font(cx)
5899 .color(Color::Muted)
5900 .size(LabelSize::XSmall),
5901 )
5902 })
5903 .when(!command_finished && !needs_confirmation, |header| {
5904 header
5905 .gap_1p5()
5906 .child(
5907 Icon::new(IconName::ArrowCircle)
5908 .size(IconSize::XSmall)
5909 .color(Color::Muted)
5910 .with_rotate_animation(2)
5911 )
5912 .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border)))
5913 .child(
5914 IconButton::new(
5915 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
5916 IconName::Stop
5917 )
5918 .icon_size(IconSize::Small)
5919 .icon_color(Color::Error)
5920 .tooltip(move |_window, cx| {
5921 Tooltip::with_meta(
5922 "Stop This Command",
5923 None,
5924 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
5925 cx,
5926 )
5927 })
5928 .on_click({
5929 let terminal = terminal.clone();
5930 cx.listener(move |this, _event, _window, cx| {
5931 terminal.update(cx, |terminal, cx| {
5932 terminal.stop_by_user(cx);
5933 });
5934 if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
5935 this.cancel_generation(cx);
5936 }
5937 })
5938 }),
5939 )
5940 })
5941 .when(truncated_output, |header| {
5942 let tooltip = if let Some(output) = output {
5943 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
5944 format!("Output exceeded terminal max lines and was \
5945 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
5946 } else {
5947 format!(
5948 "Output is {} long, and to avoid unexpected token usage, \
5949 only {} was sent back to the agent.",
5950 format_file_size(output.original_content_len as u64, true),
5951 format_file_size(output.content.len() as u64, true)
5952 )
5953 }
5954 } else {
5955 "Output was truncated".to_string()
5956 };
5957
5958 header.child(
5959 h_flex()
5960 .id(("terminal-tool-truncated-label", terminal.entity_id()))
5961 .gap_1()
5962 .child(
5963 Icon::new(IconName::Info)
5964 .size(IconSize::XSmall)
5965 .color(Color::Ignored),
5966 )
5967 .child(
5968 Label::new("Truncated")
5969 .color(Color::Muted)
5970 .size(LabelSize::XSmall),
5971 )
5972 .tooltip(Tooltip::text(tooltip)),
5973 )
5974 })
5975 .when(tool_failed || command_failed, |header| {
5976 header.child(
5977 div()
5978 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
5979 .child(
5980 Icon::new(IconName::Close)
5981 .size(IconSize::Small)
5982 .color(Color::Error),
5983 )
5984 .when_some(output.and_then(|o| o.exit_status), |this, status| {
5985 this.tooltip(Tooltip::text(format!(
5986 "Exited with code {}",
5987 status.code().unwrap_or(-1),
5988 )))
5989 }),
5990 )
5991 })
5992;
5993
5994 let terminal_view = self
5995 .entry_view_state
5996 .read(cx)
5997 .entry(entry_ix)
5998 .and_then(|entry| entry.terminal(terminal));
5999
6000 v_flex()
6001 .when(!is_subagent, |this| {
6002 this.my_1p5()
6003 .mx_5()
6004 .border_1()
6005 .when(tool_failed || command_failed, |card| card.border_dashed())
6006 .border_color(border_color)
6007 .rounded_md()
6008 })
6009 .overflow_hidden()
6010 .child(
6011 v_flex()
6012 .group(&header_group)
6013 .bg(header_bg)
6014 .text_xs()
6015 .child(header)
6016 .child(command_element),
6017 )
6018 .when(is_expanded && terminal_view.is_some(), |this| {
6019 this.child(
6020 div()
6021 .pt_2()
6022 .border_t_1()
6023 .when(tool_failed || command_failed, |card| card.border_dashed())
6024 .border_color(border_color)
6025 .bg(cx.theme().colors().editor_background)
6026 .rounded_b_md()
6027 .text_ui_sm(cx)
6028 .h_full()
6029 .children(terminal_view.map(|terminal_view| {
6030 let element = if terminal_view
6031 .read(cx)
6032 .content_mode(window, cx)
6033 .is_scrollable()
6034 {
6035 div().h_72().child(terminal_view).into_any_element()
6036 } else {
6037 terminal_view.into_any_element()
6038 };
6039
6040 div()
6041 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
6042 window.dispatch_action(NewThread.boxed_clone(), cx);
6043 cx.stop_propagation();
6044 }))
6045 .child(element)
6046 .into_any_element()
6047 })),
6048 )
6049 })
6050 .when_some(confirmation_options, |this, options| {
6051 let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
6052 this.child(self.render_permission_buttons(
6053 self.id.clone(),
6054 is_first,
6055 options,
6056 entry_ix,
6057 tool_call.id.clone(),
6058 focus_handle,
6059 cx,
6060 ))
6061 })
6062 .into_any()
6063 }
6064
6065 fn is_first_tool_call(
6066 &self,
6067 active_session_id: &acp::SessionId,
6068 tool_call_id: &acp::ToolCallId,
6069 cx: &App,
6070 ) -> bool {
6071 self.conversation
6072 .read(cx)
6073 .pending_tool_call(active_session_id, cx)
6074 .map_or(false, |(pending_session_id, pending_tool_call_id, _)| {
6075 self.id == pending_session_id && tool_call_id == &pending_tool_call_id
6076 })
6077 }
6078
6079 fn render_any_tool_call(
6080 &self,
6081 active_session_id: &acp::SessionId,
6082 entry_ix: usize,
6083 tool_call: &ToolCall,
6084 focus_handle: &FocusHandle,
6085 is_subagent: bool,
6086 window: &Window,
6087 cx: &Context<Self>,
6088 ) -> Div {
6089 let has_terminals = tool_call.terminals().next().is_some();
6090
6091 div().w_full().map(|this| {
6092 if tool_call.is_subagent() {
6093 this.child(
6094 self.render_subagent_tool_call(
6095 active_session_id,
6096 entry_ix,
6097 tool_call,
6098 tool_call
6099 .subagent_session_info
6100 .as_ref()
6101 .map(|i| i.session_id.clone()),
6102 focus_handle,
6103 window,
6104 cx,
6105 ),
6106 )
6107 } else if has_terminals {
6108 this.children(tool_call.terminals().map(|terminal| {
6109 self.render_terminal_tool_call(
6110 active_session_id,
6111 entry_ix,
6112 terminal,
6113 tool_call,
6114 focus_handle,
6115 is_subagent,
6116 window,
6117 cx,
6118 )
6119 }))
6120 } else {
6121 this.child(self.render_tool_call(
6122 active_session_id,
6123 entry_ix,
6124 tool_call,
6125 focus_handle,
6126 is_subagent,
6127 window,
6128 cx,
6129 ))
6130 }
6131 })
6132 }
6133
6134 fn render_tool_call(
6135 &self,
6136 active_session_id: &acp::SessionId,
6137 entry_ix: usize,
6138 tool_call: &ToolCall,
6139 focus_handle: &FocusHandle,
6140 is_subagent: bool,
6141 window: &Window,
6142 cx: &Context<Self>,
6143 ) -> Div {
6144 let has_location = tool_call.locations.len() == 1;
6145 let card_header_id = SharedString::from("inner-tool-call-header");
6146
6147 let failed_or_canceled = match &tool_call.status {
6148 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
6149 _ => false,
6150 };
6151
6152 let needs_confirmation = matches!(
6153 tool_call.status,
6154 ToolCallStatus::WaitingForConfirmation { .. }
6155 );
6156 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
6157
6158 let is_edit =
6159 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
6160
6161 let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
6162 let (has_revealed_diff, tool_call_output_focus, tool_call_output_focus_handle) = tool_call
6163 .diffs()
6164 .next()
6165 .and_then(|diff| {
6166 let editor = self
6167 .entry_view_state
6168 .read(cx)
6169 .entry(entry_ix)
6170 .and_then(|entry| entry.editor_for_diff(diff))?;
6171 let has_revealed_diff = diff.read(cx).has_revealed_range(cx);
6172 let has_focus = editor.read(cx).is_focused(window);
6173 let focus_handle = editor.focus_handle(cx);
6174 Some((has_revealed_diff, has_focus, focus_handle))
6175 })
6176 .unwrap_or_else(|| (false, false, focus_handle.clone()));
6177
6178 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
6179
6180 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
6181 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
6182 let mut is_open = self.expanded_tool_calls.contains(&tool_call.id);
6183
6184 is_open |= needs_confirmation;
6185
6186 let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
6187
6188 let input_output_header = |label: SharedString| {
6189 Label::new(label)
6190 .size(LabelSize::XSmall)
6191 .color(Color::Muted)
6192 .buffer_font(cx)
6193 };
6194
6195 let tool_output_display = if is_open {
6196 match &tool_call.status {
6197 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
6198 .w_full()
6199 .children(
6200 tool_call
6201 .content
6202 .iter()
6203 .enumerate()
6204 .map(|(content_ix, content)| {
6205 div()
6206 .child(self.render_tool_call_content(
6207 active_session_id,
6208 entry_ix,
6209 content,
6210 content_ix,
6211 tool_call,
6212 use_card_layout,
6213 has_image_content,
6214 failed_or_canceled,
6215 focus_handle,
6216 window,
6217 cx,
6218 ))
6219 .into_any_element()
6220 }),
6221 )
6222 .when(should_show_raw_input, |this| {
6223 let is_raw_input_expanded =
6224 self.expanded_tool_call_raw_inputs.contains(&tool_call.id);
6225
6226 let input_header = if is_raw_input_expanded {
6227 "Raw Input:"
6228 } else {
6229 "View Raw Input"
6230 };
6231
6232 this.child(
6233 v_flex()
6234 .p_2()
6235 .gap_1()
6236 .border_t_1()
6237 .border_color(self.tool_card_border_color(cx))
6238 .child(
6239 h_flex()
6240 .id("disclosure_container")
6241 .pl_0p5()
6242 .gap_1()
6243 .justify_between()
6244 .rounded_xs()
6245 .hover(|s| s.bg(cx.theme().colors().element_hover))
6246 .child(input_output_header(input_header.into()))
6247 .child(
6248 Disclosure::new(
6249 ("raw-input-disclosure", entry_ix),
6250 is_raw_input_expanded,
6251 )
6252 .opened_icon(IconName::ChevronUp)
6253 .closed_icon(IconName::ChevronDown),
6254 )
6255 .on_click(cx.listener({
6256 let id = tool_call.id.clone();
6257
6258 move |this: &mut Self, _, _, cx| {
6259 if this.expanded_tool_call_raw_inputs.contains(&id)
6260 {
6261 this.expanded_tool_call_raw_inputs.remove(&id);
6262 } else {
6263 this.expanded_tool_call_raw_inputs
6264 .insert(id.clone());
6265 }
6266 cx.notify();
6267 }
6268 })),
6269 )
6270 .when(is_raw_input_expanded, |this| {
6271 this.children(tool_call.raw_input_markdown.clone().map(
6272 |input| {
6273 self.render_markdown(
6274 input,
6275 MarkdownStyle::themed(
6276 MarkdownFont::Agent,
6277 window,
6278 cx,
6279 ),
6280 )
6281 },
6282 ))
6283 }),
6284 )
6285 })
6286 .child(self.render_permission_buttons(
6287 self.id.clone(),
6288 self.is_first_tool_call(active_session_id, &tool_call.id, cx),
6289 options,
6290 entry_ix,
6291 tool_call.id.clone(),
6292 focus_handle,
6293 cx,
6294 ))
6295 .into_any(),
6296 ToolCallStatus::Pending | ToolCallStatus::InProgress
6297 if is_edit
6298 && tool_call.content.is_empty()
6299 && self.as_native_connection(cx).is_some() =>
6300 {
6301 self.render_diff_loading(cx)
6302 }
6303 ToolCallStatus::Pending
6304 | ToolCallStatus::InProgress
6305 | ToolCallStatus::Completed
6306 | ToolCallStatus::Failed
6307 | ToolCallStatus::Canceled => v_flex()
6308 .when(should_show_raw_input, |this| {
6309 this.mt_1p5().w_full().child(
6310 v_flex()
6311 .ml(rems(0.4))
6312 .px_3p5()
6313 .pb_1()
6314 .gap_1()
6315 .border_l_1()
6316 .border_color(self.tool_card_border_color(cx))
6317 .child(input_output_header("Raw Input:".into()))
6318 .children(tool_call.raw_input_markdown.clone().map(|input| {
6319 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
6320 self.render_markdown(
6321 input,
6322 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
6323 ),
6324 )
6325 }))
6326 .child(input_output_header("Output:".into())),
6327 )
6328 })
6329 .children(
6330 tool_call
6331 .content
6332 .iter()
6333 .enumerate()
6334 .map(|(content_ix, content)| {
6335 div().id(("tool-call-output", entry_ix)).child(
6336 self.render_tool_call_content(
6337 active_session_id,
6338 entry_ix,
6339 content,
6340 content_ix,
6341 tool_call,
6342 use_card_layout,
6343 has_image_content,
6344 failed_or_canceled,
6345 focus_handle,
6346 window,
6347 cx,
6348 ),
6349 )
6350 }),
6351 )
6352 .into_any(),
6353 ToolCallStatus::Rejected => Empty.into_any(),
6354 }
6355 .into()
6356 } else {
6357 None
6358 };
6359
6360 v_flex()
6361 .map(|this| {
6362 if is_subagent {
6363 this
6364 } else if use_card_layout {
6365 this.my_1p5()
6366 .rounded_md()
6367 .border_1()
6368 .when(failed_or_canceled, |this| this.border_dashed())
6369 .border_color(self.tool_card_border_color(cx))
6370 .bg(cx.theme().colors().editor_background)
6371 .overflow_hidden()
6372 } else {
6373 this.my_1()
6374 }
6375 })
6376 .when(!is_subagent, |this| {
6377 this.map(|this| {
6378 if has_location && !use_card_layout {
6379 this.ml_4()
6380 } else {
6381 this.ml_5()
6382 }
6383 })
6384 .mr_5()
6385 })
6386 .map(|this| {
6387 if is_terminal_tool {
6388 let label_source = tool_call.label.read(cx).source();
6389 this.child(self.render_collapsible_command(
6390 card_header_id.clone(),
6391 true,
6392 label_source,
6393 cx,
6394 ))
6395 } else {
6396 this.child(
6397 h_flex()
6398 .group(&card_header_id)
6399 .relative()
6400 .w_full()
6401 .justify_between()
6402 .when(use_card_layout, |this| {
6403 this.p_0p5()
6404 .rounded_t(rems_from_px(5.))
6405 .bg(self.tool_card_header_bg(cx))
6406 })
6407 .child(self.render_tool_call_label(
6408 entry_ix,
6409 tool_call,
6410 is_edit,
6411 is_cancelled_edit,
6412 has_revealed_diff,
6413 use_card_layout,
6414 window,
6415 cx,
6416 ))
6417 .child(
6418 h_flex()
6419 .when(is_collapsible || failed_or_canceled, |this| {
6420 let diff_for_discard = if has_revealed_diff
6421 && is_cancelled_edit
6422 {
6423 tool_call.diffs().next().cloned()
6424 } else {
6425 None
6426 };
6427
6428 this.child(
6429 h_flex()
6430 .pr_0p5()
6431 .gap_1()
6432 .when(is_collapsible, |this| {
6433 this.child(
6434 Disclosure::new(
6435 ("expand-output", entry_ix),
6436 is_open,
6437 )
6438 .opened_icon(IconName::ChevronUp)
6439 .closed_icon(IconName::ChevronDown)
6440 .visible_on_hover(&card_header_id)
6441 .on_click(cx.listener({
6442 let id = tool_call.id.clone();
6443 move |this: &mut Self,
6444 _,
6445 _,
6446 cx: &mut Context<Self>| {
6447 if is_open {
6448 this.expanded_tool_calls
6449 .remove(&id);
6450 } else {
6451 this.expanded_tool_calls
6452 .insert(id.clone());
6453 }
6454 cx.notify();
6455 }
6456 })),
6457 )
6458 })
6459 .when(failed_or_canceled, |this| {
6460 if is_cancelled_edit && !has_revealed_diff {
6461 this.child(
6462 div()
6463 .id(entry_ix)
6464 .tooltip(Tooltip::text(
6465 "Interrupted Edit",
6466 ))
6467 .child(
6468 Icon::new(IconName::XCircle)
6469 .color(Color::Muted)
6470 .size(IconSize::Small),
6471 ),
6472 )
6473 } else if is_cancelled_edit {
6474 this
6475 } else {
6476 this.child(
6477 Icon::new(IconName::Close)
6478 .color(Color::Error)
6479 .size(IconSize::Small),
6480 )
6481 }
6482 })
6483 .when_some(diff_for_discard, |this, diff| {
6484 let tool_call_id = tool_call.id.clone();
6485 let is_discarded = self
6486 .discarded_partial_edits
6487 .contains(&tool_call_id);
6488
6489 this.when(!is_discarded, |this| {
6490 this.child(
6491 IconButton::new(
6492 ("discard-partial-edit", entry_ix),
6493 IconName::Undo,
6494 )
6495 .icon_size(IconSize::Small)
6496 .tooltip(move |_, cx| {
6497 Tooltip::with_meta(
6498 "Discard Interrupted Edit",
6499 None,
6500 "You can discard this interrupted partial edit and restore the original file content.",
6501 cx,
6502 )
6503 })
6504 .on_click(cx.listener({
6505 let tool_call_id =
6506 tool_call_id.clone();
6507 move |this, _, _window, cx| {
6508 let diff_data = diff.read(cx);
6509 let base_text = diff_data
6510 .base_text()
6511 .clone();
6512 let buffer =
6513 diff_data.buffer().clone();
6514 buffer.update(
6515 cx,
6516 |buffer, cx| {
6517 buffer.set_text(
6518 base_text.as_ref(),
6519 cx,
6520 );
6521 },
6522 );
6523 this.discarded_partial_edits
6524 .insert(
6525 tool_call_id.clone(),
6526 );
6527 cx.notify();
6528 }
6529 })),
6530 )
6531 })
6532 }),
6533 )
6534 })
6535 .when(tool_call_output_focus, |this| {
6536 this.child(
6537 Button::new("open-file-button", "Open File")
6538 .style(ButtonStyle::Outlined)
6539 .label_size(LabelSize::Small)
6540 .key_binding(
6541 KeyBinding::for_action_in(&OpenExcerpts, &tool_call_output_focus_handle, cx)
6542 .map(|s| s.size(rems_from_px(12.))),
6543 )
6544 .on_click(|_, window, cx| {
6545 window.dispatch_action(
6546 Box::new(OpenExcerpts),
6547 cx,
6548 )
6549 }),
6550 )
6551 }),
6552 )
6553
6554 )
6555 }
6556 })
6557 .children(tool_output_display)
6558 }
6559
6560 fn render_permission_buttons(
6561 &self,
6562 session_id: acp::SessionId,
6563 is_first: bool,
6564 options: &PermissionOptions,
6565 entry_ix: usize,
6566 tool_call_id: acp::ToolCallId,
6567 focus_handle: &FocusHandle,
6568 cx: &Context<Self>,
6569 ) -> Div {
6570 match options {
6571 PermissionOptions::Flat(options) => self.render_permission_buttons_flat(
6572 session_id,
6573 is_first,
6574 options,
6575 entry_ix,
6576 tool_call_id,
6577 focus_handle,
6578 cx,
6579 ),
6580 PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
6581 is_first,
6582 choices,
6583 None,
6584 entry_ix,
6585 tool_call_id,
6586 focus_handle,
6587 cx,
6588 ),
6589 PermissionOptions::DropdownWithPatterns {
6590 choices,
6591 patterns,
6592 tool_name,
6593 } => self.render_permission_buttons_with_dropdown(
6594 is_first,
6595 choices,
6596 Some((patterns, tool_name)),
6597 entry_ix,
6598 tool_call_id,
6599 focus_handle,
6600 cx,
6601 ),
6602 }
6603 }
6604
6605 fn render_permission_buttons_with_dropdown(
6606 &self,
6607 is_first: bool,
6608 choices: &[PermissionOptionChoice],
6609 patterns: Option<(&[PermissionPattern], &str)>,
6610 entry_ix: usize,
6611 tool_call_id: acp::ToolCallId,
6612 focus_handle: &FocusHandle,
6613 cx: &Context<Self>,
6614 ) -> Div {
6615 let selection = self.permission_selections.get(&tool_call_id);
6616
6617 let selected_index = selection
6618 .and_then(|s| s.choice_index())
6619 .unwrap_or_else(|| choices.len().saturating_sub(1));
6620
6621 let dropdown_label: SharedString =
6622 if matches!(selection, Some(PermissionSelection::SelectedPatterns(_))) {
6623 "Always for selected commands".into()
6624 } else {
6625 choices
6626 .get(selected_index)
6627 .or(choices.last())
6628 .map(|choice| choice.label())
6629 .unwrap_or_else(|| "Only this time".into())
6630 };
6631
6632 let dropdown = if let Some((pattern_list, tool_name)) = patterns {
6633 self.render_permission_granularity_dropdown_with_patterns(
6634 choices,
6635 pattern_list,
6636 tool_name,
6637 dropdown_label,
6638 entry_ix,
6639 tool_call_id.clone(),
6640 is_first,
6641 cx,
6642 )
6643 } else {
6644 self.render_permission_granularity_dropdown(
6645 choices,
6646 dropdown_label,
6647 entry_ix,
6648 tool_call_id.clone(),
6649 selected_index,
6650 is_first,
6651 cx,
6652 )
6653 };
6654
6655 h_flex()
6656 .w_full()
6657 .p_1()
6658 .gap_2()
6659 .justify_between()
6660 .border_t_1()
6661 .border_color(self.tool_card_border_color(cx))
6662 .child(
6663 h_flex()
6664 .gap_0p5()
6665 .child(
6666 Button::new(("allow-btn", entry_ix), "Allow")
6667 .start_icon(
6668 Icon::new(IconName::Check)
6669 .size(IconSize::XSmall)
6670 .color(Color::Success),
6671 )
6672 .label_size(LabelSize::Small)
6673 .when(is_first, |this| {
6674 this.key_binding(
6675 KeyBinding::for_action_in(
6676 &AllowOnce as &dyn Action,
6677 focus_handle,
6678 cx,
6679 )
6680 .map(|kb| kb.size(rems_from_px(12.))),
6681 )
6682 })
6683 .on_click(cx.listener({
6684 move |this, _, window, cx| {
6685 this.authorize_pending_with_granularity(true, window, cx);
6686 }
6687 })),
6688 )
6689 .child(
6690 Button::new(("deny-btn", entry_ix), "Deny")
6691 .start_icon(
6692 Icon::new(IconName::Close)
6693 .size(IconSize::XSmall)
6694 .color(Color::Error),
6695 )
6696 .label_size(LabelSize::Small)
6697 .when(is_first, |this| {
6698 this.key_binding(
6699 KeyBinding::for_action_in(
6700 &RejectOnce as &dyn Action,
6701 focus_handle,
6702 cx,
6703 )
6704 .map(|kb| kb.size(rems_from_px(12.))),
6705 )
6706 })
6707 .on_click(cx.listener({
6708 move |this, _, window, cx| {
6709 this.authorize_pending_with_granularity(false, window, cx);
6710 }
6711 })),
6712 ),
6713 )
6714 .child(dropdown)
6715 }
6716
6717 fn render_permission_granularity_dropdown(
6718 &self,
6719 choices: &[PermissionOptionChoice],
6720 current_label: SharedString,
6721 entry_ix: usize,
6722 tool_call_id: acp::ToolCallId,
6723 selected_index: usize,
6724 is_first: bool,
6725 cx: &Context<Self>,
6726 ) -> AnyElement {
6727 let menu_options: Vec<(usize, SharedString)> = choices
6728 .iter()
6729 .enumerate()
6730 .map(|(i, choice)| (i, choice.label()))
6731 .collect();
6732
6733 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6734
6735 PopoverMenu::new(("permission-granularity", entry_ix))
6736 .with_handle(permission_dropdown_handle)
6737 .trigger(
6738 Button::new(("granularity-trigger", entry_ix), current_label)
6739 .end_icon(
6740 Icon::new(IconName::ChevronDown)
6741 .size(IconSize::XSmall)
6742 .color(Color::Muted),
6743 )
6744 .label_size(LabelSize::Small)
6745 .when(is_first, |this| {
6746 this.key_binding(
6747 KeyBinding::for_action_in(
6748 &crate::OpenPermissionDropdown as &dyn Action,
6749 &self.focus_handle(cx),
6750 cx,
6751 )
6752 .map(|kb| kb.size(rems_from_px(12.))),
6753 )
6754 }),
6755 )
6756 .menu(move |window, cx| {
6757 let tool_call_id = tool_call_id.clone();
6758 let options = menu_options.clone();
6759
6760 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
6761 for (index, display_name) in options.iter() {
6762 let display_name = display_name.clone();
6763 let index = *index;
6764 let tool_call_id_for_entry = tool_call_id.clone();
6765 let is_selected = index == selected_index;
6766 menu = menu.toggleable_entry(
6767 display_name,
6768 is_selected,
6769 IconPosition::End,
6770 None,
6771 move |window, cx| {
6772 window.dispatch_action(
6773 SelectPermissionGranularity {
6774 tool_call_id: tool_call_id_for_entry.0.to_string(),
6775 index,
6776 }
6777 .boxed_clone(),
6778 cx,
6779 );
6780 },
6781 );
6782 }
6783
6784 menu
6785 }))
6786 })
6787 .into_any_element()
6788 }
6789
6790 fn render_permission_granularity_dropdown_with_patterns(
6791 &self,
6792 choices: &[PermissionOptionChoice],
6793 patterns: &[PermissionPattern],
6794 _tool_name: &str,
6795 current_label: SharedString,
6796 entry_ix: usize,
6797 tool_call_id: acp::ToolCallId,
6798 is_first: bool,
6799 cx: &Context<Self>,
6800 ) -> AnyElement {
6801 let default_choice_index = choices.len().saturating_sub(1);
6802 let menu_options: Vec<(usize, SharedString)> = choices
6803 .iter()
6804 .enumerate()
6805 .map(|(i, choice)| (i, choice.label()))
6806 .collect();
6807
6808 let pattern_options: Vec<(usize, SharedString)> = patterns
6809 .iter()
6810 .enumerate()
6811 .map(|(i, cp)| {
6812 (
6813 i,
6814 SharedString::from(format!("Always for `{}` commands", cp.display_name)),
6815 )
6816 })
6817 .collect();
6818
6819 let pattern_count = patterns.len();
6820 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6821 let view = cx.entity().downgrade();
6822
6823 PopoverMenu::new(("permission-granularity", entry_ix))
6824 .with_handle(permission_dropdown_handle.clone())
6825 .anchor(Corner::TopRight)
6826 .attach(Corner::BottomRight)
6827 .trigger(
6828 Button::new(("granularity-trigger", entry_ix), current_label)
6829 .end_icon(
6830 Icon::new(IconName::ChevronDown)
6831 .size(IconSize::XSmall)
6832 .color(Color::Muted),
6833 )
6834 .label_size(LabelSize::Small)
6835 .when(is_first, |this| {
6836 this.key_binding(
6837 KeyBinding::for_action_in(
6838 &crate::OpenPermissionDropdown as &dyn Action,
6839 &self.focus_handle(cx),
6840 cx,
6841 )
6842 .map(|kb| kb.size(rems_from_px(12.))),
6843 )
6844 }),
6845 )
6846 .menu(move |window, cx| {
6847 let tool_call_id = tool_call_id.clone();
6848 let options = menu_options.clone();
6849 let patterns = pattern_options.clone();
6850 let view = view.clone();
6851 let dropdown_handle = permission_dropdown_handle.clone();
6852
6853 Some(ContextMenu::build_persistent(
6854 window,
6855 cx,
6856 move |menu, _window, cx| {
6857 let mut menu = menu;
6858
6859 // Read fresh selection state from the view on each rebuild.
6860 let selection: Option<PermissionSelection> = view.upgrade().and_then(|v| {
6861 let view = v.read(cx);
6862 view.permission_selections.get(&tool_call_id).cloned()
6863 });
6864
6865 let is_pattern_mode =
6866 matches!(selection, Some(PermissionSelection::SelectedPatterns(_)));
6867
6868 // Granularity choices: "Always for terminal", "Only this time"
6869 for (index, display_name) in options.iter() {
6870 let display_name = display_name.clone();
6871 let index = *index;
6872 let tool_call_id_for_entry = tool_call_id.clone();
6873 let is_selected = !is_pattern_mode
6874 && selection
6875 .as_ref()
6876 .and_then(|s| s.choice_index())
6877 .map_or(index == default_choice_index, |ci| ci == index);
6878
6879 let view = view.clone();
6880 menu = menu.toggleable_entry(
6881 display_name,
6882 is_selected,
6883 IconPosition::End,
6884 None,
6885 move |_window, cx| {
6886 view.update(cx, |this, cx| {
6887 this.permission_selections.insert(
6888 tool_call_id_for_entry.clone(),
6889 PermissionSelection::Choice(index),
6890 );
6891 cx.notify();
6892 })
6893 .log_err();
6894 },
6895 );
6896 }
6897
6898 menu = menu.separator().header("Select Options…");
6899
6900 for (pattern_index, label) in patterns.iter() {
6901 let label = label.clone();
6902 let pattern_index = *pattern_index;
6903 let tool_call_id_for_pattern = tool_call_id.clone();
6904 let is_checked = selection
6905 .as_ref()
6906 .is_some_and(|s| s.is_pattern_checked(pattern_index));
6907
6908 let view = view.clone();
6909 menu = menu.toggleable_entry(
6910 label,
6911 is_checked,
6912 IconPosition::End,
6913 None,
6914 move |_window, cx| {
6915 view.update(cx, |this, cx| {
6916 let selection = this
6917 .permission_selections
6918 .get_mut(&tool_call_id_for_pattern);
6919
6920 match selection {
6921 Some(PermissionSelection::SelectedPatterns(_)) => {
6922 // Already in pattern mode — toggle.
6923 this.permission_selections
6924 .get_mut(&tool_call_id_for_pattern)
6925 .expect("just matched above")
6926 .toggle_pattern(pattern_index);
6927 }
6928 _ => {
6929 // First click: activate pattern mode
6930 // with all patterns checked.
6931 this.permission_selections.insert(
6932 tool_call_id_for_pattern.clone(),
6933 PermissionSelection::SelectedPatterns(
6934 (0..pattern_count).collect(),
6935 ),
6936 );
6937 }
6938 }
6939 cx.notify();
6940 })
6941 .log_err();
6942 },
6943 );
6944 }
6945
6946 let any_patterns_checked = selection
6947 .as_ref()
6948 .is_some_and(|s| s.has_any_checked_patterns());
6949 let dropdown_handle = dropdown_handle.clone();
6950 menu = menu.custom_row(move |_window, _cx| {
6951 div()
6952 .py_1()
6953 .w_full()
6954 .child(
6955 Button::new("apply-patterns", "Apply")
6956 .full_width()
6957 .style(ButtonStyle::Outlined)
6958 .label_size(LabelSize::Small)
6959 .disabled(!any_patterns_checked)
6960 .on_click({
6961 let dropdown_handle = dropdown_handle.clone();
6962 move |_event, _window, cx| {
6963 dropdown_handle.hide(cx);
6964 }
6965 }),
6966 )
6967 .into_any_element()
6968 });
6969
6970 menu
6971 },
6972 ))
6973 })
6974 .into_any_element()
6975 }
6976
6977 fn render_permission_buttons_flat(
6978 &self,
6979 session_id: acp::SessionId,
6980 is_first: bool,
6981 options: &[acp::PermissionOption],
6982 entry_ix: usize,
6983 tool_call_id: acp::ToolCallId,
6984 focus_handle: &FocusHandle,
6985 cx: &Context<Self>,
6986 ) -> Div {
6987 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = ArrayVec::new();
6988
6989 div()
6990 .p_1()
6991 .border_t_1()
6992 .border_color(self.tool_card_border_color(cx))
6993 .w_full()
6994 .v_flex()
6995 .gap_0p5()
6996 .children(options.iter().map(move |option| {
6997 let option_id = SharedString::from(option.option_id.0.clone());
6998 Button::new((option_id, entry_ix), option.name.clone())
6999 .map(|this| {
7000 let (icon, action) = match option.kind {
7001 acp::PermissionOptionKind::AllowOnce => (
7002 Icon::new(IconName::Check)
7003 .size(IconSize::XSmall)
7004 .color(Color::Success),
7005 Some(&AllowOnce as &dyn Action),
7006 ),
7007 acp::PermissionOptionKind::AllowAlways => (
7008 Icon::new(IconName::CheckDouble)
7009 .size(IconSize::XSmall)
7010 .color(Color::Success),
7011 Some(&AllowAlways as &dyn Action),
7012 ),
7013 acp::PermissionOptionKind::RejectOnce => (
7014 Icon::new(IconName::Close)
7015 .size(IconSize::XSmall)
7016 .color(Color::Error),
7017 Some(&RejectOnce as &dyn Action),
7018 ),
7019 acp::PermissionOptionKind::RejectAlways | _ => (
7020 Icon::new(IconName::Close)
7021 .size(IconSize::XSmall)
7022 .color(Color::Error),
7023 None,
7024 ),
7025 };
7026
7027 let this = this.start_icon(icon);
7028
7029 let Some(action) = action else {
7030 return this;
7031 };
7032
7033 if !is_first || seen_kinds.contains(&option.kind) {
7034 return this;
7035 }
7036
7037 seen_kinds.push(option.kind).unwrap();
7038
7039 this.key_binding(
7040 KeyBinding::for_action_in(action, focus_handle, cx)
7041 .map(|kb| kb.size(rems_from_px(12.))),
7042 )
7043 })
7044 .label_size(LabelSize::Small)
7045 .on_click(cx.listener({
7046 let session_id = session_id.clone();
7047 let tool_call_id = tool_call_id.clone();
7048 let option_id = option.option_id.clone();
7049 let option_kind = option.kind;
7050 move |this, _, window, cx| {
7051 this.authorize_tool_call(
7052 session_id.clone(),
7053 tool_call_id.clone(),
7054 SelectedPermissionOutcome::new(option_id.clone(), option_kind),
7055 window,
7056 cx,
7057 );
7058 }
7059 }))
7060 }))
7061 }
7062
7063 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
7064 let bar = |n: u64, width_class: &str| {
7065 let bg_color = cx.theme().colors().element_active;
7066 let base = h_flex().h_1().rounded_full();
7067
7068 let modified = match width_class {
7069 "w_4_5" => base.w_3_4(),
7070 "w_1_4" => base.w_1_4(),
7071 "w_2_4" => base.w_2_4(),
7072 "w_3_5" => base.w_3_5(),
7073 "w_2_5" => base.w_2_5(),
7074 _ => base.w_1_2(),
7075 };
7076
7077 modified.with_animation(
7078 ElementId::Integer(n),
7079 Animation::new(Duration::from_secs(2)).repeat(),
7080 move |tab, delta| {
7081 let delta = (delta - 0.15 * n as f32) / 0.7;
7082 let delta = 1.0 - (0.5 - delta).abs() * 2.;
7083 let delta = ease_in_out(delta.clamp(0., 1.));
7084 let delta = 0.1 + 0.9 * delta;
7085
7086 tab.bg(bg_color.opacity(delta))
7087 },
7088 )
7089 };
7090
7091 v_flex()
7092 .p_3()
7093 .gap_1()
7094 .rounded_b_md()
7095 .bg(cx.theme().colors().editor_background)
7096 .child(bar(0, "w_4_5"))
7097 .child(bar(1, "w_1_4"))
7098 .child(bar(2, "w_2_4"))
7099 .child(bar(3, "w_3_5"))
7100 .child(bar(4, "w_2_5"))
7101 .into_any_element()
7102 }
7103
7104 fn render_tool_call_label(
7105 &self,
7106 entry_ix: usize,
7107 tool_call: &ToolCall,
7108 is_edit: bool,
7109 has_failed: bool,
7110 has_revealed_diff: bool,
7111 use_card_layout: bool,
7112 window: &Window,
7113 cx: &Context<Self>,
7114 ) -> Div {
7115 let has_location = tool_call.locations.len() == 1;
7116 let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
7117 let is_subagent_tool_call = tool_call.is_subagent();
7118
7119 let file_icon = if has_location {
7120 FileIcons::get_icon(&tool_call.locations[0].path, cx)
7121 .map(|from_path| Icon::from_path(from_path).color(Color::Muted))
7122 .unwrap_or(Icon::new(IconName::ToolPencil).color(Color::Muted))
7123 } else {
7124 Icon::new(IconName::ToolPencil).color(Color::Muted)
7125 };
7126
7127 let tool_icon = if is_file && has_failed && has_revealed_diff {
7128 div()
7129 .id(entry_ix)
7130 .tooltip(Tooltip::text("Interrupted Edit"))
7131 .child(DecoratedIcon::new(
7132 file_icon,
7133 Some(
7134 IconDecoration::new(
7135 IconDecorationKind::Triangle,
7136 self.tool_card_header_bg(cx),
7137 cx,
7138 )
7139 .color(cx.theme().status().warning)
7140 .position(gpui::Point {
7141 x: px(-2.),
7142 y: px(-2.),
7143 }),
7144 ),
7145 ))
7146 .into_any_element()
7147 } else if is_file {
7148 div().child(file_icon).into_any_element()
7149 } else if is_subagent_tool_call {
7150 Icon::new(self.agent_icon)
7151 .size(IconSize::Small)
7152 .color(Color::Muted)
7153 .into_any_element()
7154 } else {
7155 Icon::new(match tool_call.kind {
7156 acp::ToolKind::Read => IconName::ToolSearch,
7157 acp::ToolKind::Edit => IconName::ToolPencil,
7158 acp::ToolKind::Delete => IconName::ToolDeleteFile,
7159 acp::ToolKind::Move => IconName::ArrowRightLeft,
7160 acp::ToolKind::Search => IconName::ToolSearch,
7161 acp::ToolKind::Execute => IconName::ToolTerminal,
7162 acp::ToolKind::Think => IconName::ToolThink,
7163 acp::ToolKind::Fetch => IconName::ToolWeb,
7164 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
7165 acp::ToolKind::Other | _ => IconName::ToolHammer,
7166 })
7167 .size(IconSize::Small)
7168 .color(Color::Muted)
7169 .into_any_element()
7170 };
7171
7172 let gradient_overlay = {
7173 div()
7174 .absolute()
7175 .top_0()
7176 .right_0()
7177 .w_12()
7178 .h_full()
7179 .map(|this| {
7180 if use_card_layout {
7181 this.bg(linear_gradient(
7182 90.,
7183 linear_color_stop(self.tool_card_header_bg(cx), 1.),
7184 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
7185 ))
7186 } else {
7187 this.bg(linear_gradient(
7188 90.,
7189 linear_color_stop(cx.theme().colors().panel_background, 1.),
7190 linear_color_stop(
7191 cx.theme().colors().panel_background.opacity(0.2),
7192 0.,
7193 ),
7194 ))
7195 }
7196 })
7197 };
7198
7199 h_flex()
7200 .relative()
7201 .w_full()
7202 .h(window.line_height() - px(2.))
7203 .text_size(self.tool_name_font_size())
7204 .gap_1p5()
7205 .when(has_location || use_card_layout, |this| this.px_1())
7206 .when(has_location, |this| {
7207 this.cursor(CursorStyle::PointingHand)
7208 .rounded(rems_from_px(3.)) // Concentric border radius
7209 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
7210 })
7211 .overflow_hidden()
7212 .child(tool_icon)
7213 .child(if has_location {
7214 h_flex()
7215 .id(("open-tool-call-location", entry_ix))
7216 .w_full()
7217 .map(|this| {
7218 if use_card_layout {
7219 this.text_color(cx.theme().colors().text)
7220 } else {
7221 this.text_color(cx.theme().colors().text_muted)
7222 }
7223 })
7224 .child(
7225 self.render_markdown(
7226 tool_call.label.clone(),
7227 MarkdownStyle {
7228 prevent_mouse_interaction: true,
7229 ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx)
7230 .with_muted_text(cx)
7231 },
7232 ),
7233 )
7234 .tooltip(Tooltip::text("Go to File"))
7235 .on_click(cx.listener(move |this, _, window, cx| {
7236 this.open_tool_call_location(entry_ix, 0, window, cx);
7237 }))
7238 .into_any_element()
7239 } else {
7240 h_flex()
7241 .w_full()
7242 .child(self.render_markdown(
7243 tool_call.label.clone(),
7244 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx),
7245 ))
7246 .into_any()
7247 })
7248 .when(!is_edit, |this| this.child(gradient_overlay))
7249 }
7250
7251 fn open_tool_call_location(
7252 &self,
7253 entry_ix: usize,
7254 location_ix: usize,
7255 window: &mut Window,
7256 cx: &mut Context<Self>,
7257 ) -> Option<()> {
7258 let (tool_call_location, agent_location) = self
7259 .thread
7260 .read(cx)
7261 .entries()
7262 .get(entry_ix)?
7263 .location(location_ix)?;
7264
7265 let project_path = self
7266 .project
7267 .upgrade()?
7268 .read(cx)
7269 .find_project_path(&tool_call_location.path, cx)?;
7270
7271 let open_task = self
7272 .workspace
7273 .update(cx, |workspace, cx| {
7274 workspace.open_path(project_path, None, true, window, cx)
7275 })
7276 .log_err()?;
7277 window
7278 .spawn(cx, async move |cx| {
7279 let item = open_task.await?;
7280
7281 let Some(active_editor) = item.downcast::<Editor>() else {
7282 return anyhow::Ok(());
7283 };
7284
7285 active_editor.update_in(cx, |editor, window, cx| {
7286 let snapshot = editor.buffer().read(cx).snapshot(cx);
7287 if snapshot.as_singleton().is_some()
7288 && let Some(anchor) = snapshot.anchor_in_excerpt(agent_location.position)
7289 {
7290 editor.change_selections(Default::default(), window, cx, |selections| {
7291 selections.select_anchor_ranges([anchor..anchor]);
7292 })
7293 } else {
7294 let row = tool_call_location.line.unwrap_or_default();
7295 editor.change_selections(Default::default(), window, cx, |selections| {
7296 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
7297 })
7298 }
7299 })?;
7300
7301 anyhow::Ok(())
7302 })
7303 .detach_and_log_err(cx);
7304
7305 None
7306 }
7307
7308 fn render_tool_call_content(
7309 &self,
7310 session_id: &acp::SessionId,
7311 entry_ix: usize,
7312 content: &ToolCallContent,
7313 context_ix: usize,
7314 tool_call: &ToolCall,
7315 card_layout: bool,
7316 is_image_tool_call: bool,
7317 has_failed: bool,
7318 focus_handle: &FocusHandle,
7319 window: &Window,
7320 cx: &Context<Self>,
7321 ) -> AnyElement {
7322 match content {
7323 ToolCallContent::ContentBlock(content) => {
7324 if let Some(resource_link) = content.resource_link() {
7325 self.render_resource_link(resource_link, cx)
7326 } else if let Some(markdown) = content.markdown() {
7327 self.render_markdown_output(
7328 markdown.clone(),
7329 tool_call.id.clone(),
7330 context_ix,
7331 card_layout,
7332 window,
7333 cx,
7334 )
7335 } else if let Some(image) = content.image() {
7336 let location = tool_call.locations.first().cloned();
7337 self.render_image_output(
7338 entry_ix,
7339 image.clone(),
7340 location,
7341 card_layout,
7342 is_image_tool_call,
7343 cx,
7344 )
7345 } else {
7346 Empty.into_any_element()
7347 }
7348 }
7349 ToolCallContent::Diff(diff) => {
7350 self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
7351 }
7352 ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call(
7353 session_id,
7354 entry_ix,
7355 terminal,
7356 tool_call,
7357 focus_handle,
7358 false,
7359 window,
7360 cx,
7361 ),
7362 }
7363 }
7364
7365 fn render_resource_link(
7366 &self,
7367 resource_link: &acp::ResourceLink,
7368 cx: &Context<Self>,
7369 ) -> AnyElement {
7370 let uri: SharedString = resource_link.uri.clone().into();
7371 let is_file = resource_link.uri.strip_prefix("file://");
7372
7373 let Some(project) = self.project.upgrade() else {
7374 return Empty.into_any_element();
7375 };
7376
7377 let label: SharedString = if let Some(abs_path) = is_file {
7378 if let Some(project_path) = project
7379 .read(cx)
7380 .project_path_for_absolute_path(&Path::new(abs_path), cx)
7381 && let Some(worktree) = project
7382 .read(cx)
7383 .worktree_for_id(project_path.worktree_id, cx)
7384 {
7385 worktree
7386 .read(cx)
7387 .full_path(&project_path.path)
7388 .to_string_lossy()
7389 .to_string()
7390 .into()
7391 } else {
7392 abs_path.to_string().into()
7393 }
7394 } else {
7395 uri.clone()
7396 };
7397
7398 let button_id = SharedString::from(format!("item-{}", uri));
7399
7400 div()
7401 .ml(rems(0.4))
7402 .pl_2p5()
7403 .border_l_1()
7404 .border_color(self.tool_card_border_color(cx))
7405 .overflow_hidden()
7406 .child(
7407 Button::new(button_id, label)
7408 .label_size(LabelSize::Small)
7409 .color(Color::Muted)
7410 .truncate(true)
7411 .when(is_file.is_none(), |this| {
7412 this.end_icon(
7413 Icon::new(IconName::ArrowUpRight)
7414 .size(IconSize::XSmall)
7415 .color(Color::Muted),
7416 )
7417 })
7418 .on_click(cx.listener({
7419 let workspace = self.workspace.clone();
7420 move |_, _, window, cx: &mut Context<Self>| {
7421 open_link(uri.clone(), &workspace, window, cx);
7422 }
7423 })),
7424 )
7425 .into_any_element()
7426 }
7427
7428 fn render_diff_editor(
7429 &self,
7430 entry_ix: usize,
7431 diff: &Entity<acp_thread::Diff>,
7432 tool_call: &ToolCall,
7433 has_failed: bool,
7434 cx: &Context<Self>,
7435 ) -> AnyElement {
7436 let tool_progress = matches!(
7437 &tool_call.status,
7438 ToolCallStatus::InProgress | ToolCallStatus::Pending
7439 );
7440
7441 let revealed_diff_editor = if let Some(entry) =
7442 self.entry_view_state.read(cx).entry(entry_ix)
7443 && let Some(editor) = entry.editor_for_diff(diff)
7444 && diff.read(cx).has_revealed_range(cx)
7445 {
7446 Some(editor)
7447 } else {
7448 None
7449 };
7450
7451 let show_top_border = !has_failed || revealed_diff_editor.is_some();
7452
7453 v_flex()
7454 .h_full()
7455 .when(show_top_border, |this| {
7456 this.border_t_1()
7457 .when(has_failed, |this| this.border_dashed())
7458 .border_color(self.tool_card_border_color(cx))
7459 })
7460 .child(if let Some(editor) = revealed_diff_editor {
7461 editor.into_any_element()
7462 } else if tool_progress && self.as_native_connection(cx).is_some() {
7463 self.render_diff_loading(cx)
7464 } else {
7465 Empty.into_any()
7466 })
7467 .into_any()
7468 }
7469
7470 fn render_markdown_output(
7471 &self,
7472 markdown: Entity<Markdown>,
7473 tool_call_id: acp::ToolCallId,
7474 context_ix: usize,
7475 card_layout: bool,
7476 window: &Window,
7477 cx: &Context<Self>,
7478 ) -> AnyElement {
7479 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
7480
7481 v_flex()
7482 .gap_2()
7483 .map(|this| {
7484 if card_layout {
7485 this.p_2().when(context_ix > 0, |this| {
7486 this.border_t_1()
7487 .border_color(self.tool_card_border_color(cx))
7488 })
7489 } else {
7490 this.ml(rems(0.4))
7491 .px_3p5()
7492 .border_l_1()
7493 .border_color(self.tool_card_border_color(cx))
7494 }
7495 })
7496 .text_xs()
7497 .text_color(cx.theme().colors().text_muted)
7498 .child(self.render_markdown(
7499 markdown,
7500 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
7501 ))
7502 .when(!card_layout, |this| {
7503 this.child(
7504 IconButton::new(button_id, IconName::ChevronUp)
7505 .full_width()
7506 .style(ButtonStyle::Outlined)
7507 .icon_color(Color::Muted)
7508 .on_click(cx.listener({
7509 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
7510 this.expanded_tool_calls.remove(&tool_call_id);
7511 cx.notify();
7512 }
7513 })),
7514 )
7515 })
7516 .into_any_element()
7517 }
7518
7519 fn render_image_output(
7520 &self,
7521 entry_ix: usize,
7522 image: Arc<gpui::Image>,
7523 location: Option<acp::ToolCallLocation>,
7524 card_layout: bool,
7525 show_dimensions: bool,
7526 cx: &Context<Self>,
7527 ) -> AnyElement {
7528 let dimensions_label = if show_dimensions {
7529 let format_name = match image.format() {
7530 gpui::ImageFormat::Png => "PNG",
7531 gpui::ImageFormat::Jpeg => "JPEG",
7532 gpui::ImageFormat::Webp => "WebP",
7533 gpui::ImageFormat::Gif => "GIF",
7534 gpui::ImageFormat::Svg => "SVG",
7535 gpui::ImageFormat::Bmp => "BMP",
7536 gpui::ImageFormat::Tiff => "TIFF",
7537 gpui::ImageFormat::Ico => "ICO",
7538 };
7539 let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
7540 .with_guessed_format()
7541 .ok()
7542 .and_then(|reader| reader.into_dimensions().ok());
7543 dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
7544 } else {
7545 None
7546 };
7547
7548 v_flex()
7549 .gap_2()
7550 .map(|this| {
7551 if card_layout {
7552 this
7553 } else {
7554 this.ml(rems(0.4))
7555 .px_3p5()
7556 .border_l_1()
7557 .border_color(self.tool_card_border_color(cx))
7558 }
7559 })
7560 .when(dimensions_label.is_some() || location.is_some(), |this| {
7561 this.child(
7562 h_flex()
7563 .w_full()
7564 .justify_between()
7565 .items_center()
7566 .children(dimensions_label.map(|label| {
7567 Label::new(label)
7568 .size(LabelSize::XSmall)
7569 .color(Color::Muted)
7570 .buffer_font(cx)
7571 }))
7572 .when_some(location, |this, _loc| {
7573 this.child(
7574 Button::new(("go-to-file", entry_ix), "Go to File")
7575 .label_size(LabelSize::Small)
7576 .on_click(cx.listener(move |this, _, window, cx| {
7577 this.open_tool_call_location(entry_ix, 0, window, cx);
7578 })),
7579 )
7580 }),
7581 )
7582 })
7583 .child(
7584 img(image)
7585 .max_w_96()
7586 .max_h_96()
7587 .object_fit(ObjectFit::ScaleDown),
7588 )
7589 .into_any_element()
7590 }
7591
7592 fn render_subagent_tool_call(
7593 &self,
7594 active_session_id: &acp::SessionId,
7595 entry_ix: usize,
7596 tool_call: &ToolCall,
7597 subagent_session_id: Option<acp::SessionId>,
7598 focus_handle: &FocusHandle,
7599 window: &Window,
7600 cx: &Context<Self>,
7601 ) -> Div {
7602 let subagent_thread_view = subagent_session_id.and_then(|id| {
7603 self.server_view
7604 .upgrade()
7605 .and_then(|server_view| server_view.read(cx).as_connected())
7606 .and_then(|connected| connected.threads.get(&id))
7607 });
7608
7609 let content = self.render_subagent_card(
7610 active_session_id,
7611 entry_ix,
7612 subagent_thread_view,
7613 tool_call,
7614 focus_handle,
7615 window,
7616 cx,
7617 );
7618
7619 v_flex().mx_5().my_1p5().gap_3().child(content)
7620 }
7621
7622 fn render_subagent_card(
7623 &self,
7624 active_session_id: &acp::SessionId,
7625 entry_ix: usize,
7626 thread_view: Option<&Entity<ThreadView>>,
7627 tool_call: &ToolCall,
7628 focus_handle: &FocusHandle,
7629 window: &Window,
7630 cx: &Context<Self>,
7631 ) -> AnyElement {
7632 let thread = thread_view
7633 .as_ref()
7634 .map(|view| view.read(cx).thread.clone());
7635 let subagent_session_id = thread
7636 .as_ref()
7637 .map(|thread| thread.read(cx).session_id().clone());
7638 let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log());
7639 let changed_buffers = action_log
7640 .map(|log| log.read(cx).changed_buffers(cx))
7641 .unwrap_or_default();
7642
7643 let is_pending_tool_call = thread
7644 .as_ref()
7645 .and_then(|thread| {
7646 self.conversation
7647 .read(cx)
7648 .pending_tool_call(thread.read(cx).session_id(), cx)
7649 })
7650 .is_some();
7651
7652 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
7653 let files_changed = changed_buffers.len();
7654 let diff_stats = DiffStats::all_files(&changed_buffers, cx);
7655
7656 let is_running = matches!(
7657 tool_call.status,
7658 ToolCallStatus::Pending
7659 | ToolCallStatus::InProgress
7660 | ToolCallStatus::WaitingForConfirmation { .. }
7661 );
7662
7663 let is_failed = matches!(
7664 tool_call.status,
7665 ToolCallStatus::Failed | ToolCallStatus::Rejected
7666 );
7667
7668 let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled)
7669 || tool_call.content.iter().any(|c| match c {
7670 ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
7671 markdown.read(cx).source() == "User canceled"
7672 }
7673 _ => false,
7674 });
7675
7676 let thread_title = thread
7677 .as_ref()
7678 .and_then(|t| t.read(cx).title())
7679 .filter(|t| !t.is_empty());
7680 let tool_call_label = tool_call.label.read(cx).source().to_string();
7681 let has_tool_call_label = !tool_call_label.is_empty();
7682
7683 let has_title = thread_title.is_some() || has_tool_call_label;
7684 let has_no_title_or_canceled = !has_title || is_failed || is_cancelled;
7685
7686 let title: SharedString = if let Some(thread_title) = thread_title {
7687 thread_title
7688 } else if !tool_call_label.is_empty() {
7689 tool_call_label.into()
7690 } else if is_cancelled {
7691 "Subagent Canceled".into()
7692 } else if is_failed {
7693 "Subagent Failed".into()
7694 } else {
7695 "Spawning Agent…".into()
7696 };
7697
7698 let card_header_id = format!("subagent-header-{}", entry_ix);
7699 let status_icon = format!("status-icon-{}", entry_ix);
7700 let diff_stat_id = format!("subagent-diff-{}", entry_ix);
7701
7702 let icon = h_flex().w_4().justify_center().child(if is_running {
7703 SpinnerLabel::new()
7704 .size(LabelSize::Small)
7705 .into_any_element()
7706 } else if is_cancelled {
7707 div()
7708 .id(status_icon)
7709 .child(
7710 Icon::new(IconName::Circle)
7711 .size(IconSize::Small)
7712 .color(Color::Custom(
7713 cx.theme().colors().icon_disabled.opacity(0.5),
7714 )),
7715 )
7716 .tooltip(Tooltip::text("Subagent Cancelled"))
7717 .into_any_element()
7718 } else if is_failed {
7719 div()
7720 .id(status_icon)
7721 .child(
7722 Icon::new(IconName::Close)
7723 .size(IconSize::Small)
7724 .color(Color::Error),
7725 )
7726 .tooltip(Tooltip::text("Subagent Failed"))
7727 .into_any_element()
7728 } else {
7729 Icon::new(IconName::Check)
7730 .size(IconSize::Small)
7731 .color(Color::Success)
7732 .into_any_element()
7733 });
7734
7735 let has_expandable_content = thread
7736 .as_ref()
7737 .map_or(false, |thread| !thread.read(cx).entries().is_empty());
7738
7739 let tooltip_meta_description = if is_expanded {
7740 "Click to Collapse"
7741 } else {
7742 "Click to Preview"
7743 };
7744
7745 let error_message = self.subagent_error_message(&tool_call.status, tool_call, cx);
7746
7747 v_flex()
7748 .w_full()
7749 .rounded_md()
7750 .border_1()
7751 .when(has_no_title_or_canceled, |this| this.border_dashed())
7752 .border_color(self.tool_card_border_color(cx))
7753 .overflow_hidden()
7754 .child(
7755 h_flex()
7756 .group(&card_header_id)
7757 .h_8()
7758 .p_1()
7759 .w_full()
7760 .justify_between()
7761 .when(!has_no_title_or_canceled, |this| {
7762 this.bg(self.tool_card_header_bg(cx))
7763 })
7764 .child(
7765 h_flex()
7766 .id(format!("subagent-title-{}", entry_ix))
7767 .px_1()
7768 .min_w_0()
7769 .size_full()
7770 .gap_2()
7771 .justify_between()
7772 .rounded_sm()
7773 .overflow_hidden()
7774 .child(
7775 h_flex()
7776 .min_w_0()
7777 .w_full()
7778 .gap_1p5()
7779 .child(icon)
7780 .child(
7781 Label::new(title.to_string())
7782 .size(LabelSize::Custom(self.tool_name_font_size()))
7783 .truncate(),
7784 )
7785 .when(files_changed > 0, |this| {
7786 this.child(
7787 Label::new(format!(
7788 "— {} {} changed",
7789 files_changed,
7790 if files_changed == 1 { "file" } else { "files" }
7791 ))
7792 .size(LabelSize::Custom(self.tool_name_font_size()))
7793 .color(Color::Muted),
7794 )
7795 .child(
7796 DiffStat::new(
7797 diff_stat_id.clone(),
7798 diff_stats.lines_added as usize,
7799 diff_stats.lines_removed as usize,
7800 )
7801 .label_size(LabelSize::Custom(
7802 self.tool_name_font_size(),
7803 )),
7804 )
7805 }),
7806 )
7807 .when(!has_no_title_or_canceled && !is_pending_tool_call, |this| {
7808 this.tooltip(move |_, cx| {
7809 Tooltip::with_meta(
7810 title.to_string(),
7811 None,
7812 tooltip_meta_description,
7813 cx,
7814 )
7815 })
7816 })
7817 .when(has_expandable_content && !is_pending_tool_call, |this| {
7818 this.cursor_pointer()
7819 .hover(|s| s.bg(cx.theme().colors().element_hover))
7820 .child(
7821 div().visible_on_hover(card_header_id).child(
7822 Icon::new(if is_expanded {
7823 IconName::ChevronUp
7824 } else {
7825 IconName::ChevronDown
7826 })
7827 .color(Color::Muted)
7828 .size(IconSize::Small),
7829 ),
7830 )
7831 .on_click(cx.listener({
7832 let tool_call_id = tool_call.id.clone();
7833 move |this, _, _, cx| {
7834 if this.expanded_tool_calls.contains(&tool_call_id) {
7835 this.expanded_tool_calls.remove(&tool_call_id);
7836 } else {
7837 this.expanded_tool_calls
7838 .insert(tool_call_id.clone());
7839 }
7840 let expanded =
7841 this.expanded_tool_calls.contains(&tool_call_id);
7842 telemetry::event!("Subagent Toggled", expanded);
7843 cx.notify();
7844 }
7845 }))
7846 }),
7847 )
7848 .when(is_running && subagent_session_id.is_some(), |buttons| {
7849 buttons.child(
7850 IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop)
7851 .icon_size(IconSize::Small)
7852 .icon_color(Color::Error)
7853 .tooltip(Tooltip::text("Stop Subagent"))
7854 .when_some(
7855 thread_view
7856 .as_ref()
7857 .map(|view| view.read(cx).thread.clone()),
7858 |this, thread| {
7859 this.on_click(cx.listener(
7860 move |_this, _event, _window, cx| {
7861 telemetry::event!("Subagent Stopped");
7862 thread.update(cx, |thread, cx| {
7863 thread.cancel(cx).detach();
7864 });
7865 },
7866 ))
7867 },
7868 ),
7869 )
7870 }),
7871 )
7872 .when_some(thread_view, |this, thread_view| {
7873 let thread = &thread_view.read(cx).thread;
7874 let pending_tool_call = self
7875 .conversation
7876 .read(cx)
7877 .pending_tool_call(thread.read(cx).session_id(), cx);
7878
7879 let session_id = thread.read(cx).session_id().clone();
7880
7881 let fullscreen_toggle = h_flex()
7882 .id(entry_ix)
7883 .py_1()
7884 .w_full()
7885 .justify_center()
7886 .border_t_1()
7887 .when(is_failed, |this| this.border_dashed())
7888 .border_color(self.tool_card_border_color(cx))
7889 .cursor_pointer()
7890 .hover(|s| s.bg(cx.theme().colors().element_hover))
7891 .child(
7892 Icon::new(IconName::Maximize)
7893 .color(Color::Muted)
7894 .size(IconSize::Small),
7895 )
7896 .tooltip(Tooltip::text("Make Subagent Full Screen"))
7897 .on_click(cx.listener(move |this, _event, window, cx| {
7898 telemetry::event!("Subagent Maximized");
7899 this.server_view
7900 .update(cx, |this, cx| {
7901 this.navigate_to_session(session_id.clone(), window, cx);
7902 })
7903 .ok();
7904 }));
7905
7906 if is_running && let Some((_, subagent_tool_call_id, _)) = pending_tool_call {
7907 if let Some((entry_ix, tool_call)) =
7908 thread.read(cx).tool_call(&subagent_tool_call_id)
7909 {
7910 this.child(Divider::horizontal().color(DividerColor::Border))
7911 .child(thread_view.read(cx).render_any_tool_call(
7912 active_session_id,
7913 entry_ix,
7914 tool_call,
7915 focus_handle,
7916 true,
7917 window,
7918 cx,
7919 ))
7920 .child(fullscreen_toggle)
7921 } else {
7922 this
7923 }
7924 } else {
7925 this.when(is_expanded, |this| {
7926 this.child(self.render_subagent_expanded_content(
7927 thread_view,
7928 tool_call,
7929 window,
7930 cx,
7931 ))
7932 .when_some(error_message, |this, message| {
7933 this.child(
7934 Callout::new()
7935 .severity(Severity::Error)
7936 .icon(IconName::XCircle)
7937 .title(message),
7938 )
7939 })
7940 .child(fullscreen_toggle)
7941 })
7942 }
7943 })
7944 .into_any_element()
7945 }
7946
7947 fn render_subagent_expanded_content(
7948 &self,
7949 thread_view: &Entity<ThreadView>,
7950 tool_call: &ToolCall,
7951 window: &Window,
7952 cx: &Context<Self>,
7953 ) -> impl IntoElement {
7954 const MAX_PREVIEW_ENTRIES: usize = 8;
7955
7956 let subagent_view = thread_view.read(cx);
7957 let session_id = subagent_view.thread.read(cx).session_id().clone();
7958
7959 let is_canceled_or_failed = matches!(
7960 tool_call.status,
7961 ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
7962 );
7963
7964 let editor_bg = cx.theme().colors().editor_background;
7965 let overlay = {
7966 div()
7967 .absolute()
7968 .inset_0()
7969 .size_full()
7970 .bg(linear_gradient(
7971 180.,
7972 linear_color_stop(editor_bg.opacity(0.5), 0.),
7973 linear_color_stop(editor_bg.opacity(0.), 0.1),
7974 ))
7975 .block_mouse_except_scroll()
7976 };
7977
7978 let entries = subagent_view.thread.read(cx).entries();
7979 let total_entries = entries.len();
7980 let mut entry_range = if let Some(info) = tool_call.subagent_session_info.as_ref() {
7981 info.message_start_index
7982 ..info
7983 .message_end_index
7984 .map(|i| (i + 1).min(total_entries))
7985 .unwrap_or(total_entries)
7986 } else {
7987 0..total_entries
7988 };
7989 entry_range.start = entry_range
7990 .end
7991 .saturating_sub(MAX_PREVIEW_ENTRIES)
7992 .max(entry_range.start);
7993 let start_ix = entry_range.start;
7994
7995 let scroll_handle = self
7996 .subagent_scroll_handles
7997 .borrow_mut()
7998 .entry(session_id.clone())
7999 .or_default()
8000 .clone();
8001
8002 scroll_handle.scroll_to_bottom();
8003
8004 let rendered_entries: Vec<AnyElement> = entries
8005 .get(entry_range)
8006 .unwrap_or_default()
8007 .iter()
8008 .enumerate()
8009 .map(|(i, entry)| {
8010 let actual_ix = start_ix + i;
8011 subagent_view.render_entry(actual_ix, total_entries, entry, window, cx)
8012 })
8013 .collect();
8014
8015 v_flex()
8016 .w_full()
8017 .border_t_1()
8018 .when(is_canceled_or_failed, |this| this.border_dashed())
8019 .border_color(self.tool_card_border_color(cx))
8020 .overflow_hidden()
8021 .child(
8022 div()
8023 .pb_1()
8024 .min_h_0()
8025 .id(format!("subagent-entries-{}", session_id))
8026 .track_scroll(&scroll_handle)
8027 .children(rendered_entries),
8028 )
8029 .h_56()
8030 .child(overlay)
8031 .into_any_element()
8032 }
8033
8034 fn subagent_error_message(
8035 &self,
8036 status: &ToolCallStatus,
8037 tool_call: &ToolCall,
8038 cx: &App,
8039 ) -> Option<SharedString> {
8040 if matches!(status, ToolCallStatus::Failed) {
8041 tool_call.content.iter().find_map(|content| {
8042 if let ToolCallContent::ContentBlock(block) = content {
8043 if let acp_thread::ContentBlock::Markdown { markdown } = block {
8044 let source = markdown.read(cx).source().to_string();
8045 if !source.is_empty() {
8046 if source == "User canceled" {
8047 return None;
8048 } else {
8049 return Some(SharedString::from(source));
8050 }
8051 }
8052 }
8053 }
8054 None
8055 })
8056 } else {
8057 None
8058 }
8059 }
8060
8061 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
8062 cx.theme()
8063 .colors()
8064 .element_background
8065 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
8066 }
8067
8068 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
8069 cx.theme().colors().border.opacity(0.8)
8070 }
8071
8072 fn tool_name_font_size(&self) -> Rems {
8073 rems_from_px(13.)
8074 }
8075
8076 pub(crate) fn render_thread_error(
8077 &mut self,
8078 window: &mut Window,
8079 cx: &mut Context<Self>,
8080 ) -> Option<Div> {
8081 let content = match self.thread_error.as_ref()? {
8082 ThreadError::Other { message, .. } => {
8083 self.render_any_thread_error(message.clone(), window, cx)
8084 }
8085 ThreadError::Refusal => self.render_refusal_error(cx),
8086 ThreadError::AuthenticationRequired(error) => {
8087 self.render_authentication_required_error(error.clone(), cx)
8088 }
8089 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
8090 };
8091
8092 Some(div().child(content))
8093 }
8094
8095 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
8096 let model_or_agent_name = self.current_model_name(cx);
8097 let refusal_message = format!(
8098 "{} refused to respond to this prompt. \
8099 This can happen when a model believes the prompt violates its content policy \
8100 or safety guidelines, so rephrasing it can sometimes address the issue.",
8101 model_or_agent_name
8102 );
8103
8104 Callout::new()
8105 .severity(Severity::Error)
8106 .title("Request Refused")
8107 .icon(IconName::XCircle)
8108 .description(refusal_message.clone())
8109 .actions_slot(self.create_copy_button(&refusal_message))
8110 .dismiss_action(self.dismiss_error_button(cx))
8111 }
8112
8113 fn render_authentication_required_error(
8114 &self,
8115 error: SharedString,
8116 cx: &mut Context<Self>,
8117 ) -> Callout {
8118 Callout::new()
8119 .severity(Severity::Error)
8120 .title("Authentication Required")
8121 .icon(IconName::XCircle)
8122 .description(error.clone())
8123 .actions_slot(
8124 h_flex()
8125 .gap_0p5()
8126 .child(self.authenticate_button(cx))
8127 .child(self.create_copy_button(error)),
8128 )
8129 .dismiss_action(self.dismiss_error_button(cx))
8130 }
8131
8132 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
8133 const ERROR_MESSAGE: &str =
8134 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
8135
8136 Callout::new()
8137 .severity(Severity::Error)
8138 .icon(IconName::XCircle)
8139 .title("Free Usage Exceeded")
8140 .description(ERROR_MESSAGE)
8141 .actions_slot(
8142 h_flex()
8143 .gap_0p5()
8144 .child(self.upgrade_button(cx))
8145 .child(self.create_copy_button(ERROR_MESSAGE)),
8146 )
8147 .dismiss_action(self.dismiss_error_button(cx))
8148 }
8149
8150 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8151 Button::new("upgrade", "Upgrade")
8152 .label_size(LabelSize::Small)
8153 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
8154 .on_click(cx.listener({
8155 move |this, _, _, cx| {
8156 this.clear_thread_error(cx);
8157 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
8158 }
8159 }))
8160 }
8161
8162 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8163 Button::new("authenticate", "Authenticate")
8164 .label_size(LabelSize::Small)
8165 .style(ButtonStyle::Filled)
8166 .on_click(cx.listener({
8167 move |this, _, window, cx| {
8168 let server_view = this.server_view.clone();
8169 let agent_name = this.agent_id.clone();
8170
8171 this.clear_thread_error(cx);
8172 if let Some(message) = this.in_flight_prompt.take() {
8173 this.message_editor.update(cx, |editor, cx| {
8174 editor.set_message(message, window, cx);
8175 });
8176 }
8177 let connection = this.thread.read(cx).connection().clone();
8178 window.defer(cx, |window, cx| {
8179 ConversationView::handle_auth_required(
8180 server_view,
8181 AuthRequired::new(),
8182 agent_name,
8183 connection,
8184 window,
8185 cx,
8186 );
8187 })
8188 }
8189 }))
8190 }
8191
8192 fn current_model_name(&self, cx: &App) -> SharedString {
8193 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
8194 // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
8195 // This provides better clarity about what refused the request
8196 if self.as_native_connection(cx).is_some() {
8197 self.model_selector
8198 .clone()
8199 .and_then(|selector| selector.read(cx).active_model(cx))
8200 .map(|model| model.name.clone())
8201 .unwrap_or_else(|| SharedString::from("The model"))
8202 } else {
8203 // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
8204 self.agent_id.0.clone()
8205 }
8206 }
8207
8208 fn render_any_thread_error(
8209 &mut self,
8210 error: SharedString,
8211 window: &mut Window,
8212 cx: &mut Context<'_, Self>,
8213 ) -> Callout {
8214 let can_resume = self.thread.read(cx).can_retry(cx);
8215
8216 let markdown = if let Some(markdown) = &self.thread_error_markdown {
8217 markdown.clone()
8218 } else {
8219 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
8220 self.thread_error_markdown = Some(markdown.clone());
8221 markdown
8222 };
8223
8224 let markdown_style =
8225 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx);
8226 let description = self
8227 .render_markdown(markdown, markdown_style)
8228 .into_any_element();
8229
8230 Callout::new()
8231 .severity(Severity::Error)
8232 .icon(IconName::XCircle)
8233 .title("An Error Happened")
8234 .description_slot(description)
8235 .actions_slot(
8236 h_flex()
8237 .gap_0p5()
8238 .when(can_resume, |this| {
8239 this.child(
8240 IconButton::new("retry", IconName::RotateCw)
8241 .icon_size(IconSize::Small)
8242 .tooltip(Tooltip::text("Retry Generation"))
8243 .on_click(cx.listener(|this, _, _window, cx| {
8244 this.retry_generation(cx);
8245 })),
8246 )
8247 })
8248 .child(self.create_copy_button(error.to_string())),
8249 )
8250 .dismiss_action(self.dismiss_error_button(cx))
8251 }
8252
8253 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
8254 let workspace = self.workspace.clone();
8255 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
8256 open_link(text, &workspace, window, cx);
8257 })
8258 }
8259
8260 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
8261 let message = message.into();
8262
8263 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
8264 }
8265
8266 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8267 IconButton::new("dismiss", IconName::Close)
8268 .icon_size(IconSize::Small)
8269 .tooltip(Tooltip::text("Dismiss"))
8270 .on_click(cx.listener({
8271 move |this, _, _, cx| {
8272 this.clear_thread_error(cx);
8273 cx.notify();
8274 }
8275 }))
8276 }
8277
8278 fn render_resume_notice(_cx: &Context<Self>) -> AnyElement {
8279 let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off.";
8280
8281 Callout::new()
8282 .border_position(ui::BorderPosition::Bottom)
8283 .severity(Severity::Info)
8284 .icon(IconName::Info)
8285 .title("Resumed Session")
8286 .description(description)
8287 .into_any_element()
8288 }
8289
8290 fn update_recent_history_from_cache(
8291 &mut self,
8292 history: &Entity<ThreadHistory>,
8293 cx: &mut Context<Self>,
8294 ) {
8295 self.recent_history_entries = history.read(cx).get_recent_sessions(3);
8296 self.hovered_recent_history_item = None;
8297 cx.notify();
8298 }
8299
8300 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
8301 Callout::new()
8302 .icon(IconName::Warning)
8303 .severity(Severity::Warning)
8304 .title("Codex on Windows")
8305 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
8306 .actions_slot(
8307 Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({
8308 move |_, _, _window, cx| {
8309 #[cfg(windows)]
8310 _window.dispatch_action(
8311 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
8312 cx,
8313 );
8314 cx.notify();
8315 }
8316 })),
8317 )
8318 .dismiss_action(
8319 IconButton::new("dismiss", IconName::Close)
8320 .icon_size(IconSize::Small)
8321 .icon_color(Color::Muted)
8322 .tooltip(Tooltip::text("Dismiss Warning"))
8323 .on_click(cx.listener({
8324 move |this, _, _, cx| {
8325 this.show_codex_windows_warning = false;
8326 cx.notify();
8327 }
8328 })),
8329 )
8330 }
8331
8332 fn render_external_source_prompt_warning(&self, cx: &mut Context<Self>) -> Callout {
8333 Callout::new()
8334 .icon(IconName::Warning)
8335 .severity(Severity::Warning)
8336 .title("Review before sending")
8337 .description("This prompt was pre-filled by an external link. Read it carefully before you send it.")
8338 .dismiss_action(
8339 IconButton::new("dismiss-external-source-prompt-warning", IconName::Close)
8340 .icon_size(IconSize::Small)
8341 .icon_color(Color::Muted)
8342 .tooltip(Tooltip::text("Dismiss Warning"))
8343 .on_click(cx.listener({
8344 move |this, _, _, cx| {
8345 this.show_external_source_prompt_warning = false;
8346 cx.notify();
8347 }
8348 })),
8349 )
8350 }
8351
8352 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
8353 let server_view = self.server_view.clone();
8354 let has_version = !version.is_empty();
8355 let title = if has_version {
8356 "New version available"
8357 } else {
8358 "Agent update available"
8359 };
8360 let button_label = if has_version {
8361 format!("Update to v{}", version)
8362 } else {
8363 "Reconnect".to_string()
8364 };
8365
8366 v_flex().w_full().justify_end().child(
8367 h_flex()
8368 .p_2()
8369 .pr_3()
8370 .w_full()
8371 .gap_1p5()
8372 .border_t_1()
8373 .border_color(cx.theme().colors().border)
8374 .bg(cx.theme().colors().element_background)
8375 .child(
8376 h_flex()
8377 .flex_1()
8378 .gap_1p5()
8379 .child(
8380 Icon::new(IconName::Download)
8381 .color(Color::Accent)
8382 .size(IconSize::Small),
8383 )
8384 .child(Label::new(title).size(LabelSize::Small)),
8385 )
8386 .child(
8387 Button::new("update-button", button_label)
8388 .label_size(LabelSize::Small)
8389 .style(ButtonStyle::Tinted(TintColor::Accent))
8390 .on_click(move |_, window, cx| {
8391 server_view
8392 .update(cx, |view, cx| view.reset(window, cx))
8393 .ok();
8394 }),
8395 ),
8396 )
8397 }
8398
8399 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
8400 if self.token_limit_callout_dismissed {
8401 return None;
8402 }
8403
8404 let token_usage = self.thread.read(cx).token_usage()?;
8405 let ratio = token_usage.ratio();
8406
8407 let (severity, icon, title) = match ratio {
8408 acp_thread::TokenUsageRatio::Normal => return None,
8409 acp_thread::TokenUsageRatio::Warning => (
8410 Severity::Warning,
8411 IconName::Warning,
8412 "Thread reaching the token limit soon",
8413 ),
8414 acp_thread::TokenUsageRatio::Exceeded => (
8415 Severity::Error,
8416 IconName::XCircle,
8417 "Thread reached the token limit",
8418 ),
8419 };
8420
8421 let description = "To continue, start a new thread from a summary.";
8422
8423 Some(
8424 Callout::new()
8425 .severity(severity)
8426 .icon(icon)
8427 .title(title)
8428 .description(description)
8429 .actions_slot(
8430 h_flex().gap_0p5().child(
8431 Button::new("start-new-thread", "Start New Thread")
8432 .label_size(LabelSize::Small)
8433 .on_click(cx.listener(|this, _, window, cx| {
8434 let session_id = this.thread.read(cx).session_id().clone();
8435 window.dispatch_action(
8436 crate::NewNativeAgentThreadFromSummary {
8437 from_session_id: session_id,
8438 }
8439 .boxed_clone(),
8440 cx,
8441 );
8442 })),
8443 ),
8444 )
8445 .dismiss_action(self.dismiss_error_button(cx)),
8446 )
8447 }
8448
8449 fn open_permission_dropdown(
8450 &mut self,
8451 _: &crate::OpenPermissionDropdown,
8452 window: &mut Window,
8453 cx: &mut Context<Self>,
8454 ) {
8455 let menu_handle = self.permission_dropdown_handle.clone();
8456 window.defer(cx, move |window, cx| {
8457 menu_handle.toggle(window, cx);
8458 });
8459 }
8460
8461 fn open_add_context_menu(
8462 &mut self,
8463 _action: &OpenAddContextMenu,
8464 window: &mut Window,
8465 cx: &mut Context<Self>,
8466 ) {
8467 let menu_handle = self.add_context_menu_handle.clone();
8468 window.defer(cx, move |window, cx| {
8469 menu_handle.toggle(window, cx);
8470 });
8471 }
8472
8473 fn toggle_fast_mode(&mut self, cx: &mut Context<Self>) {
8474 if !self.fast_mode_available(cx) {
8475 return;
8476 }
8477 let Some(thread) = self.as_native_thread(cx) else {
8478 return;
8479 };
8480 thread.update(cx, |thread, cx| {
8481 let new_speed = thread
8482 .speed()
8483 .map(|speed| speed.toggle())
8484 .unwrap_or(Speed::Fast);
8485 thread.set_speed(new_speed, cx);
8486
8487 let fs = thread.project().read(cx).fs().clone();
8488 update_settings_file(fs, cx, move |settings, _| {
8489 if let Some(agent) = settings.agent.as_mut()
8490 && let Some(default_model) = agent.default_model.as_mut()
8491 {
8492 default_model.speed = Some(new_speed);
8493 }
8494 });
8495 });
8496 }
8497
8498 fn cycle_thinking_effort(&mut self, cx: &mut Context<Self>) {
8499 let Some(thread) = self.as_native_thread(cx) else {
8500 return;
8501 };
8502
8503 let (effort_levels, current_effort) = {
8504 let thread_ref = thread.read(cx);
8505 let Some(model) = thread_ref.model() else {
8506 return;
8507 };
8508 if !model.supports_thinking() || !thread_ref.thinking_enabled() {
8509 return;
8510 }
8511 let effort_levels = model.supported_effort_levels();
8512 if effort_levels.is_empty() {
8513 return;
8514 }
8515 let current_effort = thread_ref.thinking_effort().cloned();
8516 (effort_levels, current_effort)
8517 };
8518
8519 let current_index = current_effort.and_then(|current| {
8520 effort_levels
8521 .iter()
8522 .position(|level| level.value == current)
8523 });
8524 let next_index = match current_index {
8525 Some(index) => (index + 1) % effort_levels.len(),
8526 None => 0,
8527 };
8528 let next_effort = effort_levels[next_index].value.to_string();
8529
8530 thread.update(cx, |thread, cx| {
8531 thread.set_thinking_effort(Some(next_effort.clone()), cx);
8532
8533 let fs = thread.project().read(cx).fs().clone();
8534 update_settings_file(fs, cx, move |settings, _| {
8535 if let Some(agent) = settings.agent.as_mut()
8536 && let Some(default_model) = agent.default_model.as_mut()
8537 {
8538 default_model.effort = Some(next_effort);
8539 }
8540 });
8541 });
8542 }
8543
8544 fn toggle_thinking_effort_menu(
8545 &mut self,
8546 _action: &ToggleThinkingEffortMenu,
8547 window: &mut Window,
8548 cx: &mut Context<Self>,
8549 ) {
8550 let menu_handle = self.thinking_effort_menu_handle.clone();
8551 window.defer(cx, move |window, cx| {
8552 menu_handle.toggle(window, cx);
8553 });
8554 }
8555}
8556
8557impl Render for ThreadView {
8558 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
8559 let has_messages = self.list_state.item_count() > 0;
8560 let max_content_width = AgentSettings::get_global(cx).max_content_width;
8561 let list_state = self.list_state.clone();
8562
8563 let conversation = v_flex()
8564 .when(self.resumed_without_history, |this| {
8565 this.child(Self::render_resume_notice(cx))
8566 })
8567 .map(|this| {
8568 if has_messages {
8569 this.flex_1()
8570 .size_full()
8571 .child(
8572 v_flex()
8573 .mx_auto()
8574 .max_w(max_content_width)
8575 .size_full()
8576 .child(self.render_entries(cx)),
8577 )
8578 .vertical_scrollbar_for(&list_state, window, cx)
8579 .into_any()
8580 } else {
8581 this.into_any()
8582 }
8583 });
8584
8585 v_flex()
8586 .key_context("AcpThread")
8587 .track_focus(&self.focus_handle)
8588 .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
8589 if this.parent_id.is_none() {
8590 this.cancel_generation(cx);
8591 }
8592 }))
8593 .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
8594 if let Some(parent_session_id) = this.parent_id.clone() {
8595 this.server_view
8596 .update(cx, |view, cx| {
8597 view.navigate_to_session(parent_session_id, window, cx);
8598 })
8599 .ok();
8600 }
8601 }))
8602 .on_action(cx.listener(Self::keep_all))
8603 .on_action(cx.listener(Self::reject_all))
8604 .on_action(cx.listener(Self::undo_last_reject))
8605 .on_action(cx.listener(Self::allow_always))
8606 .on_action(cx.listener(Self::allow_once))
8607 .on_action(cx.listener(Self::reject_once))
8608 .on_action(cx.listener(Self::handle_authorize_tool_call))
8609 .on_action(cx.listener(Self::handle_select_permission_granularity))
8610 .on_action(cx.listener(Self::handle_toggle_command_pattern))
8611 .on_action(cx.listener(Self::open_permission_dropdown))
8612 .on_action(cx.listener(Self::open_add_context_menu))
8613 .on_action(cx.listener(Self::scroll_output_page_up))
8614 .on_action(cx.listener(Self::scroll_output_page_down))
8615 .on_action(cx.listener(Self::scroll_output_line_up))
8616 .on_action(cx.listener(Self::scroll_output_line_down))
8617 .on_action(cx.listener(Self::scroll_output_to_top))
8618 .on_action(cx.listener(Self::scroll_output_to_bottom))
8619 .on_action(cx.listener(Self::scroll_output_to_previous_message))
8620 .on_action(cx.listener(Self::scroll_output_to_next_message))
8621 .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {
8622 this.toggle_fast_mode(cx);
8623 }))
8624 .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
8625 if this.thread.read(cx).status() != ThreadStatus::Idle {
8626 return;
8627 }
8628 if let Some(thread) = this.as_native_thread(cx) {
8629 thread.update(cx, |thread, cx| {
8630 thread.set_thinking_enabled(!thread.thinking_enabled(), cx);
8631 });
8632 }
8633 }))
8634 .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
8635 if this.thread.read(cx).status() != ThreadStatus::Idle {
8636 return;
8637 }
8638 this.cycle_thinking_effort(cx);
8639 }))
8640 .on_action(
8641 cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| {
8642 if this.thread.read(cx).status() != ThreadStatus::Idle {
8643 return;
8644 }
8645 this.toggle_thinking_effort_menu(action, window, cx);
8646 }),
8647 )
8648 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
8649 this.send_queued_message_at_index(0, true, window, cx);
8650 }))
8651 .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
8652 this.remove_from_queue(0, cx);
8653 cx.notify();
8654 }))
8655 .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
8656 this.move_queued_message_to_main_editor(0, None, None, window, cx);
8657 }))
8658 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
8659 this.local_queued_messages.clear();
8660 this.sync_queue_flag_to_native_thread(cx);
8661 this.can_fast_track_queue = false;
8662 cx.notify();
8663 }))
8664 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
8665 if this.thread.read(cx).status() != ThreadStatus::Idle {
8666 return;
8667 }
8668 if let Some(config_options_view) = this.config_options_view.clone() {
8669 let handled = config_options_view.update(cx, |view, cx| {
8670 view.toggle_category_picker(
8671 acp::SessionConfigOptionCategory::Mode,
8672 window,
8673 cx,
8674 )
8675 });
8676 if handled {
8677 return;
8678 }
8679 }
8680
8681 if let Some(profile_selector) = this.profile_selector.clone() {
8682 profile_selector.read(cx).menu_handle().toggle(window, cx);
8683 } else if let Some(mode_selector) = this.mode_selector.clone() {
8684 mode_selector.read(cx).menu_handle().toggle(window, cx);
8685 }
8686 }))
8687 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
8688 if this.thread.read(cx).status() != ThreadStatus::Idle {
8689 return;
8690 }
8691 if let Some(config_options_view) = this.config_options_view.clone() {
8692 let handled = config_options_view.update(cx, |view, cx| {
8693 view.cycle_category_option(
8694 acp::SessionConfigOptionCategory::Mode,
8695 false,
8696 cx,
8697 )
8698 });
8699 if handled {
8700 return;
8701 }
8702 }
8703
8704 if let Some(profile_selector) = this.profile_selector.clone() {
8705 profile_selector.update(cx, |profile_selector, cx| {
8706 profile_selector.cycle_profile(cx);
8707 });
8708 } else if let Some(mode_selector) = this.mode_selector.clone() {
8709 mode_selector.update(cx, |mode_selector, cx| {
8710 mode_selector.cycle_mode(window, cx);
8711 });
8712 }
8713 }))
8714 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
8715 if this.thread.read(cx).status() != ThreadStatus::Idle {
8716 return;
8717 }
8718 if let Some(config_options_view) = this.config_options_view.clone() {
8719 let handled = config_options_view.update(cx, |view, cx| {
8720 view.toggle_category_picker(
8721 acp::SessionConfigOptionCategory::Model,
8722 window,
8723 cx,
8724 )
8725 });
8726 if handled {
8727 return;
8728 }
8729 }
8730
8731 if let Some(model_selector) = this.model_selector.clone() {
8732 model_selector
8733 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
8734 }
8735 }))
8736 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
8737 if this.thread.read(cx).status() != ThreadStatus::Idle {
8738 return;
8739 }
8740 if let Some(config_options_view) = this.config_options_view.clone() {
8741 let handled = config_options_view.update(cx, |view, cx| {
8742 view.cycle_category_option(
8743 acp::SessionConfigOptionCategory::Model,
8744 true,
8745 cx,
8746 )
8747 });
8748 if handled {
8749 return;
8750 }
8751 }
8752
8753 if let Some(model_selector) = this.model_selector.clone() {
8754 model_selector.update(cx, |model_selector, cx| {
8755 model_selector.cycle_favorite_models(window, cx);
8756 });
8757 }
8758 }))
8759 .size_full()
8760 .children(self.render_subagent_titlebar(cx))
8761 .child(conversation)
8762 .children(self.render_activity_bar(window, cx))
8763 .when(self.show_external_source_prompt_warning, |this| {
8764 this.child(self.render_external_source_prompt_warning(cx))
8765 })
8766 .when(self.show_codex_windows_warning, |this| {
8767 this.child(self.render_codex_windows_warning(cx))
8768 })
8769 .children(self.render_thread_retry_status_callout())
8770 .children(self.render_thread_error(window, cx))
8771 .when_some(
8772 match has_messages {
8773 true => None,
8774 false => self.new_server_version_available.clone(),
8775 },
8776 |this, version| this.child(self.render_new_version_callout(&version, cx)),
8777 )
8778 .children(self.render_token_limit_callout(cx))
8779 .child(self.render_message_editor(window, cx))
8780 }
8781}
8782
8783pub(crate) fn open_link(
8784 url: SharedString,
8785 workspace: &WeakEntity<Workspace>,
8786 window: &mut Window,
8787 cx: &mut App,
8788) {
8789 let Some(workspace) = workspace.upgrade() else {
8790 cx.open_url(&url);
8791 return;
8792 };
8793
8794 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() {
8795 workspace.update(cx, |workspace, cx| match mention {
8796 MentionUri::File { abs_path } => {
8797 let project = workspace.project();
8798 let Some(path) =
8799 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
8800 else {
8801 return;
8802 };
8803
8804 workspace
8805 .open_path(path, None, true, window, cx)
8806 .detach_and_log_err(cx);
8807 }
8808 MentionUri::PastedImage { .. } => {}
8809 MentionUri::Directory { abs_path } => {
8810 let project = workspace.project();
8811 let Some(entry_id) = project.update(cx, |project, cx| {
8812 let path = project.find_project_path(abs_path, cx)?;
8813 project.entry_for_path(&path, cx).map(|entry| entry.id)
8814 }) else {
8815 return;
8816 };
8817
8818 project.update(cx, |_, cx| {
8819 cx.emit(project::Event::RevealInProjectPanel(entry_id));
8820 });
8821 }
8822 MentionUri::Symbol {
8823 abs_path: path,
8824 line_range,
8825 ..
8826 }
8827 | MentionUri::Selection {
8828 abs_path: Some(path),
8829 line_range,
8830 } => {
8831 let project = workspace.project();
8832 let Some(path) =
8833 project.update(cx, |project, cx| project.find_project_path(path, cx))
8834 else {
8835 return;
8836 };
8837
8838 let item = workspace.open_path(path, None, true, window, cx);
8839 window
8840 .spawn(cx, async move |cx| {
8841 let Some(editor) = item.await?.downcast::<Editor>() else {
8842 return Ok(());
8843 };
8844 let range =
8845 Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
8846 editor
8847 .update_in(cx, |editor, window, cx| {
8848 editor.change_selections(
8849 SelectionEffects::scroll(Autoscroll::center()),
8850 window,
8851 cx,
8852 |s| s.select_ranges(vec![range]),
8853 );
8854 })
8855 .ok();
8856 anyhow::Ok(())
8857 })
8858 .detach_and_log_err(cx);
8859 }
8860 MentionUri::Selection { abs_path: None, .. } => {}
8861 MentionUri::Thread { id, name } => {
8862 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
8863 panel.update(cx, |panel, cx| {
8864 panel.open_thread(id, None, Some(name.into()), window, cx)
8865 });
8866 }
8867 }
8868 MentionUri::Rule { id, .. } => {
8869 let PromptId::User { uuid } = id else {
8870 return;
8871 };
8872 window.dispatch_action(
8873 Box::new(OpenRulesLibrary {
8874 prompt_to_select: Some(uuid.0),
8875 }),
8876 cx,
8877 )
8878 }
8879 MentionUri::Fetch { url } => {
8880 cx.open_url(url.as_str());
8881 }
8882 MentionUri::Diagnostics { .. } => {}
8883 MentionUri::TerminalSelection { .. } => {}
8884 MentionUri::GitDiff { .. } => {}
8885 MentionUri::MergeConflict { .. } => {}
8886 })
8887 } else {
8888 cx.open_url(&url);
8889 }
8890}