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