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 let max_content_width = AgentSettings::get_global(cx).max_content_width;
3018
3019 Some(
3020 h_flex()
3021 .w_full()
3022 .h(Tab::container_height(cx))
3023 .border_b_1()
3024 .when(is_done && is_canceled_or_failed, |this| {
3025 this.border_dashed()
3026 })
3027 .border_color(cx.theme().colors().border)
3028 .bg(cx.theme().colors().editor_background.opacity(0.2))
3029 .child(
3030 h_flex()
3031 .size_full()
3032 .max_w(max_content_width)
3033 .mx_auto()
3034 .pl_2()
3035 .pr_1()
3036 .flex_shrink_0()
3037 .justify_between()
3038 .gap_1()
3039 .child(
3040 h_flex()
3041 .flex_1()
3042 .gap_2()
3043 .child(
3044 Icon::new(IconName::ForwardArrowUp)
3045 .size(IconSize::Small)
3046 .color(Color::Muted),
3047 )
3048 .child(self.title_editor.clone())
3049 .when(is_done && is_canceled_or_failed, |this| {
3050 this.child(Icon::new(IconName::Close).color(Color::Error))
3051 })
3052 .when(is_done && !is_canceled_or_failed, |this| {
3053 this.child(Icon::new(IconName::Check).color(Color::Success))
3054 }),
3055 )
3056 .child(
3057 h_flex()
3058 .gap_0p5()
3059 .when(!is_done, |this| {
3060 this.child(
3061 IconButton::new("stop_subagent", IconName::Stop)
3062 .icon_size(IconSize::Small)
3063 .icon_color(Color::Error)
3064 .tooltip(Tooltip::text("Stop Subagent"))
3065 .on_click(move |_, _, cx| {
3066 thread.update(cx, |thread, cx| {
3067 thread.cancel(cx).detach();
3068 });
3069 }),
3070 )
3071 })
3072 .child(
3073 IconButton::new("minimize_subagent", IconName::Dash)
3074 .icon_size(IconSize::Small)
3075 .tooltip(Tooltip::text("Minimize Subagent"))
3076 .on_click(move |_, window, cx| {
3077 let _ = server_view.update(cx, |server_view, cx| {
3078 server_view.navigate_to_session(
3079 parent_session_id.clone(),
3080 window,
3081 cx,
3082 );
3083 });
3084 }),
3085 ),
3086 ),
3087 ),
3088 )
3089 }
3090
3091 pub(crate) fn render_message_editor(
3092 &mut self,
3093 window: &mut Window,
3094 cx: &mut Context<Self>,
3095 ) -> AnyElement {
3096 if self.is_subagent() {
3097 return div().into_any_element();
3098 }
3099
3100 let focus_handle = self.message_editor.focus_handle(cx);
3101 let editor_bg_color = cx.theme().colors().editor_background;
3102 let editor_expanded = self.editor_expanded;
3103 let has_messages = self.list_state.item_count() > 0;
3104 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
3105 let (expand_icon, expand_tooltip) = if editor_expanded {
3106 (IconName::Minimize, "Minimize Message Editor")
3107 } else {
3108 (IconName::Maximize, "Expand Message Editor")
3109 };
3110
3111 let max_content_width = AgentSettings::get_global(cx).max_content_width;
3112
3113 v_flex()
3114 .on_action(cx.listener(Self::expand_message_editor))
3115 .p_2()
3116 .gap_2()
3117 .when(!v2_empty_state, |this| {
3118 this.border_t_1().border_color(cx.theme().colors().border)
3119 })
3120 .bg(editor_bg_color)
3121 .when(v2_empty_state, |this| this.flex_1().size_full())
3122 .when(editor_expanded && !v2_empty_state, |this| {
3123 this.h(vh(0.8, window)).size_full().justify_between()
3124 })
3125 .child(
3126 v_flex()
3127 .flex_1()
3128 .w_full()
3129 .max_w(max_content_width)
3130 .mx_auto()
3131 .child(
3132 v_flex()
3133 .relative()
3134 .size_full()
3135 .when(v2_empty_state, |this| this.flex_1())
3136 .pt_1()
3137 .pr_2p5()
3138 .child(self.message_editor.clone())
3139 .when(!v2_empty_state, |this| {
3140 this.child(
3141 h_flex()
3142 .absolute()
3143 .top_0()
3144 .right_0()
3145 .opacity(0.5)
3146 .hover(|this| this.opacity(1.0))
3147 .child(
3148 IconButton::new("toggle-height", expand_icon)
3149 .icon_size(IconSize::Small)
3150 .icon_color(Color::Muted)
3151 .tooltip({
3152 move |_window, cx| {
3153 Tooltip::for_action_in(
3154 expand_tooltip,
3155 &ExpandMessageEditor,
3156 &focus_handle,
3157 cx,
3158 )
3159 }
3160 })
3161 .on_click(cx.listener(|this, _, window, cx| {
3162 this.expand_message_editor(
3163 &ExpandMessageEditor,
3164 window,
3165 cx,
3166 );
3167 })),
3168 ),
3169 )
3170 }),
3171 )
3172 .child(
3173 h_flex()
3174 .flex_none()
3175 .flex_wrap()
3176 .justify_between()
3177 .child(
3178 h_flex()
3179 .gap_0p5()
3180 .child(self.render_add_context_button(cx))
3181 .child(self.render_follow_toggle(cx))
3182 .children(self.render_fast_mode_control(cx))
3183 .children(self.render_thinking_control(cx)),
3184 )
3185 .child(
3186 h_flex()
3187 .gap_1()
3188 .children(self.render_token_usage(cx))
3189 .children(self.profile_selector.clone())
3190 .map(|this| {
3191 // Either config_options_view OR (mode_selector + model_selector)
3192 match self.config_options_view.clone() {
3193 Some(config_view) => this.child(config_view),
3194 None => this
3195 .children(self.mode_selector.clone())
3196 .children(self.model_selector.clone()),
3197 }
3198 })
3199 .child(self.render_send_button(cx)),
3200 ),
3201 ),
3202 )
3203 .into_any()
3204 }
3205
3206 fn render_message_queue_entries(
3207 &self,
3208 _window: &mut Window,
3209 cx: &Context<Self>,
3210 ) -> impl IntoElement {
3211 let message_editor = self.message_editor.read(cx);
3212 let focus_handle = message_editor.focus_handle(cx);
3213
3214 let queued_message_editors = &self.queued_message_editors;
3215 let queue_len = queued_message_editors.len();
3216 let can_fast_track = self.can_fast_track_queue && queue_len > 0;
3217
3218 v_flex()
3219 .id("message_queue_list")
3220 .max_h_40()
3221 .overflow_y_scroll()
3222 .children(
3223 queued_message_editors
3224 .iter()
3225 .enumerate()
3226 .map(|(index, editor)| {
3227 let is_next = index == 0;
3228 let (icon_color, tooltip_text) = if is_next {
3229 (Color::Accent, "Next in Queue")
3230 } else {
3231 (Color::Muted, "In Queue")
3232 };
3233
3234 let editor_focused = editor.focus_handle(cx).is_focused(_window);
3235 let keybinding_size = rems_from_px(12.);
3236
3237 h_flex()
3238 .group("queue_entry")
3239 .w_full()
3240 .p_1p5()
3241 .gap_1()
3242 .bg(cx.theme().colors().editor_background)
3243 .when(index < queue_len - 1, |this| {
3244 this.border_b_1()
3245 .border_color(cx.theme().colors().border_variant)
3246 })
3247 .child(
3248 div()
3249 .id("next_in_queue")
3250 .child(
3251 Icon::new(IconName::Circle)
3252 .size(IconSize::Small)
3253 .color(icon_color),
3254 )
3255 .tooltip(Tooltip::text(tooltip_text)),
3256 )
3257 .child(editor.clone())
3258 .child(if editor_focused {
3259 h_flex()
3260 .gap_1()
3261 .min_w(rems_from_px(150.))
3262 .justify_end()
3263 .child(
3264 IconButton::new(("edit", index), IconName::Pencil)
3265 .icon_size(IconSize::Small)
3266 .tooltip(|_window, cx| {
3267 Tooltip::with_meta(
3268 "Edit Queued Message",
3269 None,
3270 "Type anything to edit",
3271 cx,
3272 )
3273 })
3274 .on_click(cx.listener(move |this, _, window, cx| {
3275 this.move_queued_message_to_main_editor(
3276 index, None, None, window, cx,
3277 );
3278 })),
3279 )
3280 .child(
3281 Button::new(("send_now_focused", index), "Send Now")
3282 .label_size(LabelSize::Small)
3283 .style(ButtonStyle::Outlined)
3284 .key_binding(
3285 KeyBinding::for_action_in(
3286 &SendImmediately,
3287 &editor.focus_handle(cx),
3288 cx,
3289 )
3290 .map(|kb| kb.size(keybinding_size)),
3291 )
3292 .on_click(cx.listener(move |this, _, window, cx| {
3293 this.send_queued_message_at_index(
3294 index, true, window, cx,
3295 );
3296 })),
3297 )
3298 } else {
3299 h_flex()
3300 .when(!is_next, |this| this.visible_on_hover("queue_entry"))
3301 .gap_1()
3302 .min_w(rems_from_px(150.))
3303 .justify_end()
3304 .child(
3305 IconButton::new(("delete", index), IconName::Trash)
3306 .icon_size(IconSize::Small)
3307 .tooltip({
3308 let focus_handle = focus_handle.clone();
3309 move |_window, cx| {
3310 if is_next {
3311 Tooltip::for_action_in(
3312 "Remove Message from Queue",
3313 &RemoveFirstQueuedMessage,
3314 &focus_handle,
3315 cx,
3316 )
3317 } else {
3318 Tooltip::simple(
3319 "Remove Message from Queue",
3320 cx,
3321 )
3322 }
3323 }
3324 })
3325 .on_click(cx.listener(move |this, _, _, cx| {
3326 this.remove_from_queue(index, cx);
3327 cx.notify();
3328 })),
3329 )
3330 .child(
3331 IconButton::new(("edit", index), IconName::Pencil)
3332 .icon_size(IconSize::Small)
3333 .tooltip({
3334 let focus_handle = focus_handle.clone();
3335 move |_window, cx| {
3336 if is_next {
3337 Tooltip::for_action_in(
3338 "Edit",
3339 &EditFirstQueuedMessage,
3340 &focus_handle,
3341 cx,
3342 )
3343 } else {
3344 Tooltip::simple("Edit", cx)
3345 }
3346 }
3347 })
3348 .on_click(cx.listener(move |this, _, window, cx| {
3349 this.move_queued_message_to_main_editor(
3350 index, None, None, window, cx,
3351 );
3352 })),
3353 )
3354 .child(
3355 Button::new(("send_now", index), "Send Now")
3356 .label_size(LabelSize::Small)
3357 .when(is_next, |this| this.style(ButtonStyle::Outlined))
3358 .when(is_next && message_editor.is_empty(cx), |this| {
3359 let action: Box<dyn gpui::Action> =
3360 if can_fast_track {
3361 Box::new(Chat)
3362 } else {
3363 Box::new(SendNextQueuedMessage)
3364 };
3365
3366 this.key_binding(
3367 KeyBinding::for_action_in(
3368 action.as_ref(),
3369 &focus_handle.clone(),
3370 cx,
3371 )
3372 .map(|kb| kb.size(keybinding_size)),
3373 )
3374 })
3375 .on_click(cx.listener(move |this, _, window, cx| {
3376 this.send_queued_message_at_index(
3377 index, true, window, cx,
3378 );
3379 })),
3380 )
3381 })
3382 }),
3383 )
3384 .into_any_element()
3385 }
3386
3387 fn supports_split_token_display(&self, cx: &App) -> bool {
3388 self.as_native_thread(cx)
3389 .and_then(|thread| thread.read(cx).model())
3390 .is_some_and(|model| model.supports_split_token_display())
3391 }
3392
3393 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3394 let thread = self.thread.read(cx);
3395 let usage = thread.token_usage()?;
3396 let show_split = self.supports_split_token_display(cx);
3397
3398 let progress_color = |ratio: f32| -> Hsla {
3399 if ratio >= 0.85 {
3400 cx.theme().status().warning
3401 } else {
3402 cx.theme().colors().text_muted
3403 }
3404 };
3405
3406 let used = crate::humanize_token_count(usage.used_tokens);
3407 let max = crate::humanize_token_count(usage.max_tokens);
3408 let input_tokens_label = crate::humanize_token_count(usage.input_tokens);
3409 let output_tokens_label = crate::humanize_token_count(usage.output_tokens);
3410
3411 let progress_ratio = if usage.max_tokens > 0 {
3412 usage.used_tokens as f32 / usage.max_tokens as f32
3413 } else {
3414 0.0
3415 };
3416
3417 let ring_size = px(16.0);
3418 let stroke_width = px(2.);
3419
3420 let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32);
3421
3422 let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6));
3423
3424 let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self
3425 .as_native_thread(cx)
3426 .map(|thread| {
3427 let project_context = thread.read(cx).project_context().read(cx);
3428 let user_rules_count = project_context.user_rules.len();
3429 let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0);
3430 let project_entry_ids = project_context
3431 .worktrees
3432 .iter()
3433 .filter_map(|wt| wt.rules_file.as_ref())
3434 .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id))
3435 .collect::<Vec<_>>();
3436 let project_rules_count = project_entry_ids.len();
3437 (
3438 user_rules_count,
3439 first_user_rules_id,
3440 project_rules_count,
3441 project_entry_ids,
3442 )
3443 })
3444 .unwrap_or_default();
3445
3446 let workspace = self.workspace.clone();
3447
3448 let max_output_tokens = self
3449 .as_native_thread(cx)
3450 .and_then(|thread| thread.read(cx).model())
3451 .and_then(|model| model.max_output_tokens())
3452 .unwrap_or(0);
3453 let input_max_label =
3454 crate::humanize_token_count(usage.max_tokens.saturating_sub(max_output_tokens));
3455 let output_max_label = crate::humanize_token_count(max_output_tokens);
3456
3457 let build_tooltip = {
3458 move |_window: &mut Window, cx: &mut App| {
3459 let percentage = percentage.clone();
3460 let used = used.clone();
3461 let max = max.clone();
3462 let input_tokens_label = input_tokens_label.clone();
3463 let output_tokens_label = output_tokens_label.clone();
3464 let input_max_label = input_max_label.clone();
3465 let output_max_label = output_max_label.clone();
3466 let project_entry_ids = project_entry_ids.clone();
3467 let workspace = workspace.clone();
3468 cx.new(move |_cx| TokenUsageTooltip {
3469 percentage,
3470 used,
3471 max,
3472 input_tokens: input_tokens_label,
3473 output_tokens: output_tokens_label,
3474 input_max: input_max_label,
3475 output_max: output_max_label,
3476 show_split,
3477 separator_color: tooltip_separator_color,
3478 user_rules_count,
3479 first_user_rules_id,
3480 project_rules_count,
3481 project_entry_ids,
3482 workspace,
3483 })
3484 .into()
3485 }
3486 };
3487
3488 if show_split {
3489 let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens);
3490 let output_max_raw = max_output_tokens;
3491
3492 let input_ratio = if input_max_raw > 0 {
3493 usage.input_tokens as f32 / input_max_raw as f32
3494 } else {
3495 0.0
3496 };
3497 let output_ratio = if output_max_raw > 0 {
3498 usage.output_tokens as f32 / output_max_raw as f32
3499 } else {
3500 0.0
3501 };
3502
3503 Some(
3504 h_flex()
3505 .id("split_token_usage")
3506 .flex_shrink_0()
3507 .gap_1p5()
3508 .mr_1()
3509 .child(
3510 h_flex()
3511 .gap_0p5()
3512 .child(
3513 Icon::new(IconName::ArrowUp)
3514 .size(IconSize::XSmall)
3515 .color(Color::Muted),
3516 )
3517 .child(
3518 CircularProgress::new(
3519 usage.input_tokens as f32,
3520 input_max_raw as f32,
3521 ring_size,
3522 cx,
3523 )
3524 .stroke_width(stroke_width)
3525 .progress_color(progress_color(input_ratio)),
3526 ),
3527 )
3528 .child(
3529 h_flex()
3530 .gap_0p5()
3531 .child(
3532 Icon::new(IconName::ArrowDown)
3533 .size(IconSize::XSmall)
3534 .color(Color::Muted),
3535 )
3536 .child(
3537 CircularProgress::new(
3538 usage.output_tokens as f32,
3539 output_max_raw as f32,
3540 ring_size,
3541 cx,
3542 )
3543 .stroke_width(stroke_width)
3544 .progress_color(progress_color(output_ratio)),
3545 ),
3546 )
3547 .hoverable_tooltip(build_tooltip)
3548 .into_any_element(),
3549 )
3550 } else {
3551 Some(
3552 h_flex()
3553 .id("circular_progress_tokens")
3554 .mt_px()
3555 .mr_1()
3556 .child(
3557 CircularProgress::new(
3558 usage.used_tokens as f32,
3559 usage.max_tokens as f32,
3560 ring_size,
3561 cx,
3562 )
3563 .stroke_width(stroke_width)
3564 .progress_color(progress_color(progress_ratio)),
3565 )
3566 .hoverable_tooltip(build_tooltip)
3567 .into_any_element(),
3568 )
3569 }
3570 }
3571
3572 fn fast_mode_available(&self, cx: &Context<Self>) -> bool {
3573 if !cx.is_staff() {
3574 return false;
3575 }
3576 self.as_native_thread(cx)
3577 .and_then(|thread| thread.read(cx).model())
3578 .map(|model| model.supports_fast_mode())
3579 .unwrap_or(false)
3580 }
3581
3582 fn render_fast_mode_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3583 if !self.fast_mode_available(cx) {
3584 return None;
3585 }
3586
3587 let thread = self.as_native_thread(cx)?.read(cx);
3588
3589 let (tooltip_label, color, icon) = if matches!(thread.speed(), Some(Speed::Fast)) {
3590 ("Disable Fast Mode", Color::Muted, IconName::FastForward)
3591 } else {
3592 (
3593 "Enable Fast Mode",
3594 Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
3595 IconName::FastForwardOff,
3596 )
3597 };
3598
3599 let focus_handle = self.message_editor.focus_handle(cx);
3600
3601 Some(
3602 IconButton::new("fast-mode", icon)
3603 .icon_size(IconSize::Small)
3604 .icon_color(color)
3605 .tooltip(move |_, cx| {
3606 Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &focus_handle, cx)
3607 })
3608 .on_click(cx.listener(move |this, _, _window, cx| {
3609 this.toggle_fast_mode(cx);
3610 }))
3611 .into_any_element(),
3612 )
3613 }
3614
3615 fn render_thinking_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3616 let thread = self.as_native_thread(cx)?.read(cx);
3617 let model = thread.model()?;
3618
3619 let supports_thinking = model.supports_thinking();
3620 if !supports_thinking {
3621 return None;
3622 }
3623
3624 let thinking = thread.thinking_enabled();
3625
3626 let (tooltip_label, icon, color) = if thinking {
3627 (
3628 "Disable Thinking Mode",
3629 IconName::ThinkingMode,
3630 Color::Muted,
3631 )
3632 } else {
3633 (
3634 "Enable Thinking Mode",
3635 IconName::ThinkingModeOff,
3636 Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
3637 )
3638 };
3639
3640 let focus_handle = self.message_editor.focus_handle(cx);
3641
3642 let thinking_toggle = IconButton::new("thinking-mode", icon)
3643 .icon_size(IconSize::Small)
3644 .icon_color(color)
3645 .tooltip(move |_, cx| {
3646 Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
3647 })
3648 .on_click(cx.listener(move |this, _, _window, cx| {
3649 if let Some(thread) = this.as_native_thread(cx) {
3650 thread.update(cx, |thread, cx| {
3651 let enable_thinking = !thread.thinking_enabled();
3652 thread.set_thinking_enabled(enable_thinking, cx);
3653
3654 let fs = thread.project().read(cx).fs().clone();
3655 update_settings_file(fs, cx, move |settings, _| {
3656 if let Some(agent) = settings.agent.as_mut()
3657 && let Some(default_model) = agent.default_model.as_mut()
3658 {
3659 default_model.enable_thinking = enable_thinking;
3660 }
3661 });
3662 });
3663 }
3664 }));
3665
3666 if model.supported_effort_levels().is_empty() {
3667 return Some(thinking_toggle.into_any_element());
3668 }
3669
3670 if !model.supported_effort_levels().is_empty() && !thinking {
3671 return Some(thinking_toggle.into_any_element());
3672 }
3673
3674 let left_btn = thinking_toggle;
3675 let right_btn = self.render_effort_selector(
3676 model.supported_effort_levels(),
3677 thread.thinking_effort().cloned(),
3678 cx,
3679 );
3680
3681 Some(
3682 SplitButton::new(left_btn, right_btn.into_any_element())
3683 .style(SplitButtonStyle::Transparent)
3684 .into_any_element(),
3685 )
3686 }
3687
3688 fn render_effort_selector(
3689 &self,
3690 supported_effort_levels: Vec<LanguageModelEffortLevel>,
3691 selected_effort: Option<String>,
3692 cx: &Context<Self>,
3693 ) -> impl IntoElement {
3694 let weak_self = cx.weak_entity();
3695
3696 let default_effort_level = supported_effort_levels
3697 .iter()
3698 .find(|effort_level| effort_level.is_default)
3699 .cloned();
3700
3701 let selected = selected_effort.and_then(|effort| {
3702 supported_effort_levels
3703 .iter()
3704 .find(|level| level.value == effort)
3705 .cloned()
3706 });
3707
3708 let label = selected
3709 .clone()
3710 .or(default_effort_level)
3711 .map_or("Select Effort".into(), |effort| effort.name);
3712
3713 let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() {
3714 (Color::Accent, IconName::ChevronUp)
3715 } else {
3716 (Color::Muted, IconName::ChevronDown)
3717 };
3718
3719 let focus_handle = self.message_editor.focus_handle(cx);
3720 let show_cycle_row = supported_effort_levels.len() > 1;
3721
3722 let tooltip = Tooltip::element({
3723 move |_, cx| {
3724 let mut content = v_flex().gap_1().child(
3725 h_flex()
3726 .gap_2()
3727 .justify_between()
3728 .child(Label::new("Change Thinking Effort"))
3729 .child(KeyBinding::for_action_in(
3730 &ToggleThinkingEffortMenu,
3731 &focus_handle,
3732 cx,
3733 )),
3734 );
3735
3736 if show_cycle_row {
3737 content = content.child(
3738 h_flex()
3739 .pt_1()
3740 .gap_2()
3741 .justify_between()
3742 .border_t_1()
3743 .border_color(cx.theme().colors().border_variant)
3744 .child(Label::new("Cycle Thinking Effort"))
3745 .child(KeyBinding::for_action_in(
3746 &CycleThinkingEffort,
3747 &focus_handle,
3748 cx,
3749 )),
3750 );
3751 }
3752
3753 content.into_any_element()
3754 }
3755 });
3756
3757 PopoverMenu::new("effort-selector")
3758 .trigger_with_tooltip(
3759 ButtonLike::new_rounded_right("effort-selector-trigger")
3760 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
3761 .child(Label::new(label).size(LabelSize::Small).color(label_color))
3762 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
3763 tooltip,
3764 )
3765 .menu(move |window, cx| {
3766 Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
3767 menu = menu.header("Change Thinking Effort");
3768
3769 for effort_level in supported_effort_levels.clone() {
3770 let is_selected = selected
3771 .as_ref()
3772 .is_some_and(|selected| selected.value == effort_level.value);
3773 let entry = ContextMenuEntry::new(effort_level.name)
3774 .toggleable(IconPosition::End, is_selected);
3775
3776 menu.push_item(entry.handler({
3777 let effort = effort_level.value.clone();
3778 let weak_self = weak_self.clone();
3779 move |_window, cx| {
3780 let effort = effort.clone();
3781 weak_self
3782 .update(cx, |this, cx| {
3783 if let Some(thread) = this.as_native_thread(cx) {
3784 thread.update(cx, |thread, cx| {
3785 thread.set_thinking_effort(
3786 Some(effort.to_string()),
3787 cx,
3788 );
3789
3790 let fs = thread.project().read(cx).fs().clone();
3791 update_settings_file(fs, cx, move |settings, _| {
3792 if let Some(agent) = settings.agent.as_mut()
3793 && let Some(default_model) =
3794 agent.default_model.as_mut()
3795 {
3796 default_model.effort =
3797 Some(effort.to_string());
3798 }
3799 });
3800 });
3801 }
3802 })
3803 .ok();
3804 }
3805 }));
3806 }
3807
3808 menu
3809 }))
3810 })
3811 .with_handle(self.thinking_effort_menu_handle.clone())
3812 .offset(gpui::Point {
3813 x: px(0.0),
3814 y: px(-2.0),
3815 })
3816 .anchor(Corner::BottomLeft)
3817 }
3818
3819 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3820 let message_editor = self.message_editor.read(cx);
3821 let is_editor_empty = message_editor.is_empty(cx);
3822 let focus_handle = message_editor.focus_handle(cx);
3823
3824 let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle;
3825
3826 if self.is_loading_contents {
3827 div()
3828 .id("loading-message-content")
3829 .px_1()
3830 .tooltip(Tooltip::text("Loading Added Context…"))
3831 .child(loading_contents_spinner(IconSize::default()))
3832 .into_any_element()
3833 } else if is_generating && is_editor_empty {
3834 IconButton::new("stop-generation", IconName::Stop)
3835 .icon_color(Color::Error)
3836 .style(ButtonStyle::Tinted(TintColor::Error))
3837 .tooltip(move |_window, cx| {
3838 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx)
3839 })
3840 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3841 .into_any_element()
3842 } else {
3843 let send_icon = if is_generating {
3844 IconName::QueueMessage
3845 } else {
3846 IconName::Send
3847 };
3848 IconButton::new("send-message", send_icon)
3849 .style(ButtonStyle::Filled)
3850 .map(|this| {
3851 if is_editor_empty && !is_generating {
3852 this.disabled(true).icon_color(Color::Muted)
3853 } else {
3854 this.icon_color(Color::Accent)
3855 }
3856 })
3857 .tooltip(move |_window, cx| {
3858 if is_editor_empty && !is_generating {
3859 Tooltip::for_action("Type to Send", &Chat, cx)
3860 } else if is_generating {
3861 let focus_handle = focus_handle.clone();
3862
3863 Tooltip::element(move |_window, cx| {
3864 v_flex()
3865 .gap_1()
3866 .child(
3867 h_flex()
3868 .gap_2()
3869 .justify_between()
3870 .child(Label::new("Queue and Send"))
3871 .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)),
3872 )
3873 .child(
3874 h_flex()
3875 .pt_1()
3876 .gap_2()
3877 .justify_between()
3878 .border_t_1()
3879 .border_color(cx.theme().colors().border_variant)
3880 .child(Label::new("Send Immediately"))
3881 .child(KeyBinding::for_action_in(
3882 &SendImmediately,
3883 &focus_handle,
3884 cx,
3885 )),
3886 )
3887 .into_any_element()
3888 })(_window, cx)
3889 } else {
3890 Tooltip::for_action("Send Message", &Chat, cx)
3891 }
3892 })
3893 .on_click(cx.listener(|this, _, window, cx| {
3894 this.send(window, cx);
3895 }))
3896 .into_any_element()
3897 }
3898 }
3899
3900 fn render_add_context_button(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3901 let focus_handle = self.message_editor.focus_handle(cx);
3902 let weak_self = cx.weak_entity();
3903
3904 PopoverMenu::new("add-context-menu")
3905 .trigger_with_tooltip(
3906 IconButton::new("add-context", IconName::Plus)
3907 .icon_size(IconSize::Small)
3908 .icon_color(Color::Muted),
3909 {
3910 move |_window, cx| {
3911 Tooltip::for_action_in(
3912 "Add Context",
3913 &OpenAddContextMenu,
3914 &focus_handle,
3915 cx,
3916 )
3917 }
3918 },
3919 )
3920 .anchor(Corner::BottomLeft)
3921 .with_handle(self.add_context_menu_handle.clone())
3922 .offset(gpui::Point {
3923 x: px(0.0),
3924 y: px(-2.0),
3925 })
3926 .menu(move |window, cx| {
3927 weak_self
3928 .update(cx, |this, cx| this.build_add_context_menu(window, cx))
3929 .ok()
3930 })
3931 }
3932
3933 fn build_add_context_menu(
3934 &self,
3935 window: &mut Window,
3936 cx: &mut Context<Self>,
3937 ) -> Entity<ContextMenu> {
3938 let message_editor = self.message_editor.clone();
3939 let workspace = self.workspace.clone();
3940 let session_capabilities = self.session_capabilities.read();
3941 let supports_images = session_capabilities.supports_images();
3942 let supports_embedded_context = session_capabilities.supports_embedded_context();
3943
3944 let has_editor_selection = workspace
3945 .upgrade()
3946 .and_then(|ws| {
3947 ws.read(cx)
3948 .active_item(cx)
3949 .and_then(|item| item.downcast::<Editor>())
3950 })
3951 .is_some_and(|editor| {
3952 editor.update(cx, |editor, cx| {
3953 editor.has_non_empty_selection(&editor.display_snapshot(cx))
3954 })
3955 });
3956
3957 let has_terminal_selection = workspace
3958 .upgrade()
3959 .and_then(|ws| ws.read(cx).panel::<TerminalPanel>(cx))
3960 .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty());
3961
3962 let has_selection = has_editor_selection || has_terminal_selection;
3963
3964 ContextMenu::build(window, cx, move |menu, _window, _cx| {
3965 menu.key_context("AddContextMenu")
3966 .header("Context")
3967 .item(
3968 ContextMenuEntry::new("Files & Directories")
3969 .icon(IconName::File)
3970 .icon_color(Color::Muted)
3971 .icon_size(IconSize::XSmall)
3972 .handler({
3973 let message_editor = message_editor.clone();
3974 move |window, cx| {
3975 message_editor.focus_handle(cx).focus(window, cx);
3976 message_editor.update(cx, |editor, cx| {
3977 editor.insert_context_type("file", window, cx);
3978 });
3979 }
3980 }),
3981 )
3982 .item(
3983 ContextMenuEntry::new("Symbols")
3984 .icon(IconName::Code)
3985 .icon_color(Color::Muted)
3986 .icon_size(IconSize::XSmall)
3987 .handler({
3988 let message_editor = message_editor.clone();
3989 move |window, cx| {
3990 message_editor.focus_handle(cx).focus(window, cx);
3991 message_editor.update(cx, |editor, cx| {
3992 editor.insert_context_type("symbol", window, cx);
3993 });
3994 }
3995 }),
3996 )
3997 .item(
3998 ContextMenuEntry::new("Threads")
3999 .icon(IconName::Thread)
4000 .icon_color(Color::Muted)
4001 .icon_size(IconSize::XSmall)
4002 .handler({
4003 let message_editor = message_editor.clone();
4004 move |window, cx| {
4005 message_editor.focus_handle(cx).focus(window, cx);
4006 message_editor.update(cx, |editor, cx| {
4007 editor.insert_context_type("thread", window, cx);
4008 });
4009 }
4010 }),
4011 )
4012 .item(
4013 ContextMenuEntry::new("Rules")
4014 .icon(IconName::Reader)
4015 .icon_color(Color::Muted)
4016 .icon_size(IconSize::XSmall)
4017 .handler({
4018 let message_editor = message_editor.clone();
4019 move |window, cx| {
4020 message_editor.focus_handle(cx).focus(window, cx);
4021 message_editor.update(cx, |editor, cx| {
4022 editor.insert_context_type("rule", window, cx);
4023 });
4024 }
4025 }),
4026 )
4027 .item(
4028 ContextMenuEntry::new("Image")
4029 .icon(IconName::Image)
4030 .icon_color(Color::Muted)
4031 .icon_size(IconSize::XSmall)
4032 .disabled(!supports_images)
4033 .handler({
4034 let message_editor = message_editor.clone();
4035 move |window, cx| {
4036 message_editor.focus_handle(cx).focus(window, cx);
4037 message_editor.update(cx, |editor, cx| {
4038 editor.add_images_from_picker(window, cx);
4039 });
4040 }
4041 }),
4042 )
4043 .item(
4044 ContextMenuEntry::new("Selection")
4045 .icon(IconName::CursorIBeam)
4046 .icon_color(Color::Muted)
4047 .icon_size(IconSize::XSmall)
4048 .disabled(!has_selection)
4049 .handler({
4050 move |window, cx| {
4051 window.dispatch_action(
4052 zed_actions::agent::AddSelectionToThread.boxed_clone(),
4053 cx,
4054 );
4055 }
4056 }),
4057 )
4058 .item(
4059 ContextMenuEntry::new("Branch Diff")
4060 .icon(IconName::GitBranch)
4061 .icon_color(Color::Muted)
4062 .icon_size(IconSize::XSmall)
4063 .disabled(!supports_embedded_context)
4064 .handler({
4065 move |window, cx| {
4066 message_editor.update(cx, |editor, cx| {
4067 editor.insert_branch_diff_crease(window, cx);
4068 });
4069 }
4070 }),
4071 )
4072 })
4073 }
4074
4075 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
4076 let following = self.is_following(cx);
4077
4078 let tooltip_label = if following {
4079 if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
4080 format!("Stop Following the {}", self.agent_id)
4081 } else {
4082 format!("Stop Following {}", self.agent_id)
4083 }
4084 } else {
4085 if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
4086 format!("Follow the {}", self.agent_id)
4087 } else {
4088 format!("Follow {}", self.agent_id)
4089 }
4090 };
4091
4092 IconButton::new("follow-agent", IconName::Crosshair)
4093 .icon_size(IconSize::Small)
4094 .icon_color(Color::Muted)
4095 .toggle_state(following)
4096 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
4097 .tooltip(move |_window, cx| {
4098 if following {
4099 Tooltip::for_action(tooltip_label.clone(), &Follow, cx)
4100 } else {
4101 Tooltip::with_meta(
4102 tooltip_label.clone(),
4103 Some(&Follow),
4104 "Track the agent's location as it reads and edits files.",
4105 cx,
4106 )
4107 }
4108 })
4109 .on_click(cx.listener(move |this, _, window, cx| {
4110 this.toggle_following(window, cx);
4111 }))
4112 }
4113}
4114
4115struct TokenUsageTooltip {
4116 percentage: String,
4117 used: String,
4118 max: String,
4119 input_tokens: String,
4120 output_tokens: String,
4121 input_max: String,
4122 output_max: String,
4123 show_split: bool,
4124 separator_color: Color,
4125 user_rules_count: usize,
4126 first_user_rules_id: Option<uuid::Uuid>,
4127 project_rules_count: usize,
4128 project_entry_ids: Vec<ProjectEntryId>,
4129 workspace: WeakEntity<Workspace>,
4130}
4131
4132impl Render for TokenUsageTooltip {
4133 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4134 let separator_color = self.separator_color;
4135 let percentage = self.percentage.clone();
4136 let used = self.used.clone();
4137 let max = self.max.clone();
4138 let input_tokens = self.input_tokens.clone();
4139 let output_tokens = self.output_tokens.clone();
4140 let input_max = self.input_max.clone();
4141 let output_max = self.output_max.clone();
4142 let show_split = self.show_split;
4143 let user_rules_count = self.user_rules_count;
4144 let first_user_rules_id = self.first_user_rules_id;
4145 let project_rules_count = self.project_rules_count;
4146 let project_entry_ids = self.project_entry_ids.clone();
4147 let workspace = self.workspace.clone();
4148
4149 ui::tooltip_container(cx, move |container, cx| {
4150 container
4151 .min_w_40()
4152 .child(
4153 Label::new("Context")
4154 .color(Color::Muted)
4155 .size(LabelSize::Small),
4156 )
4157 .when(!show_split, |this| {
4158 this.child(
4159 h_flex()
4160 .gap_0p5()
4161 .child(Label::new(percentage.clone()))
4162 .child(Label::new("\u{2022}").color(separator_color).mx_1())
4163 .child(Label::new(used.clone()))
4164 .child(Label::new("/").color(separator_color))
4165 .child(Label::new(max.clone()).color(Color::Muted)),
4166 )
4167 })
4168 .when(show_split, |this| {
4169 this.child(
4170 v_flex()
4171 .gap_0p5()
4172 .child(
4173 h_flex()
4174 .gap_0p5()
4175 .child(Label::new("Input:").color(Color::Muted).mr_0p5())
4176 .child(Label::new(input_tokens))
4177 .child(Label::new("/").color(separator_color))
4178 .child(Label::new(input_max).color(Color::Muted)),
4179 )
4180 .child(
4181 h_flex()
4182 .gap_0p5()
4183 .child(Label::new("Output:").color(Color::Muted).mr_0p5())
4184 .child(Label::new(output_tokens))
4185 .child(Label::new("/").color(separator_color))
4186 .child(Label::new(output_max).color(Color::Muted)),
4187 ),
4188 )
4189 })
4190 .when(
4191 user_rules_count > 0 || project_rules_count > 0,
4192 move |this| {
4193 this.child(
4194 v_flex()
4195 .mt_1p5()
4196 .pt_1p5()
4197 .pb_0p5()
4198 .gap_0p5()
4199 .border_t_1()
4200 .border_color(cx.theme().colors().border_variant)
4201 .child(
4202 Label::new("Rules")
4203 .color(Color::Muted)
4204 .size(LabelSize::Small),
4205 )
4206 .child(
4207 v_flex()
4208 .mx_neg_1()
4209 .when(user_rules_count > 0, move |this| {
4210 this.child(
4211 Button::new(
4212 "open-user-rules",
4213 format!("{} user rules", user_rules_count),
4214 )
4215 .end_icon(
4216 Icon::new(IconName::ArrowUpRight)
4217 .color(Color::Muted)
4218 .size(IconSize::XSmall),
4219 )
4220 .on_click(move |_, window, cx| {
4221 window.dispatch_action(
4222 Box::new(OpenRulesLibrary {
4223 prompt_to_select: first_user_rules_id,
4224 }),
4225 cx,
4226 );
4227 }),
4228 )
4229 })
4230 .when(project_rules_count > 0, move |this| {
4231 let workspace = workspace.clone();
4232 let project_entry_ids = project_entry_ids.clone();
4233 this.child(
4234 Button::new(
4235 "open-project-rules",
4236 format!(
4237 "{} project rules",
4238 project_rules_count
4239 ),
4240 )
4241 .end_icon(
4242 Icon::new(IconName::ArrowUpRight)
4243 .color(Color::Muted)
4244 .size(IconSize::XSmall),
4245 )
4246 .on_click(move |_, window, cx| {
4247 let _ =
4248 workspace.update(cx, |workspace, cx| {
4249 let project =
4250 workspace.project().read(cx);
4251 let paths = project_entry_ids
4252 .iter()
4253 .flat_map(|id| {
4254 project.path_for_entry(*id, cx)
4255 })
4256 .collect::<Vec<_>>();
4257 for path in paths {
4258 workspace
4259 .open_path(
4260 path, None, true, window,
4261 cx,
4262 )
4263 .detach_and_log_err(cx);
4264 }
4265 });
4266 }),
4267 )
4268 }),
4269 ),
4270 )
4271 },
4272 )
4273 })
4274 }
4275}
4276
4277impl ThreadView {
4278 pub(crate) fn render_entries(&mut self, cx: &mut Context<Self>) -> List {
4279 list(
4280 self.list_state.clone(),
4281 cx.processor(|this, index: usize, window, cx| {
4282 let entries = this.thread.read(cx).entries();
4283 if let Some(entry) = entries.get(index) {
4284 this.render_entry(index, entries.len(), entry, window, cx)
4285 } else if this.generating_indicator_in_list {
4286 let confirmation = entries
4287 .last()
4288 .is_some_and(|entry| Self::is_waiting_for_confirmation(entry));
4289 this.render_generating(confirmation, cx).into_any_element()
4290 } else {
4291 Empty.into_any()
4292 }
4293 }),
4294 )
4295 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
4296 .flex_grow()
4297 }
4298
4299 fn render_entry(
4300 &self,
4301 entry_ix: usize,
4302 total_entries: usize,
4303 entry: &AgentThreadEntry,
4304 window: &Window,
4305 cx: &Context<Self>,
4306 ) -> AnyElement {
4307 let is_indented = entry.is_indented();
4308 let is_first_indented = is_indented
4309 && self
4310 .thread
4311 .read(cx)
4312 .entries()
4313 .get(entry_ix.saturating_sub(1))
4314 .is_none_or(|entry| !entry.is_indented());
4315
4316 let primary = match &entry {
4317 AgentThreadEntry::UserMessage(message) => {
4318 let Some(editor) = self
4319 .entry_view_state
4320 .read(cx)
4321 .entry(entry_ix)
4322 .and_then(|entry| entry.message_editor())
4323 .cloned()
4324 else {
4325 return Empty.into_any_element();
4326 };
4327
4328 let editing = self.editing_message == Some(entry_ix);
4329 let editor_focus = editor.focus_handle(cx).is_focused(window);
4330 let focus_border = cx.theme().colors().border_focused;
4331
4332 let has_checkpoint_button = message
4333 .checkpoint
4334 .as_ref()
4335 .is_some_and(|checkpoint| checkpoint.show);
4336
4337 let is_subagent = self.is_subagent();
4338 let is_editable = message.id.is_some() && !is_subagent;
4339 let agent_name = if is_subagent {
4340 "subagents".into()
4341 } else {
4342 self.agent_id.clone()
4343 };
4344
4345 v_flex()
4346 .id(("user_message", entry_ix))
4347 .map(|this| {
4348 if is_first_indented {
4349 this.pt_0p5()
4350 } else {
4351 this.pt_2()
4352 }
4353 })
4354 .pb_3()
4355 .px_2()
4356 .gap_1p5()
4357 .w_full()
4358 .when(is_editable && has_checkpoint_button, |this| {
4359 this.children(message.id.clone().map(|message_id| {
4360 h_flex()
4361 .px_3()
4362 .gap_2()
4363 .child(Divider::horizontal())
4364 .child(
4365 Button::new("restore-checkpoint", "Restore Checkpoint")
4366 .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted))
4367 .label_size(LabelSize::XSmall)
4368 .color(Color::Muted)
4369 .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
4370 .on_click(cx.listener(move |this, _, _window, cx| {
4371 this.restore_checkpoint(&message_id, cx);
4372 }))
4373 )
4374 .child(Divider::horizontal())
4375 }))
4376 })
4377 .child(
4378 div()
4379 .relative()
4380 .child(
4381 div()
4382 .py_3()
4383 .px_2()
4384 .rounded_md()
4385 .bg(cx.theme().colors().editor_background)
4386 .border_1()
4387 .when(is_indented, |this| {
4388 this.py_2().px_2().shadow_sm()
4389 })
4390 .border_color(cx.theme().colors().border)
4391 .map(|this| {
4392 if !is_editable {
4393 if is_subagent {
4394 return this.border_dashed();
4395 }
4396 return this;
4397 }
4398 if editing && editor_focus {
4399 return this.border_color(focus_border);
4400 }
4401 if editing && !editor_focus {
4402 return this.border_dashed()
4403 }
4404 this.shadow_md().hover(|s| {
4405 s.border_color(focus_border.opacity(0.8))
4406 })
4407 })
4408 .text_xs()
4409 .child(editor.clone().into_any_element())
4410 )
4411 .when(editor_focus, |this| {
4412 let base_container = h_flex()
4413 .absolute()
4414 .top_neg_3p5()
4415 .right_3()
4416 .gap_1()
4417 .rounded_sm()
4418 .border_1()
4419 .border_color(cx.theme().colors().border)
4420 .bg(cx.theme().colors().editor_background)
4421 .overflow_hidden();
4422
4423 let is_loading_contents = self.is_loading_contents;
4424 if is_editable {
4425 this.child(
4426 base_container
4427 .child(
4428 IconButton::new("cancel", IconName::Close)
4429 .disabled(is_loading_contents)
4430 .icon_color(Color::Error)
4431 .icon_size(IconSize::XSmall)
4432 .on_click(cx.listener(Self::cancel_editing))
4433 )
4434 .child(
4435 if is_loading_contents {
4436 div()
4437 .id("loading-edited-message-content")
4438 .tooltip(Tooltip::text("Loading Added Context…"))
4439 .child(loading_contents_spinner(IconSize::XSmall))
4440 .into_any_element()
4441 } else {
4442 IconButton::new("regenerate", IconName::Return)
4443 .icon_color(Color::Muted)
4444 .icon_size(IconSize::XSmall)
4445 .tooltip(Tooltip::text(
4446 "Editing will restart the thread from this point."
4447 ))
4448 .on_click(cx.listener({
4449 let editor = editor.clone();
4450 move |this, _, window, cx| {
4451 this.regenerate(
4452 entry_ix, editor.clone(), window, cx,
4453 );
4454 }
4455 })).into_any_element()
4456 }
4457 )
4458 )
4459 } else {
4460 this.child(
4461 base_container
4462 .border_dashed()
4463 .child(IconButton::new("non_editable", IconName::PencilUnavailable)
4464 .icon_size(IconSize::Small)
4465 .icon_color(Color::Muted)
4466 .style(ButtonStyle::Transparent)
4467 .tooltip(Tooltip::element({
4468 let agent_name = agent_name.clone();
4469 move |_, _| {
4470 v_flex()
4471 .gap_1()
4472 .child(Label::new("Unavailable Editing"))
4473 .child(
4474 div().max_w_64().child(
4475 Label::new(format!(
4476 "Editing previous messages is not available for {} yet.",
4477 agent_name
4478 ))
4479 .size(LabelSize::Small)
4480 .color(Color::Muted),
4481 ),
4482 )
4483 .into_any_element()
4484 }
4485 }))),
4486 )
4487 }
4488 }),
4489 )
4490 .into_any()
4491 }
4492 AgentThreadEntry::AssistantMessage(AssistantMessage {
4493 chunks,
4494 indented: _,
4495 is_subagent_output: _,
4496 }) => {
4497 let mut is_blank = true;
4498 let is_last = entry_ix + 1 == total_entries;
4499
4500 let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
4501 let message_body = v_flex()
4502 .w_full()
4503 .gap_3()
4504 .children(chunks.iter().enumerate().filter_map(
4505 |(chunk_ix, chunk)| match chunk {
4506 AssistantMessageChunk::Message { block } => {
4507 block.markdown().and_then(|md| {
4508 let this_is_blank = md.read(cx).source().trim().is_empty();
4509 is_blank = is_blank && this_is_blank;
4510 if this_is_blank {
4511 return None;
4512 }
4513
4514 Some(
4515 self.render_markdown(md.clone(), style.clone())
4516 .into_any_element(),
4517 )
4518 })
4519 }
4520 AssistantMessageChunk::Thought { block } => {
4521 block.markdown().and_then(|md| {
4522 let this_is_blank = md.read(cx).source().trim().is_empty();
4523 is_blank = is_blank && this_is_blank;
4524 if this_is_blank {
4525 return None;
4526 }
4527 Some(
4528 self.render_thinking_block(
4529 entry_ix,
4530 chunk_ix,
4531 md.clone(),
4532 window,
4533 cx,
4534 )
4535 .into_any_element(),
4536 )
4537 })
4538 }
4539 },
4540 ))
4541 .into_any();
4542
4543 if is_blank {
4544 Empty.into_any()
4545 } else {
4546 v_flex()
4547 .px_5()
4548 .py_1p5()
4549 .when(is_last, |this| this.pb_4())
4550 .w_full()
4551 .text_ui(cx)
4552 .child(self.render_message_context_menu(entry_ix, message_body, cx))
4553 .when_some(
4554 self.entry_view_state
4555 .read(cx)
4556 .entry(entry_ix)
4557 .and_then(|entry| entry.focus_handle(cx)),
4558 |this, handle| this.track_focus(&handle),
4559 )
4560 .into_any()
4561 }
4562 }
4563 AgentThreadEntry::ToolCall(tool_call) => self
4564 .render_any_tool_call(
4565 &self.id,
4566 entry_ix,
4567 tool_call,
4568 &self.focus_handle(cx),
4569 false,
4570 window,
4571 cx,
4572 )
4573 .into_any(),
4574 AgentThreadEntry::CompletedPlan(entries) => {
4575 self.render_completed_plan(entries, window, cx)
4576 }
4577 };
4578
4579 let is_subagent_output = self.is_subagent()
4580 && matches!(entry, AgentThreadEntry::AssistantMessage(msg) if msg.is_subagent_output);
4581
4582 let primary = if is_subagent_output {
4583 v_flex()
4584 .w_full()
4585 .child(
4586 h_flex()
4587 .id("subagent_output")
4588 .px_5()
4589 .py_1()
4590 .gap_2()
4591 .child(Divider::horizontal())
4592 .child(
4593 h_flex()
4594 .gap_1()
4595 .child(
4596 Icon::new(IconName::ForwardArrowUp)
4597 .color(Color::Muted)
4598 .size(IconSize::Small),
4599 )
4600 .child(
4601 Label::new("Subagent Output")
4602 .size(LabelSize::Custom(self.tool_name_font_size()))
4603 .color(Color::Muted),
4604 ),
4605 )
4606 .child(Divider::horizontal())
4607 .tooltip(Tooltip::text("Everything below this line was sent as output from this subagent to the main agent.")),
4608 )
4609 .child(primary)
4610 .into_any_element()
4611 } else {
4612 primary
4613 };
4614
4615 let thread = self.thread.clone();
4616
4617 let primary = if is_indented {
4618 let line_top = if is_first_indented {
4619 rems_from_px(-12.0)
4620 } else {
4621 rems_from_px(0.0)
4622 };
4623
4624 div()
4625 .relative()
4626 .w_full()
4627 .pl_5()
4628 .bg(cx.theme().colors().panel_background.opacity(0.2))
4629 .child(
4630 div()
4631 .absolute()
4632 .left(rems_from_px(18.0))
4633 .top(line_top)
4634 .bottom_0()
4635 .w_px()
4636 .bg(cx.theme().colors().border.opacity(0.6)),
4637 )
4638 .child(primary)
4639 .into_any_element()
4640 } else {
4641 primary
4642 };
4643
4644 let needs_confirmation = Self::is_waiting_for_confirmation(entry);
4645
4646 let comments_editor = self.thread_feedback.comments_editor.clone();
4647
4648 let primary = if entry_ix + 1 == total_entries {
4649 v_flex()
4650 .w_full()
4651 .child(primary)
4652 .when(!needs_confirmation, |this| {
4653 this.child(self.render_thread_controls(&thread, cx))
4654 })
4655 .when_some(comments_editor, |this, editor| {
4656 this.child(Self::render_feedback_feedback_editor(editor, cx))
4657 })
4658 .into_any_element()
4659 } else {
4660 primary
4661 };
4662
4663 if let Some(editing_index) = self.editing_message
4664 && editing_index < entry_ix
4665 {
4666 let is_subagent = self.is_subagent();
4667
4668 let backdrop = div()
4669 .id(("backdrop", entry_ix))
4670 .size_full()
4671 .absolute()
4672 .inset_0()
4673 .bg(cx.theme().colors().panel_background)
4674 .opacity(0.8)
4675 .block_mouse_except_scroll()
4676 .on_click(cx.listener(Self::cancel_editing));
4677
4678 div()
4679 .relative()
4680 .child(primary)
4681 .when(!is_subagent, |this| this.child(backdrop))
4682 .into_any_element()
4683 } else {
4684 primary
4685 }
4686 }
4687
4688 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
4689 h_flex()
4690 .key_context("AgentFeedbackMessageEditor")
4691 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4692 this.thread_feedback.dismiss_comments();
4693 cx.notify();
4694 }))
4695 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4696 this.submit_feedback_message(cx);
4697 }))
4698 .p_2()
4699 .mb_2()
4700 .mx_5()
4701 .gap_1()
4702 .rounded_md()
4703 .border_1()
4704 .border_color(cx.theme().colors().border)
4705 .bg(cx.theme().colors().editor_background)
4706 .child(div().w_full().child(editor))
4707 .child(
4708 h_flex()
4709 .child(
4710 IconButton::new("dismiss-feedback-message", IconName::Close)
4711 .icon_color(Color::Error)
4712 .icon_size(IconSize::XSmall)
4713 .shape(ui::IconButtonShape::Square)
4714 .on_click(cx.listener(move |this, _, _window, cx| {
4715 this.thread_feedback.dismiss_comments();
4716 cx.notify();
4717 })),
4718 )
4719 .child(
4720 IconButton::new("submit-feedback-message", IconName::Return)
4721 .icon_size(IconSize::XSmall)
4722 .shape(ui::IconButtonShape::Square)
4723 .on_click(cx.listener(move |this, _, _window, cx| {
4724 this.submit_feedback_message(cx);
4725 })),
4726 ),
4727 )
4728 }
4729
4730 fn render_thread_controls(
4731 &self,
4732 thread: &Entity<AcpThread>,
4733 cx: &Context<Self>,
4734 ) -> impl IntoElement {
4735 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
4736 if is_generating {
4737 return Empty.into_any_element();
4738 }
4739
4740 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4741 .shape(ui::IconButtonShape::Square)
4742 .icon_size(IconSize::Small)
4743 .icon_color(Color::Ignored)
4744 .tooltip(Tooltip::text("Open Thread as Markdown"))
4745 .on_click(cx.listener(move |this, _, window, cx| {
4746 if let Some(workspace) = this.workspace.upgrade() {
4747 this.open_thread_as_markdown(workspace, window, cx)
4748 .detach_and_log_err(cx);
4749 }
4750 }));
4751
4752 let scroll_to_recent_user_prompt =
4753 IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
4754 .shape(ui::IconButtonShape::Square)
4755 .icon_size(IconSize::Small)
4756 .icon_color(Color::Ignored)
4757 .tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
4758 .on_click(cx.listener(move |this, _, _, cx| {
4759 this.scroll_to_most_recent_user_prompt(cx);
4760 }));
4761
4762 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4763 .shape(ui::IconButtonShape::Square)
4764 .icon_size(IconSize::Small)
4765 .icon_color(Color::Ignored)
4766 .tooltip(Tooltip::text("Scroll To Top"))
4767 .on_click(cx.listener(move |this, _, _, cx| {
4768 this.scroll_to_top(cx);
4769 }));
4770
4771 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
4772 let last_turn_clock = show_stats
4773 .then(|| {
4774 self.turn_fields
4775 .last_turn_duration
4776 .filter(|&duration| duration > STOPWATCH_THRESHOLD)
4777 .map(|duration| {
4778 Label::new(duration_alt_display(duration))
4779 .size(LabelSize::Small)
4780 .color(Color::Muted)
4781 })
4782 })
4783 .flatten();
4784
4785 let last_turn_tokens_label = last_turn_clock
4786 .is_some()
4787 .then(|| {
4788 self.turn_fields
4789 .last_turn_tokens
4790 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
4791 .map(|tokens| {
4792 Label::new(format!("{} tokens", crate::humanize_token_count(tokens)))
4793 .size(LabelSize::Small)
4794 .color(Color::Muted)
4795 })
4796 })
4797 .flatten();
4798
4799 let mut container = h_flex()
4800 .w_full()
4801 .py_2()
4802 .px_5()
4803 .gap_px()
4804 .opacity(0.6)
4805 .hover(|s| s.opacity(1.))
4806 .justify_end()
4807 .when(
4808 last_turn_tokens_label.is_some() || last_turn_clock.is_some(),
4809 |this| {
4810 this.child(
4811 h_flex()
4812 .gap_1()
4813 .px_1()
4814 .when_some(last_turn_tokens_label, |this, label| this.child(label))
4815 .when_some(last_turn_clock, |this, label| this.child(label)),
4816 )
4817 },
4818 );
4819
4820 if AgentSettings::get_global(cx).enable_feedback
4821 && self.thread.read(cx).connection().telemetry().is_some()
4822 {
4823 let feedback = self.thread_feedback.feedback;
4824
4825 let tooltip_meta = || {
4826 SharedString::new(
4827 "Rating the thread sends all of your current conversation to the Zed team.",
4828 )
4829 };
4830
4831 container = container
4832 .child(
4833 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4834 .shape(ui::IconButtonShape::Square)
4835 .icon_size(IconSize::Small)
4836 .icon_color(match feedback {
4837 Some(ThreadFeedback::Positive) => Color::Accent,
4838 _ => Color::Ignored,
4839 })
4840 .tooltip(move |window, cx| match feedback {
4841 Some(ThreadFeedback::Positive) => {
4842 Tooltip::text("Thanks for your feedback!")(window, cx)
4843 }
4844 _ => {
4845 Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx)
4846 }
4847 })
4848 .on_click(cx.listener(move |this, _, window, cx| {
4849 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
4850 })),
4851 )
4852 .child(
4853 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4854 .shape(ui::IconButtonShape::Square)
4855 .icon_size(IconSize::Small)
4856 .icon_color(match feedback {
4857 Some(ThreadFeedback::Negative) => Color::Accent,
4858 _ => Color::Ignored,
4859 })
4860 .tooltip(move |window, cx| match feedback {
4861 Some(ThreadFeedback::Negative) => {
4862 Tooltip::text(
4863 "We appreciate your feedback and will use it to improve in the future.",
4864 )(window, cx)
4865 }
4866 _ => {
4867 Tooltip::with_meta(
4868 "Not Helpful Response",
4869 None,
4870 tooltip_meta(),
4871 cx,
4872 )
4873 }
4874 })
4875 .on_click(cx.listener(move |this, _, window, cx| {
4876 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
4877 })),
4878 );
4879 }
4880
4881 if let Some(project) = self.project.upgrade()
4882 && let Some(server_view) = self.server_view.upgrade()
4883 && cx.has_flag::<AgentSharingFeatureFlag>()
4884 && project.read(cx).client().status().borrow().is_connected()
4885 {
4886 let button = if self.is_imported_thread(cx) {
4887 IconButton::new("sync-thread", IconName::ArrowCircle)
4888 .shape(ui::IconButtonShape::Square)
4889 .icon_size(IconSize::Small)
4890 .icon_color(Color::Ignored)
4891 .tooltip(Tooltip::text("Sync with source thread"))
4892 .on_click(cx.listener(move |this, _, window, cx| {
4893 this.sync_thread(project.clone(), server_view.clone(), window, cx);
4894 }))
4895 } else {
4896 IconButton::new("share-thread", IconName::ArrowUpRight)
4897 .shape(ui::IconButtonShape::Square)
4898 .icon_size(IconSize::Small)
4899 .icon_color(Color::Ignored)
4900 .tooltip(Tooltip::text("Share Thread"))
4901 .on_click(cx.listener(move |this, _, window, cx| {
4902 this.share_thread(window, cx);
4903 }))
4904 };
4905
4906 container = container.child(button);
4907 }
4908
4909 container
4910 .child(open_as_markdown)
4911 .child(scroll_to_recent_user_prompt)
4912 .child(scroll_to_top)
4913 .into_any_element()
4914 }
4915
4916 pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
4917 let entries = self.thread.read(cx).entries();
4918 if entries.is_empty() {
4919 return;
4920 }
4921
4922 // Find the most recent user message and scroll it to the top of the viewport.
4923 // (Fallback: if no user message exists, scroll to the bottom.)
4924 if let Some(ix) = entries
4925 .iter()
4926 .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
4927 {
4928 self.list_state.scroll_to(ListOffset {
4929 item_ix: ix,
4930 offset_in_item: px(0.0),
4931 });
4932 cx.notify();
4933 } else {
4934 self.scroll_to_end(cx);
4935 }
4936 }
4937
4938 pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
4939 self.list_state.scroll_to_end();
4940 cx.notify();
4941 }
4942
4943 fn handle_feedback_click(
4944 &mut self,
4945 feedback: ThreadFeedback,
4946 window: &mut Window,
4947 cx: &mut Context<Self>,
4948 ) {
4949 self.thread_feedback
4950 .submit(self.thread.clone(), feedback, window, cx);
4951 cx.notify();
4952 }
4953
4954 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4955 let thread = self.thread.clone();
4956 self.thread_feedback.submit_comments(thread, cx);
4957 cx.notify();
4958 }
4959
4960 pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
4961 self.list_state.scroll_to(ListOffset::default());
4962 cx.notify();
4963 }
4964
4965 fn scroll_output_page_up(
4966 &mut self,
4967 _: &ScrollOutputPageUp,
4968 _window: &mut Window,
4969 cx: &mut Context<Self>,
4970 ) {
4971 let page_height = self.list_state.viewport_bounds().size.height;
4972 self.list_state.scroll_by(-page_height * 0.9);
4973 cx.notify();
4974 }
4975
4976 fn scroll_output_page_down(
4977 &mut self,
4978 _: &ScrollOutputPageDown,
4979 _window: &mut Window,
4980 cx: &mut Context<Self>,
4981 ) {
4982 let page_height = self.list_state.viewport_bounds().size.height;
4983 self.list_state.scroll_by(page_height * 0.9);
4984 cx.notify();
4985 }
4986
4987 fn scroll_output_line_up(
4988 &mut self,
4989 _: &ScrollOutputLineUp,
4990 window: &mut Window,
4991 cx: &mut Context<Self>,
4992 ) {
4993 self.list_state.scroll_by(-window.line_height() * 3.);
4994 cx.notify();
4995 }
4996
4997 fn scroll_output_line_down(
4998 &mut self,
4999 _: &ScrollOutputLineDown,
5000 window: &mut Window,
5001 cx: &mut Context<Self>,
5002 ) {
5003 self.list_state.scroll_by(window.line_height() * 3.);
5004 cx.notify();
5005 }
5006
5007 fn scroll_output_to_top(
5008 &mut self,
5009 _: &ScrollOutputToTop,
5010 _window: &mut Window,
5011 cx: &mut Context<Self>,
5012 ) {
5013 self.scroll_to_top(cx);
5014 }
5015
5016 fn scroll_output_to_bottom(
5017 &mut self,
5018 _: &ScrollOutputToBottom,
5019 _window: &mut Window,
5020 cx: &mut Context<Self>,
5021 ) {
5022 self.scroll_to_end(cx);
5023 }
5024
5025 fn scroll_output_to_previous_message(
5026 &mut self,
5027 _: &ScrollOutputToPreviousMessage,
5028 _window: &mut Window,
5029 cx: &mut Context<Self>,
5030 ) {
5031 let entries = self.thread.read(cx).entries();
5032 let current_ix = self.list_state.logical_scroll_top().item_ix;
5033 if let Some(target_ix) = (0..current_ix)
5034 .rev()
5035 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5036 {
5037 self.list_state.scroll_to(ListOffset {
5038 item_ix: target_ix,
5039 offset_in_item: px(0.),
5040 });
5041 cx.notify();
5042 }
5043 }
5044
5045 fn scroll_output_to_next_message(
5046 &mut self,
5047 _: &ScrollOutputToNextMessage,
5048 _window: &mut Window,
5049 cx: &mut Context<Self>,
5050 ) {
5051 let entries = self.thread.read(cx).entries();
5052 let current_ix = self.list_state.logical_scroll_top().item_ix;
5053 if let Some(target_ix) = (current_ix + 1..entries.len())
5054 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5055 {
5056 self.list_state.scroll_to(ListOffset {
5057 item_ix: target_ix,
5058 offset_in_item: px(0.),
5059 });
5060 cx.notify();
5061 }
5062 }
5063
5064 pub fn open_thread_as_markdown(
5065 &self,
5066 workspace: Entity<Workspace>,
5067 window: &mut Window,
5068 cx: &mut App,
5069 ) -> Task<Result<()>> {
5070 let markdown_language_task = workspace
5071 .read(cx)
5072 .app_state()
5073 .languages
5074 .language_for_name("Markdown");
5075
5076 let thread = self.thread.read(cx);
5077 let thread_title = thread
5078 .title()
5079 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
5080 .to_string();
5081 let markdown = thread.to_markdown(cx);
5082
5083 let project = workspace.read(cx).project().clone();
5084 window.spawn(cx, async move |cx| {
5085 let markdown_language = markdown_language_task.await?;
5086
5087 let buffer = project
5088 .update(cx, |project, cx| {
5089 project.create_buffer(Some(markdown_language), false, cx)
5090 })
5091 .await?;
5092
5093 buffer.update(cx, |buffer, cx| {
5094 buffer.set_text(markdown, cx);
5095 buffer.set_capability(language::Capability::ReadWrite, cx);
5096 });
5097
5098 workspace.update_in(cx, |workspace, window, cx| {
5099 let buffer = cx
5100 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
5101
5102 workspace.add_item_to_active_pane(
5103 Box::new(cx.new(|cx| {
5104 let mut editor =
5105 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5106 editor.set_breadcrumb_header(thread_title);
5107 editor
5108 })),
5109 None,
5110 true,
5111 window,
5112 cx,
5113 );
5114 })?;
5115 anyhow::Ok(())
5116 })
5117 }
5118
5119 pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context<Self>) {
5120 let has_messages = self.list_state.item_count() > 0;
5121 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
5122
5123 let mode = if v2_empty_state {
5124 EditorMode::Full {
5125 scale_ui_elements_with_buffer_font_size: false,
5126 show_active_line_background: false,
5127 sizing_behavior: SizingBehavior::Default,
5128 }
5129 } else {
5130 EditorMode::AutoHeight {
5131 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
5132 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
5133 }
5134 };
5135 self.message_editor.update(cx, |editor, cx| {
5136 editor.set_mode(mode, cx);
5137 });
5138 }
5139
5140 /// Ensures the list item count includes (or excludes) an extra item for the generating indicator
5141 pub(crate) fn sync_generating_indicator(&mut self, cx: &App) {
5142 let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating);
5143
5144 if is_generating && !self.generating_indicator_in_list {
5145 let entries_count = self.thread.read(cx).entries().len();
5146 self.list_state.splice(entries_count..entries_count, 1);
5147 self.generating_indicator_in_list = true;
5148 } else if !is_generating && self.generating_indicator_in_list {
5149 let entries_count = self.thread.read(cx).entries().len();
5150 self.list_state.splice(entries_count..entries_count + 1, 0);
5151 self.generating_indicator_in_list = false;
5152 }
5153 }
5154
5155 fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
5156 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
5157 let elapsed_label = show_stats
5158 .then(|| {
5159 self.turn_fields.turn_started_at.and_then(|started_at| {
5160 let elapsed = started_at.elapsed();
5161 (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
5162 })
5163 })
5164 .flatten();
5165
5166 let is_blocked_on_terminal_command =
5167 !confirmation && self.is_blocked_on_terminal_command(cx);
5168 let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls();
5169
5170 let turn_tokens_label = elapsed_label
5171 .is_some()
5172 .then(|| {
5173 self.turn_fields
5174 .turn_tokens
5175 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
5176 .map(|tokens| crate::humanize_token_count(tokens))
5177 })
5178 .flatten();
5179
5180 let arrow_icon = if is_waiting {
5181 IconName::ArrowUp
5182 } else {
5183 IconName::ArrowDown
5184 };
5185
5186 h_flex()
5187 .id("generating-spinner")
5188 .py_2()
5189 .px(rems_from_px(22.))
5190 .gap_2()
5191 .map(|this| {
5192 if confirmation {
5193 this.child(
5194 h_flex()
5195 .w_2()
5196 .child(SpinnerLabel::sand().size(LabelSize::Small)),
5197 )
5198 .child(
5199 div().min_w(rems(8.)).child(
5200 LoadingLabel::new("Awaiting Confirmation")
5201 .size(LabelSize::Small)
5202 .color(Color::Muted),
5203 ),
5204 )
5205 } else if is_blocked_on_terminal_command {
5206 this
5207 } else {
5208 this.child(SpinnerLabel::new().size(LabelSize::Small))
5209 }
5210 })
5211 .when_some(elapsed_label, |this, elapsed| {
5212 this.child(
5213 Label::new(elapsed)
5214 .size(LabelSize::Small)
5215 .color(Color::Muted),
5216 )
5217 })
5218 .when_some(turn_tokens_label, |this, tokens| {
5219 this.child(
5220 h_flex()
5221 .gap_0p5()
5222 .child(
5223 Icon::new(arrow_icon)
5224 .size(IconSize::XSmall)
5225 .color(Color::Muted),
5226 )
5227 .child(
5228 Label::new(format!("{} tokens", tokens))
5229 .size(LabelSize::Small)
5230 .color(Color::Muted),
5231 ),
5232 )
5233 })
5234 .into_any_element()
5235 }
5236
5237 pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context<Self>) {
5238 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5239
5240 if !matches!(
5241 thinking_display,
5242 ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview
5243 ) {
5244 return;
5245 }
5246
5247 let key = {
5248 let thread = self.thread.read(cx);
5249 if thread.status() != ThreadStatus::Generating {
5250 return;
5251 }
5252 let entries = thread.entries();
5253 let last_ix = entries.len().saturating_sub(1);
5254 match entries.get(last_ix) {
5255 Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() {
5256 Some(AssistantMessageChunk::Thought { .. }) => {
5257 Some((last_ix, msg.chunks.len() - 1))
5258 }
5259 _ => None,
5260 },
5261 _ => None,
5262 }
5263 };
5264
5265 if let Some(key) = key {
5266 if self.auto_expanded_thinking_block != Some(key) {
5267 self.auto_expanded_thinking_block = Some(key);
5268 self.expanded_thinking_blocks.insert(key);
5269 cx.notify();
5270 }
5271 } else if self.auto_expanded_thinking_block.is_some() {
5272 if thinking_display == ThinkingBlockDisplay::Auto {
5273 if let Some(key) = self.auto_expanded_thinking_block {
5274 if !self.user_toggled_thinking_blocks.contains(&key) {
5275 self.expanded_thinking_blocks.remove(&key);
5276 }
5277 }
5278 }
5279 self.auto_expanded_thinking_block = None;
5280 cx.notify();
5281 }
5282 }
5283
5284 pub(crate) fn clear_auto_expand_tracking(&mut self) {
5285 self.auto_expanded_thinking_block = None;
5286 }
5287
5288 fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context<Self>) {
5289 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5290
5291 match thinking_display {
5292 ThinkingBlockDisplay::Auto => {
5293 let is_open = self.expanded_thinking_blocks.contains(&key)
5294 || self.user_toggled_thinking_blocks.contains(&key);
5295
5296 if is_open {
5297 self.expanded_thinking_blocks.remove(&key);
5298 self.user_toggled_thinking_blocks.remove(&key);
5299 } else {
5300 self.expanded_thinking_blocks.insert(key);
5301 self.user_toggled_thinking_blocks.insert(key);
5302 }
5303 }
5304 ThinkingBlockDisplay::Preview => {
5305 let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key);
5306 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5307
5308 if is_user_expanded {
5309 self.user_toggled_thinking_blocks.remove(&key);
5310 self.expanded_thinking_blocks.remove(&key);
5311 } else if is_in_expanded_set {
5312 self.user_toggled_thinking_blocks.insert(key);
5313 } else {
5314 self.expanded_thinking_blocks.insert(key);
5315 self.user_toggled_thinking_blocks.insert(key);
5316 }
5317 }
5318 ThinkingBlockDisplay::AlwaysExpanded => {
5319 if self.user_toggled_thinking_blocks.contains(&key) {
5320 self.user_toggled_thinking_blocks.remove(&key);
5321 } else {
5322 self.user_toggled_thinking_blocks.insert(key);
5323 }
5324 }
5325 ThinkingBlockDisplay::AlwaysCollapsed => {
5326 if self.user_toggled_thinking_blocks.contains(&key) {
5327 self.user_toggled_thinking_blocks.remove(&key);
5328 self.expanded_thinking_blocks.remove(&key);
5329 } else {
5330 self.expanded_thinking_blocks.insert(key);
5331 self.user_toggled_thinking_blocks.insert(key);
5332 }
5333 }
5334 }
5335
5336 cx.notify();
5337 }
5338
5339 fn render_thinking_block(
5340 &self,
5341 entry_ix: usize,
5342 chunk_ix: usize,
5343 chunk: Entity<Markdown>,
5344 window: &Window,
5345 cx: &Context<Self>,
5346 ) -> AnyElement {
5347 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
5348 let card_header_id = SharedString::from("inner-card-header");
5349
5350 let key = (entry_ix, chunk_ix);
5351
5352 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5353 let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key);
5354 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5355
5356 let (is_open, is_constrained) = match thinking_display {
5357 ThinkingBlockDisplay::Auto => {
5358 let is_open = is_user_toggled || is_in_expanded_set;
5359 (is_open, false)
5360 }
5361 ThinkingBlockDisplay::Preview => {
5362 let is_open = is_user_toggled || is_in_expanded_set;
5363 let is_constrained = is_in_expanded_set && !is_user_toggled;
5364 (is_open, is_constrained)
5365 }
5366 ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false),
5367 ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false),
5368 };
5369
5370 let should_auto_scroll = self.auto_expanded_thinking_block == Some(key);
5371
5372 let scroll_handle = self
5373 .entry_view_state
5374 .read(cx)
5375 .entry(entry_ix)
5376 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
5377
5378 if should_auto_scroll {
5379 if let Some(ref handle) = scroll_handle {
5380 handle.scroll_to_bottom();
5381 }
5382 }
5383
5384 let panel_bg = cx.theme().colors().panel_background;
5385
5386 v_flex()
5387 .gap_1()
5388 .child(
5389 h_flex()
5390 .id(header_id)
5391 .group(&card_header_id)
5392 .relative()
5393 .w_full()
5394 .pr_1()
5395 .justify_between()
5396 .child(
5397 h_flex()
5398 .h(window.line_height() - px(2.))
5399 .gap_1p5()
5400 .overflow_hidden()
5401 .child(
5402 Icon::new(IconName::ToolThink)
5403 .size(IconSize::Small)
5404 .color(Color::Muted),
5405 )
5406 .child(
5407 div()
5408 .text_size(self.tool_name_font_size())
5409 .text_color(cx.theme().colors().text_muted)
5410 .child("Thinking"),
5411 ),
5412 )
5413 .child(
5414 Disclosure::new(("expand", entry_ix), is_open)
5415 .opened_icon(IconName::ChevronUp)
5416 .closed_icon(IconName::ChevronDown)
5417 .visible_on_hover(&card_header_id)
5418 .on_click(cx.listener(
5419 move |this, _event: &ClickEvent, _window, cx| {
5420 this.toggle_thinking_block_expansion(key, cx);
5421 },
5422 )),
5423 )
5424 .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
5425 this.toggle_thinking_block_expansion(key, cx);
5426 })),
5427 )
5428 .when(is_open, |this| {
5429 this.child(
5430 div()
5431 .when(is_constrained, |this| this.relative())
5432 .child(
5433 div()
5434 .id(("thinking-content", chunk_ix))
5435 .ml_1p5()
5436 .pl_3p5()
5437 .border_l_1()
5438 .border_color(self.tool_card_border_color(cx))
5439 .when(is_constrained, |this| this.max_h_64())
5440 .when_some(scroll_handle, |this, scroll_handle| {
5441 this.track_scroll(&scroll_handle)
5442 })
5443 .overflow_hidden()
5444 .child(self.render_markdown(
5445 chunk,
5446 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
5447 )),
5448 )
5449 .when(is_constrained, |this| {
5450 this.child(
5451 div()
5452 .absolute()
5453 .inset_0()
5454 .size_full()
5455 .bg(linear_gradient(
5456 180.,
5457 linear_color_stop(panel_bg.opacity(0.8), 0.),
5458 linear_color_stop(panel_bg.opacity(0.), 0.1),
5459 ))
5460 .block_mouse_except_scroll(),
5461 )
5462 }),
5463 )
5464 })
5465 .into_any_element()
5466 }
5467
5468 fn render_message_context_menu(
5469 &self,
5470 entry_ix: usize,
5471 message_body: AnyElement,
5472 cx: &Context<Self>,
5473 ) -> AnyElement {
5474 let entity = cx.entity();
5475 let workspace = self.workspace.clone();
5476
5477 right_click_menu(format!("agent_context_menu-{}", entry_ix))
5478 .trigger(move |_, _, _| message_body)
5479 .menu(move |window, cx| {
5480 let focus = window.focused(cx);
5481 let entity = entity.clone();
5482 let workspace = workspace.clone();
5483
5484 ContextMenu::build(window, cx, move |menu, _, cx| {
5485 let this = entity.read(cx);
5486 let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
5487
5488 let has_selection = this
5489 .thread
5490 .read(cx)
5491 .entries()
5492 .get(entry_ix)
5493 .and_then(|entry| match &entry {
5494 AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
5495 _ => None,
5496 })
5497 .map(|chunks| {
5498 chunks.iter().any(|chunk| {
5499 let md = match chunk {
5500 AssistantMessageChunk::Message { block } => block.markdown(),
5501 AssistantMessageChunk::Thought { block } => block.markdown(),
5502 };
5503 md.map_or(false, |m| m.read(cx).selected_text().is_some())
5504 })
5505 })
5506 .unwrap_or(false);
5507
5508 let copy_this_agent_response =
5509 ContextMenuEntry::new("Copy This Agent Response").handler({
5510 let entity = entity.clone();
5511 move |_, cx| {
5512 entity.update(cx, |this, cx| {
5513 let entries = this.thread.read(cx).entries();
5514 if let Some(text) =
5515 Self::get_agent_message_content(entries, entry_ix, cx)
5516 {
5517 cx.write_to_clipboard(ClipboardItem::new_string(text));
5518 }
5519 });
5520 }
5521 });
5522
5523 let scroll_item = if is_at_top {
5524 ContextMenuEntry::new("Scroll to Bottom").handler({
5525 let entity = entity.clone();
5526 move |_, cx| {
5527 entity.update(cx, |this, cx| {
5528 this.scroll_to_end(cx);
5529 });
5530 }
5531 })
5532 } else {
5533 ContextMenuEntry::new("Scroll to Top").handler({
5534 let entity = entity.clone();
5535 move |_, cx| {
5536 entity.update(cx, |this, cx| {
5537 this.scroll_to_top(cx);
5538 });
5539 }
5540 })
5541 };
5542
5543 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
5544 .handler({
5545 let entity = entity.clone();
5546 let workspace = workspace.clone();
5547 move |window, cx| {
5548 if let Some(workspace) = workspace.upgrade() {
5549 entity
5550 .update(cx, |this, cx| {
5551 this.open_thread_as_markdown(workspace, window, cx)
5552 })
5553 .detach_and_log_err(cx);
5554 }
5555 }
5556 });
5557
5558 menu.when_some(focus, |menu, focus| menu.context(focus))
5559 .action_disabled_when(
5560 !has_selection,
5561 "Copy Selection",
5562 Box::new(markdown::CopyAsMarkdown),
5563 )
5564 .item(copy_this_agent_response)
5565 .separator()
5566 .item(scroll_item)
5567 .item(open_thread_as_markdown)
5568 })
5569 })
5570 .into_any_element()
5571 }
5572
5573 fn get_agent_message_content(
5574 entries: &[AgentThreadEntry],
5575 entry_index: usize,
5576 cx: &App,
5577 ) -> Option<String> {
5578 let entry = entries.get(entry_index)?;
5579 if matches!(entry, AgentThreadEntry::UserMessage(_)) {
5580 return None;
5581 }
5582
5583 let start_index = (0..entry_index)
5584 .rev()
5585 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5586 .map(|i| i + 1)
5587 .unwrap_or(0);
5588
5589 let end_index = (entry_index + 1..entries.len())
5590 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5591 .map(|i| i - 1)
5592 .unwrap_or(entries.len() - 1);
5593
5594 let parts: Vec<String> = (start_index..=end_index)
5595 .filter_map(|i| entries.get(i))
5596 .filter_map(|entry| {
5597 if let AgentThreadEntry::AssistantMessage(message) = entry {
5598 let text: String = message
5599 .chunks
5600 .iter()
5601 .filter_map(|chunk| match chunk {
5602 AssistantMessageChunk::Message { block } => {
5603 let markdown = block.to_markdown(cx);
5604 if markdown.trim().is_empty() {
5605 None
5606 } else {
5607 Some(markdown.to_string())
5608 }
5609 }
5610 AssistantMessageChunk::Thought { .. } => None,
5611 })
5612 .collect::<Vec<_>>()
5613 .join("\n\n");
5614
5615 if text.is_empty() { None } else { Some(text) }
5616 } else {
5617 None
5618 }
5619 })
5620 .collect();
5621
5622 let text = parts.join("\n\n");
5623 if text.is_empty() { None } else { Some(text) }
5624 }
5625
5626 fn is_blocked_on_terminal_command(&self, cx: &App) -> bool {
5627 let thread = self.thread.read(cx);
5628 if !matches!(thread.status(), ThreadStatus::Generating) {
5629 return false;
5630 }
5631
5632 let mut has_running_terminal_call = false;
5633
5634 for entry in thread.entries().iter().rev() {
5635 match entry {
5636 AgentThreadEntry::UserMessage(_) => break,
5637 AgentThreadEntry::ToolCall(tool_call)
5638 if matches!(
5639 tool_call.status,
5640 ToolCallStatus::InProgress | ToolCallStatus::Pending
5641 ) =>
5642 {
5643 if matches!(tool_call.kind, acp::ToolKind::Execute) {
5644 has_running_terminal_call = true;
5645 } else {
5646 return false;
5647 }
5648 }
5649 AgentThreadEntry::ToolCall(_)
5650 | AgentThreadEntry::AssistantMessage(_)
5651 | AgentThreadEntry::CompletedPlan(_) => {}
5652 }
5653 }
5654
5655 has_running_terminal_call
5656 }
5657
5658 fn render_collapsible_command(
5659 &self,
5660 group: SharedString,
5661 is_preview: bool,
5662 command_source: &str,
5663 cx: &Context<Self>,
5664 ) -> Div {
5665 v_flex()
5666 .group(group.clone())
5667 .p_1p5()
5668 .bg(self.tool_card_header_bg(cx))
5669 .when(is_preview, |this| {
5670 this.pt_1().child(
5671 // Wrapping this label on a container with 24px height to avoid
5672 // layout shift when it changes from being a preview label
5673 // to the actual path where the command will run in
5674 h_flex().h_6().child(
5675 Label::new("Run Command")
5676 .buffer_font(cx)
5677 .size(LabelSize::XSmall)
5678 .color(Color::Muted),
5679 ),
5680 )
5681 })
5682 .children(command_source.lines().map(|line| {
5683 let text: SharedString = if line.is_empty() {
5684 " ".into()
5685 } else {
5686 line.to_string().into()
5687 };
5688
5689 Label::new(text).buffer_font(cx).size(LabelSize::Small)
5690 }))
5691 .child(
5692 div().absolute().top_1().right_1().child(
5693 CopyButton::new("copy-command", command_source.to_string())
5694 .tooltip_label("Copy Command")
5695 .visible_on_hover(group),
5696 ),
5697 )
5698 }
5699
5700 fn render_terminal_tool_call(
5701 &self,
5702 active_session_id: &acp::SessionId,
5703 entry_ix: usize,
5704 terminal: &Entity<acp_thread::Terminal>,
5705 tool_call: &ToolCall,
5706 focus_handle: &FocusHandle,
5707 is_subagent: bool,
5708 window: &Window,
5709 cx: &Context<Self>,
5710 ) -> AnyElement {
5711 let terminal_data = terminal.read(cx);
5712 let working_dir = terminal_data.working_dir();
5713 let command = terminal_data.command();
5714 let started_at = terminal_data.started_at();
5715
5716 let tool_failed = matches!(
5717 &tool_call.status,
5718 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
5719 );
5720
5721 let confirmation_options = match &tool_call.status {
5722 ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options),
5723 _ => None,
5724 };
5725 let needs_confirmation = confirmation_options.is_some();
5726
5727 let output = terminal_data.output();
5728 let command_finished = output.is_some()
5729 && !matches!(
5730 tool_call.status,
5731 ToolCallStatus::InProgress | ToolCallStatus::Pending
5732 );
5733 let truncated_output =
5734 output.is_some_and(|output| output.original_content_len > output.content.len());
5735 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
5736
5737 let command_failed = command_finished
5738 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
5739
5740 let time_elapsed = if let Some(output) = output {
5741 output.ended_at.duration_since(started_at)
5742 } else {
5743 started_at.elapsed()
5744 };
5745
5746 let header_id =
5747 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
5748 let header_group = SharedString::from(format!(
5749 "terminal-tool-header-group-{}",
5750 terminal.entity_id()
5751 ));
5752 let header_bg = cx
5753 .theme()
5754 .colors()
5755 .element_background
5756 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
5757 let border_color = cx.theme().colors().border.opacity(0.6);
5758
5759 let working_dir = working_dir
5760 .as_ref()
5761 .map(|path| path.display().to_string())
5762 .unwrap_or_else(|| "current directory".to_string());
5763
5764 // Since the command's source is wrapped in a markdown code block
5765 // (```\n...\n```), we need to strip that so we're left with only the
5766 // command's content.
5767 let command_source = command.read(cx).source();
5768 let command_content = command_source
5769 .strip_prefix("```\n")
5770 .and_then(|s| s.strip_suffix("\n```"))
5771 .unwrap_or(&command_source);
5772
5773 let command_element =
5774 self.render_collapsible_command(header_group.clone(), false, command_content, cx);
5775
5776 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
5777
5778 let header = h_flex()
5779 .id(header_id)
5780 .pt_1()
5781 .pl_1p5()
5782 .pr_1()
5783 .flex_none()
5784 .gap_1()
5785 .justify_between()
5786 .rounded_t_md()
5787 .child(
5788 div()
5789 .id(("command-target-path", terminal.entity_id()))
5790 .w_full()
5791 .max_w_full()
5792 .overflow_x_scroll()
5793 .child(
5794 Label::new(working_dir)
5795 .buffer_font(cx)
5796 .size(LabelSize::XSmall)
5797 .color(Color::Muted),
5798 ),
5799 )
5800 .child(
5801 Disclosure::new(
5802 SharedString::from(format!(
5803 "terminal-tool-disclosure-{}",
5804 terminal.entity_id()
5805 )),
5806 is_expanded,
5807 )
5808 .opened_icon(IconName::ChevronUp)
5809 .closed_icon(IconName::ChevronDown)
5810 .visible_on_hover(&header_group)
5811 .on_click(cx.listener({
5812 let id = tool_call.id.clone();
5813 move |this, _event, _window, cx| {
5814 if is_expanded {
5815 this.expanded_tool_calls.remove(&id);
5816 } else {
5817 this.expanded_tool_calls.insert(id.clone());
5818 }
5819 cx.notify();
5820 }
5821 })),
5822 )
5823 .when(time_elapsed > Duration::from_secs(10), |header| {
5824 header.child(
5825 Label::new(format!("({})", duration_alt_display(time_elapsed)))
5826 .buffer_font(cx)
5827 .color(Color::Muted)
5828 .size(LabelSize::XSmall),
5829 )
5830 })
5831 .when(!command_finished && !needs_confirmation, |header| {
5832 header
5833 .gap_1p5()
5834 .child(
5835 Icon::new(IconName::ArrowCircle)
5836 .size(IconSize::XSmall)
5837 .color(Color::Muted)
5838 .with_rotate_animation(2)
5839 )
5840 .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border)))
5841 .child(
5842 IconButton::new(
5843 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
5844 IconName::Stop
5845 )
5846 .icon_size(IconSize::Small)
5847 .icon_color(Color::Error)
5848 .tooltip(move |_window, cx| {
5849 Tooltip::with_meta(
5850 "Stop This Command",
5851 None,
5852 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
5853 cx,
5854 )
5855 })
5856 .on_click({
5857 let terminal = terminal.clone();
5858 cx.listener(move |this, _event, _window, cx| {
5859 terminal.update(cx, |terminal, cx| {
5860 terminal.stop_by_user(cx);
5861 });
5862 if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
5863 this.cancel_generation(cx);
5864 }
5865 })
5866 }),
5867 )
5868 })
5869 .when(truncated_output, |header| {
5870 let tooltip = if let Some(output) = output {
5871 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
5872 format!("Output exceeded terminal max lines and was \
5873 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
5874 } else {
5875 format!(
5876 "Output is {} long, and to avoid unexpected token usage, \
5877 only {} was sent back to the agent.",
5878 format_file_size(output.original_content_len as u64, true),
5879 format_file_size(output.content.len() as u64, true)
5880 )
5881 }
5882 } else {
5883 "Output was truncated".to_string()
5884 };
5885
5886 header.child(
5887 h_flex()
5888 .id(("terminal-tool-truncated-label", terminal.entity_id()))
5889 .gap_1()
5890 .child(
5891 Icon::new(IconName::Info)
5892 .size(IconSize::XSmall)
5893 .color(Color::Ignored),
5894 )
5895 .child(
5896 Label::new("Truncated")
5897 .color(Color::Muted)
5898 .size(LabelSize::XSmall),
5899 )
5900 .tooltip(Tooltip::text(tooltip)),
5901 )
5902 })
5903 .when(tool_failed || command_failed, |header| {
5904 header.child(
5905 div()
5906 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
5907 .child(
5908 Icon::new(IconName::Close)
5909 .size(IconSize::Small)
5910 .color(Color::Error),
5911 )
5912 .when_some(output.and_then(|o| o.exit_status), |this, status| {
5913 this.tooltip(Tooltip::text(format!(
5914 "Exited with code {}",
5915 status.code().unwrap_or(-1),
5916 )))
5917 }),
5918 )
5919 })
5920;
5921
5922 let terminal_view = self
5923 .entry_view_state
5924 .read(cx)
5925 .entry(entry_ix)
5926 .and_then(|entry| entry.terminal(terminal));
5927
5928 v_flex()
5929 .when(!is_subagent, |this| {
5930 this.my_1p5()
5931 .mx_5()
5932 .border_1()
5933 .when(tool_failed || command_failed, |card| card.border_dashed())
5934 .border_color(border_color)
5935 .rounded_md()
5936 })
5937 .overflow_hidden()
5938 .child(
5939 v_flex()
5940 .group(&header_group)
5941 .bg(header_bg)
5942 .text_xs()
5943 .child(header)
5944 .child(command_element),
5945 )
5946 .when(is_expanded && terminal_view.is_some(), |this| {
5947 this.child(
5948 div()
5949 .pt_2()
5950 .border_t_1()
5951 .when(tool_failed || command_failed, |card| card.border_dashed())
5952 .border_color(border_color)
5953 .bg(cx.theme().colors().editor_background)
5954 .rounded_b_md()
5955 .text_ui_sm(cx)
5956 .h_full()
5957 .children(terminal_view.map(|terminal_view| {
5958 let element = if terminal_view
5959 .read(cx)
5960 .content_mode(window, cx)
5961 .is_scrollable()
5962 {
5963 div().h_72().child(terminal_view).into_any_element()
5964 } else {
5965 terminal_view.into_any_element()
5966 };
5967
5968 div()
5969 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
5970 window.dispatch_action(NewThread.boxed_clone(), cx);
5971 cx.stop_propagation();
5972 }))
5973 .child(element)
5974 .into_any_element()
5975 })),
5976 )
5977 })
5978 .when_some(confirmation_options, |this, options| {
5979 let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
5980 this.child(self.render_permission_buttons(
5981 self.id.clone(),
5982 is_first,
5983 options,
5984 entry_ix,
5985 tool_call.id.clone(),
5986 focus_handle,
5987 cx,
5988 ))
5989 })
5990 .into_any()
5991 }
5992
5993 fn is_first_tool_call(
5994 &self,
5995 active_session_id: &acp::SessionId,
5996 tool_call_id: &acp::ToolCallId,
5997 cx: &App,
5998 ) -> bool {
5999 self.conversation
6000 .read(cx)
6001 .pending_tool_call(active_session_id, cx)
6002 .map_or(false, |(pending_session_id, pending_tool_call_id, _)| {
6003 self.id == pending_session_id && tool_call_id == &pending_tool_call_id
6004 })
6005 }
6006
6007 fn render_any_tool_call(
6008 &self,
6009 active_session_id: &acp::SessionId,
6010 entry_ix: usize,
6011 tool_call: &ToolCall,
6012 focus_handle: &FocusHandle,
6013 is_subagent: bool,
6014 window: &Window,
6015 cx: &Context<Self>,
6016 ) -> Div {
6017 let has_terminals = tool_call.terminals().next().is_some();
6018
6019 div().w_full().map(|this| {
6020 if tool_call.is_subagent() {
6021 this.child(
6022 self.render_subagent_tool_call(
6023 active_session_id,
6024 entry_ix,
6025 tool_call,
6026 tool_call
6027 .subagent_session_info
6028 .as_ref()
6029 .map(|i| i.session_id.clone()),
6030 focus_handle,
6031 window,
6032 cx,
6033 ),
6034 )
6035 } else if has_terminals {
6036 this.children(tool_call.terminals().map(|terminal| {
6037 self.render_terminal_tool_call(
6038 active_session_id,
6039 entry_ix,
6040 terminal,
6041 tool_call,
6042 focus_handle,
6043 is_subagent,
6044 window,
6045 cx,
6046 )
6047 }))
6048 } else {
6049 this.child(self.render_tool_call(
6050 active_session_id,
6051 entry_ix,
6052 tool_call,
6053 focus_handle,
6054 is_subagent,
6055 window,
6056 cx,
6057 ))
6058 }
6059 })
6060 }
6061
6062 fn render_tool_call(
6063 &self,
6064 active_session_id: &acp::SessionId,
6065 entry_ix: usize,
6066 tool_call: &ToolCall,
6067 focus_handle: &FocusHandle,
6068 is_subagent: bool,
6069 window: &Window,
6070 cx: &Context<Self>,
6071 ) -> Div {
6072 let has_location = tool_call.locations.len() == 1;
6073 let card_header_id = SharedString::from("inner-tool-call-header");
6074
6075 let failed_or_canceled = match &tool_call.status {
6076 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
6077 _ => false,
6078 };
6079
6080 let needs_confirmation = matches!(
6081 tool_call.status,
6082 ToolCallStatus::WaitingForConfirmation { .. }
6083 );
6084 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
6085
6086 let is_edit =
6087 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
6088
6089 let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
6090 let (has_revealed_diff, tool_call_output_focus, tool_call_output_focus_handle) = tool_call
6091 .diffs()
6092 .next()
6093 .and_then(|diff| {
6094 let editor = self
6095 .entry_view_state
6096 .read(cx)
6097 .entry(entry_ix)
6098 .and_then(|entry| entry.editor_for_diff(diff))?;
6099 let has_revealed_diff = diff.read(cx).has_revealed_range(cx);
6100 let has_focus = editor.read(cx).is_focused(window);
6101 let focus_handle = editor.focus_handle(cx);
6102 Some((has_revealed_diff, has_focus, focus_handle))
6103 })
6104 .unwrap_or_else(|| (false, false, focus_handle.clone()));
6105
6106 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
6107
6108 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
6109 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
6110 let mut is_open = self.expanded_tool_calls.contains(&tool_call.id);
6111
6112 is_open |= needs_confirmation;
6113
6114 let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
6115
6116 let input_output_header = |label: SharedString| {
6117 Label::new(label)
6118 .size(LabelSize::XSmall)
6119 .color(Color::Muted)
6120 .buffer_font(cx)
6121 };
6122
6123 let tool_output_display = if is_open {
6124 match &tool_call.status {
6125 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
6126 .w_full()
6127 .children(
6128 tool_call
6129 .content
6130 .iter()
6131 .enumerate()
6132 .map(|(content_ix, content)| {
6133 div()
6134 .child(self.render_tool_call_content(
6135 active_session_id,
6136 entry_ix,
6137 content,
6138 content_ix,
6139 tool_call,
6140 use_card_layout,
6141 has_image_content,
6142 failed_or_canceled,
6143 focus_handle,
6144 window,
6145 cx,
6146 ))
6147 .into_any_element()
6148 }),
6149 )
6150 .when(should_show_raw_input, |this| {
6151 let is_raw_input_expanded =
6152 self.expanded_tool_call_raw_inputs.contains(&tool_call.id);
6153
6154 let input_header = if is_raw_input_expanded {
6155 "Raw Input:"
6156 } else {
6157 "View Raw Input"
6158 };
6159
6160 this.child(
6161 v_flex()
6162 .p_2()
6163 .gap_1()
6164 .border_t_1()
6165 .border_color(self.tool_card_border_color(cx))
6166 .child(
6167 h_flex()
6168 .id("disclosure_container")
6169 .pl_0p5()
6170 .gap_1()
6171 .justify_between()
6172 .rounded_xs()
6173 .hover(|s| s.bg(cx.theme().colors().element_hover))
6174 .child(input_output_header(input_header.into()))
6175 .child(
6176 Disclosure::new(
6177 ("raw-input-disclosure", entry_ix),
6178 is_raw_input_expanded,
6179 )
6180 .opened_icon(IconName::ChevronUp)
6181 .closed_icon(IconName::ChevronDown),
6182 )
6183 .on_click(cx.listener({
6184 let id = tool_call.id.clone();
6185
6186 move |this: &mut Self, _, _, cx| {
6187 if this.expanded_tool_call_raw_inputs.contains(&id)
6188 {
6189 this.expanded_tool_call_raw_inputs.remove(&id);
6190 } else {
6191 this.expanded_tool_call_raw_inputs
6192 .insert(id.clone());
6193 }
6194 cx.notify();
6195 }
6196 })),
6197 )
6198 .when(is_raw_input_expanded, |this| {
6199 this.children(tool_call.raw_input_markdown.clone().map(
6200 |input| {
6201 self.render_markdown(
6202 input,
6203 MarkdownStyle::themed(
6204 MarkdownFont::Agent,
6205 window,
6206 cx,
6207 ),
6208 )
6209 },
6210 ))
6211 }),
6212 )
6213 })
6214 .child(self.render_permission_buttons(
6215 self.id.clone(),
6216 self.is_first_tool_call(active_session_id, &tool_call.id, cx),
6217 options,
6218 entry_ix,
6219 tool_call.id.clone(),
6220 focus_handle,
6221 cx,
6222 ))
6223 .into_any(),
6224 ToolCallStatus::Pending | ToolCallStatus::InProgress
6225 if is_edit
6226 && tool_call.content.is_empty()
6227 && self.as_native_connection(cx).is_some() =>
6228 {
6229 self.render_diff_loading(cx)
6230 }
6231 ToolCallStatus::Pending
6232 | ToolCallStatus::InProgress
6233 | ToolCallStatus::Completed
6234 | ToolCallStatus::Failed
6235 | ToolCallStatus::Canceled => v_flex()
6236 .when(should_show_raw_input, |this| {
6237 this.mt_1p5().w_full().child(
6238 v_flex()
6239 .ml(rems(0.4))
6240 .px_3p5()
6241 .pb_1()
6242 .gap_1()
6243 .border_l_1()
6244 .border_color(self.tool_card_border_color(cx))
6245 .child(input_output_header("Raw Input:".into()))
6246 .children(tool_call.raw_input_markdown.clone().map(|input| {
6247 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
6248 self.render_markdown(
6249 input,
6250 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
6251 ),
6252 )
6253 }))
6254 .child(input_output_header("Output:".into())),
6255 )
6256 })
6257 .children(
6258 tool_call
6259 .content
6260 .iter()
6261 .enumerate()
6262 .map(|(content_ix, content)| {
6263 div().id(("tool-call-output", entry_ix)).child(
6264 self.render_tool_call_content(
6265 active_session_id,
6266 entry_ix,
6267 content,
6268 content_ix,
6269 tool_call,
6270 use_card_layout,
6271 has_image_content,
6272 failed_or_canceled,
6273 focus_handle,
6274 window,
6275 cx,
6276 ),
6277 )
6278 }),
6279 )
6280 .into_any(),
6281 ToolCallStatus::Rejected => Empty.into_any(),
6282 }
6283 .into()
6284 } else {
6285 None
6286 };
6287
6288 v_flex()
6289 .map(|this| {
6290 if is_subagent {
6291 this
6292 } else if use_card_layout {
6293 this.my_1p5()
6294 .rounded_md()
6295 .border_1()
6296 .when(failed_or_canceled, |this| this.border_dashed())
6297 .border_color(self.tool_card_border_color(cx))
6298 .bg(cx.theme().colors().editor_background)
6299 .overflow_hidden()
6300 } else {
6301 this.my_1()
6302 }
6303 })
6304 .when(!is_subagent, |this| {
6305 this.map(|this| {
6306 if has_location && !use_card_layout {
6307 this.ml_4()
6308 } else {
6309 this.ml_5()
6310 }
6311 })
6312 .mr_5()
6313 })
6314 .map(|this| {
6315 if is_terminal_tool {
6316 let label_source = tool_call.label.read(cx).source();
6317 this.child(self.render_collapsible_command(
6318 card_header_id.clone(),
6319 true,
6320 label_source,
6321 cx,
6322 ))
6323 } else {
6324 this.child(
6325 h_flex()
6326 .group(&card_header_id)
6327 .relative()
6328 .w_full()
6329 .justify_between()
6330 .when(use_card_layout, |this| {
6331 this.p_0p5()
6332 .rounded_t(rems_from_px(5.))
6333 .bg(self.tool_card_header_bg(cx))
6334 })
6335 .child(self.render_tool_call_label(
6336 entry_ix,
6337 tool_call,
6338 is_edit,
6339 is_cancelled_edit,
6340 has_revealed_diff,
6341 use_card_layout,
6342 window,
6343 cx,
6344 ))
6345 .child(
6346 h_flex()
6347 .when(is_collapsible || failed_or_canceled, |this| {
6348 let diff_for_discard = if has_revealed_diff
6349 && is_cancelled_edit
6350 && cx.has_flag::<AgentV2FeatureFlag>()
6351 {
6352 tool_call.diffs().next().cloned()
6353 } else {
6354 None
6355 };
6356
6357 this.child(
6358 h_flex()
6359 .pr_0p5()
6360 .gap_1()
6361 .when(is_collapsible, |this| {
6362 this.child(
6363 Disclosure::new(
6364 ("expand-output", entry_ix),
6365 is_open,
6366 )
6367 .opened_icon(IconName::ChevronUp)
6368 .closed_icon(IconName::ChevronDown)
6369 .visible_on_hover(&card_header_id)
6370 .on_click(cx.listener({
6371 let id = tool_call.id.clone();
6372 move |this: &mut Self,
6373 _,
6374 _,
6375 cx: &mut Context<Self>| {
6376 if is_open {
6377 this.expanded_tool_calls
6378 .remove(&id);
6379 } else {
6380 this.expanded_tool_calls
6381 .insert(id.clone());
6382 }
6383 cx.notify();
6384 }
6385 })),
6386 )
6387 })
6388 .when(failed_or_canceled, |this| {
6389 if is_cancelled_edit && !has_revealed_diff {
6390 this.child(
6391 div()
6392 .id(entry_ix)
6393 .tooltip(Tooltip::text(
6394 "Interrupted Edit",
6395 ))
6396 .child(
6397 Icon::new(IconName::XCircle)
6398 .color(Color::Muted)
6399 .size(IconSize::Small),
6400 ),
6401 )
6402 } else if is_cancelled_edit {
6403 this
6404 } else {
6405 this.child(
6406 Icon::new(IconName::Close)
6407 .color(Color::Error)
6408 .size(IconSize::Small),
6409 )
6410 }
6411 })
6412 .when_some(diff_for_discard, |this, diff| {
6413 let tool_call_id = tool_call.id.clone();
6414 let is_discarded = self
6415 .discarded_partial_edits
6416 .contains(&tool_call_id);
6417
6418 this.when(!is_discarded, |this| {
6419 this.child(
6420 IconButton::new(
6421 ("discard-partial-edit", entry_ix),
6422 IconName::Undo,
6423 )
6424 .icon_size(IconSize::Small)
6425 .tooltip(move |_, cx| {
6426 Tooltip::with_meta(
6427 "Discard Interrupted Edit",
6428 None,
6429 "You can discard this interrupted partial edit and restore the original file content.",
6430 cx,
6431 )
6432 })
6433 .on_click(cx.listener({
6434 let tool_call_id =
6435 tool_call_id.clone();
6436 move |this, _, _window, cx| {
6437 let diff_data = diff.read(cx);
6438 let base_text = diff_data
6439 .base_text()
6440 .clone();
6441 let buffer =
6442 diff_data.buffer().clone();
6443 buffer.update(
6444 cx,
6445 |buffer, cx| {
6446 buffer.set_text(
6447 base_text.as_ref(),
6448 cx,
6449 );
6450 },
6451 );
6452 this.discarded_partial_edits
6453 .insert(
6454 tool_call_id.clone(),
6455 );
6456 cx.notify();
6457 }
6458 })),
6459 )
6460 })
6461 }),
6462 )
6463 })
6464 .when(tool_call_output_focus, |this| {
6465 this.child(
6466 Button::new("open-file-button", "Open File")
6467 .style(ButtonStyle::Outlined)
6468 .label_size(LabelSize::Small)
6469 .key_binding(
6470 KeyBinding::for_action_in(&OpenExcerpts, &tool_call_output_focus_handle, cx)
6471 .map(|s| s.size(rems_from_px(12.))),
6472 )
6473 .on_click(|_, window, cx| {
6474 window.dispatch_action(
6475 Box::new(OpenExcerpts),
6476 cx,
6477 )
6478 }),
6479 )
6480 }),
6481 )
6482
6483 )
6484 }
6485 })
6486 .children(tool_output_display)
6487 }
6488
6489 fn render_permission_buttons(
6490 &self,
6491 session_id: acp::SessionId,
6492 is_first: bool,
6493 options: &PermissionOptions,
6494 entry_ix: usize,
6495 tool_call_id: acp::ToolCallId,
6496 focus_handle: &FocusHandle,
6497 cx: &Context<Self>,
6498 ) -> Div {
6499 match options {
6500 PermissionOptions::Flat(options) => self.render_permission_buttons_flat(
6501 session_id,
6502 is_first,
6503 options,
6504 entry_ix,
6505 tool_call_id,
6506 focus_handle,
6507 cx,
6508 ),
6509 PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
6510 is_first,
6511 choices,
6512 None,
6513 entry_ix,
6514 tool_call_id,
6515 focus_handle,
6516 cx,
6517 ),
6518 PermissionOptions::DropdownWithPatterns {
6519 choices,
6520 patterns,
6521 tool_name,
6522 } => self.render_permission_buttons_with_dropdown(
6523 is_first,
6524 choices,
6525 Some((patterns, tool_name)),
6526 entry_ix,
6527 tool_call_id,
6528 focus_handle,
6529 cx,
6530 ),
6531 }
6532 }
6533
6534 fn render_permission_buttons_with_dropdown(
6535 &self,
6536 is_first: bool,
6537 choices: &[PermissionOptionChoice],
6538 patterns: Option<(&[PermissionPattern], &str)>,
6539 entry_ix: usize,
6540 tool_call_id: acp::ToolCallId,
6541 focus_handle: &FocusHandle,
6542 cx: &Context<Self>,
6543 ) -> Div {
6544 let selection = self.permission_selections.get(&tool_call_id);
6545
6546 let selected_index = selection
6547 .and_then(|s| s.choice_index())
6548 .unwrap_or_else(|| choices.len().saturating_sub(1));
6549
6550 let dropdown_label: SharedString =
6551 if matches!(selection, Some(PermissionSelection::SelectedPatterns(_))) {
6552 "Always for selected commands".into()
6553 } else {
6554 choices
6555 .get(selected_index)
6556 .or(choices.last())
6557 .map(|choice| choice.label())
6558 .unwrap_or_else(|| "Only this time".into())
6559 };
6560
6561 let dropdown = if let Some((pattern_list, tool_name)) = patterns {
6562 self.render_permission_granularity_dropdown_with_patterns(
6563 choices,
6564 pattern_list,
6565 tool_name,
6566 dropdown_label,
6567 entry_ix,
6568 tool_call_id.clone(),
6569 is_first,
6570 cx,
6571 )
6572 } else {
6573 self.render_permission_granularity_dropdown(
6574 choices,
6575 dropdown_label,
6576 entry_ix,
6577 tool_call_id.clone(),
6578 selected_index,
6579 is_first,
6580 cx,
6581 )
6582 };
6583
6584 h_flex()
6585 .w_full()
6586 .p_1()
6587 .gap_2()
6588 .justify_between()
6589 .border_t_1()
6590 .border_color(self.tool_card_border_color(cx))
6591 .child(
6592 h_flex()
6593 .gap_0p5()
6594 .child(
6595 Button::new(("allow-btn", entry_ix), "Allow")
6596 .start_icon(
6597 Icon::new(IconName::Check)
6598 .size(IconSize::XSmall)
6599 .color(Color::Success),
6600 )
6601 .label_size(LabelSize::Small)
6602 .when(is_first, |this| {
6603 this.key_binding(
6604 KeyBinding::for_action_in(
6605 &AllowOnce as &dyn Action,
6606 focus_handle,
6607 cx,
6608 )
6609 .map(|kb| kb.size(rems_from_px(12.))),
6610 )
6611 })
6612 .on_click(cx.listener({
6613 move |this, _, window, cx| {
6614 this.authorize_pending_with_granularity(true, window, cx);
6615 }
6616 })),
6617 )
6618 .child(
6619 Button::new(("deny-btn", entry_ix), "Deny")
6620 .start_icon(
6621 Icon::new(IconName::Close)
6622 .size(IconSize::XSmall)
6623 .color(Color::Error),
6624 )
6625 .label_size(LabelSize::Small)
6626 .when(is_first, |this| {
6627 this.key_binding(
6628 KeyBinding::for_action_in(
6629 &RejectOnce as &dyn Action,
6630 focus_handle,
6631 cx,
6632 )
6633 .map(|kb| kb.size(rems_from_px(12.))),
6634 )
6635 })
6636 .on_click(cx.listener({
6637 move |this, _, window, cx| {
6638 this.authorize_pending_with_granularity(false, window, cx);
6639 }
6640 })),
6641 ),
6642 )
6643 .child(dropdown)
6644 }
6645
6646 fn render_permission_granularity_dropdown(
6647 &self,
6648 choices: &[PermissionOptionChoice],
6649 current_label: SharedString,
6650 entry_ix: usize,
6651 tool_call_id: acp::ToolCallId,
6652 selected_index: usize,
6653 is_first: bool,
6654 cx: &Context<Self>,
6655 ) -> AnyElement {
6656 let menu_options: Vec<(usize, SharedString)> = choices
6657 .iter()
6658 .enumerate()
6659 .map(|(i, choice)| (i, choice.label()))
6660 .collect();
6661
6662 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6663
6664 PopoverMenu::new(("permission-granularity", entry_ix))
6665 .with_handle(permission_dropdown_handle)
6666 .trigger(
6667 Button::new(("granularity-trigger", entry_ix), current_label)
6668 .end_icon(
6669 Icon::new(IconName::ChevronDown)
6670 .size(IconSize::XSmall)
6671 .color(Color::Muted),
6672 )
6673 .label_size(LabelSize::Small)
6674 .when(is_first, |this| {
6675 this.key_binding(
6676 KeyBinding::for_action_in(
6677 &crate::OpenPermissionDropdown as &dyn Action,
6678 &self.focus_handle(cx),
6679 cx,
6680 )
6681 .map(|kb| kb.size(rems_from_px(12.))),
6682 )
6683 }),
6684 )
6685 .menu(move |window, cx| {
6686 let tool_call_id = tool_call_id.clone();
6687 let options = menu_options.clone();
6688
6689 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
6690 for (index, display_name) in options.iter() {
6691 let display_name = display_name.clone();
6692 let index = *index;
6693 let tool_call_id_for_entry = tool_call_id.clone();
6694 let is_selected = index == selected_index;
6695 menu = menu.toggleable_entry(
6696 display_name,
6697 is_selected,
6698 IconPosition::End,
6699 None,
6700 move |window, cx| {
6701 window.dispatch_action(
6702 SelectPermissionGranularity {
6703 tool_call_id: tool_call_id_for_entry.0.to_string(),
6704 index,
6705 }
6706 .boxed_clone(),
6707 cx,
6708 );
6709 },
6710 );
6711 }
6712
6713 menu
6714 }))
6715 })
6716 .into_any_element()
6717 }
6718
6719 fn render_permission_granularity_dropdown_with_patterns(
6720 &self,
6721 choices: &[PermissionOptionChoice],
6722 patterns: &[PermissionPattern],
6723 _tool_name: &str,
6724 current_label: SharedString,
6725 entry_ix: usize,
6726 tool_call_id: acp::ToolCallId,
6727 is_first: bool,
6728 cx: &Context<Self>,
6729 ) -> AnyElement {
6730 let default_choice_index = choices.len().saturating_sub(1);
6731 let menu_options: Vec<(usize, SharedString)> = choices
6732 .iter()
6733 .enumerate()
6734 .map(|(i, choice)| (i, choice.label()))
6735 .collect();
6736
6737 let pattern_options: Vec<(usize, SharedString)> = patterns
6738 .iter()
6739 .enumerate()
6740 .map(|(i, cp)| {
6741 (
6742 i,
6743 SharedString::from(format!("Always for `{}` commands", cp.display_name)),
6744 )
6745 })
6746 .collect();
6747
6748 let pattern_count = patterns.len();
6749 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6750 let view = cx.entity().downgrade();
6751
6752 PopoverMenu::new(("permission-granularity", entry_ix))
6753 .with_handle(permission_dropdown_handle.clone())
6754 .anchor(Corner::TopRight)
6755 .attach(Corner::BottomRight)
6756 .trigger(
6757 Button::new(("granularity-trigger", entry_ix), current_label)
6758 .end_icon(
6759 Icon::new(IconName::ChevronDown)
6760 .size(IconSize::XSmall)
6761 .color(Color::Muted),
6762 )
6763 .label_size(LabelSize::Small)
6764 .when(is_first, |this| {
6765 this.key_binding(
6766 KeyBinding::for_action_in(
6767 &crate::OpenPermissionDropdown as &dyn Action,
6768 &self.focus_handle(cx),
6769 cx,
6770 )
6771 .map(|kb| kb.size(rems_from_px(12.))),
6772 )
6773 }),
6774 )
6775 .menu(move |window, cx| {
6776 let tool_call_id = tool_call_id.clone();
6777 let options = menu_options.clone();
6778 let patterns = pattern_options.clone();
6779 let view = view.clone();
6780 let dropdown_handle = permission_dropdown_handle.clone();
6781
6782 Some(ContextMenu::build_persistent(
6783 window,
6784 cx,
6785 move |menu, _window, cx| {
6786 let mut menu = menu;
6787
6788 // Read fresh selection state from the view on each rebuild.
6789 let selection: Option<PermissionSelection> = view.upgrade().and_then(|v| {
6790 let view = v.read(cx);
6791 view.permission_selections.get(&tool_call_id).cloned()
6792 });
6793
6794 let is_pattern_mode =
6795 matches!(selection, Some(PermissionSelection::SelectedPatterns(_)));
6796
6797 // Granularity choices: "Always for terminal", "Only this time"
6798 for (index, display_name) in options.iter() {
6799 let display_name = display_name.clone();
6800 let index = *index;
6801 let tool_call_id_for_entry = tool_call_id.clone();
6802 let is_selected = !is_pattern_mode
6803 && selection
6804 .as_ref()
6805 .and_then(|s| s.choice_index())
6806 .map_or(index == default_choice_index, |ci| ci == index);
6807
6808 let view = view.clone();
6809 menu = menu.toggleable_entry(
6810 display_name,
6811 is_selected,
6812 IconPosition::End,
6813 None,
6814 move |_window, cx| {
6815 view.update(cx, |this, cx| {
6816 this.permission_selections.insert(
6817 tool_call_id_for_entry.clone(),
6818 PermissionSelection::Choice(index),
6819 );
6820 cx.notify();
6821 })
6822 .log_err();
6823 },
6824 );
6825 }
6826
6827 menu = menu.separator().header("Select Options…");
6828
6829 for (pattern_index, label) in patterns.iter() {
6830 let label = label.clone();
6831 let pattern_index = *pattern_index;
6832 let tool_call_id_for_pattern = tool_call_id.clone();
6833 let is_checked = selection
6834 .as_ref()
6835 .is_some_and(|s| s.is_pattern_checked(pattern_index));
6836
6837 let view = view.clone();
6838 menu = menu.toggleable_entry(
6839 label,
6840 is_checked,
6841 IconPosition::End,
6842 None,
6843 move |_window, cx| {
6844 view.update(cx, |this, cx| {
6845 let selection = this
6846 .permission_selections
6847 .get_mut(&tool_call_id_for_pattern);
6848
6849 match selection {
6850 Some(PermissionSelection::SelectedPatterns(_)) => {
6851 // Already in pattern mode — toggle.
6852 this.permission_selections
6853 .get_mut(&tool_call_id_for_pattern)
6854 .expect("just matched above")
6855 .toggle_pattern(pattern_index);
6856 }
6857 _ => {
6858 // First click: activate pattern mode
6859 // with all patterns checked.
6860 this.permission_selections.insert(
6861 tool_call_id_for_pattern.clone(),
6862 PermissionSelection::SelectedPatterns(
6863 (0..pattern_count).collect(),
6864 ),
6865 );
6866 }
6867 }
6868 cx.notify();
6869 })
6870 .log_err();
6871 },
6872 );
6873 }
6874
6875 let any_patterns_checked = selection
6876 .as_ref()
6877 .is_some_and(|s| s.has_any_checked_patterns());
6878 let dropdown_handle = dropdown_handle.clone();
6879 menu = menu.custom_row(move |_window, _cx| {
6880 div()
6881 .py_1()
6882 .w_full()
6883 .child(
6884 Button::new("apply-patterns", "Apply")
6885 .full_width()
6886 .style(ButtonStyle::Outlined)
6887 .label_size(LabelSize::Small)
6888 .disabled(!any_patterns_checked)
6889 .on_click({
6890 let dropdown_handle = dropdown_handle.clone();
6891 move |_event, _window, cx| {
6892 dropdown_handle.hide(cx);
6893 }
6894 }),
6895 )
6896 .into_any_element()
6897 });
6898
6899 menu
6900 },
6901 ))
6902 })
6903 .into_any_element()
6904 }
6905
6906 fn render_permission_buttons_flat(
6907 &self,
6908 session_id: acp::SessionId,
6909 is_first: bool,
6910 options: &[acp::PermissionOption],
6911 entry_ix: usize,
6912 tool_call_id: acp::ToolCallId,
6913 focus_handle: &FocusHandle,
6914 cx: &Context<Self>,
6915 ) -> Div {
6916 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = ArrayVec::new();
6917
6918 div()
6919 .p_1()
6920 .border_t_1()
6921 .border_color(self.tool_card_border_color(cx))
6922 .w_full()
6923 .v_flex()
6924 .gap_0p5()
6925 .children(options.iter().map(move |option| {
6926 let option_id = SharedString::from(option.option_id.0.clone());
6927 Button::new((option_id, entry_ix), option.name.clone())
6928 .map(|this| {
6929 let (icon, action) = match option.kind {
6930 acp::PermissionOptionKind::AllowOnce => (
6931 Icon::new(IconName::Check)
6932 .size(IconSize::XSmall)
6933 .color(Color::Success),
6934 Some(&AllowOnce as &dyn Action),
6935 ),
6936 acp::PermissionOptionKind::AllowAlways => (
6937 Icon::new(IconName::CheckDouble)
6938 .size(IconSize::XSmall)
6939 .color(Color::Success),
6940 Some(&AllowAlways as &dyn Action),
6941 ),
6942 acp::PermissionOptionKind::RejectOnce => (
6943 Icon::new(IconName::Close)
6944 .size(IconSize::XSmall)
6945 .color(Color::Error),
6946 Some(&RejectOnce as &dyn Action),
6947 ),
6948 acp::PermissionOptionKind::RejectAlways | _ => (
6949 Icon::new(IconName::Close)
6950 .size(IconSize::XSmall)
6951 .color(Color::Error),
6952 None,
6953 ),
6954 };
6955
6956 let this = this.start_icon(icon);
6957
6958 let Some(action) = action else {
6959 return this;
6960 };
6961
6962 if !is_first || seen_kinds.contains(&option.kind) {
6963 return this;
6964 }
6965
6966 seen_kinds.push(option.kind).unwrap();
6967
6968 this.key_binding(
6969 KeyBinding::for_action_in(action, focus_handle, cx)
6970 .map(|kb| kb.size(rems_from_px(12.))),
6971 )
6972 })
6973 .label_size(LabelSize::Small)
6974 .on_click(cx.listener({
6975 let session_id = session_id.clone();
6976 let tool_call_id = tool_call_id.clone();
6977 let option_id = option.option_id.clone();
6978 let option_kind = option.kind;
6979 move |this, _, window, cx| {
6980 this.authorize_tool_call(
6981 session_id.clone(),
6982 tool_call_id.clone(),
6983 SelectedPermissionOutcome::new(option_id.clone(), option_kind),
6984 window,
6985 cx,
6986 );
6987 }
6988 }))
6989 }))
6990 }
6991
6992 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
6993 let bar = |n: u64, width_class: &str| {
6994 let bg_color = cx.theme().colors().element_active;
6995 let base = h_flex().h_1().rounded_full();
6996
6997 let modified = match width_class {
6998 "w_4_5" => base.w_3_4(),
6999 "w_1_4" => base.w_1_4(),
7000 "w_2_4" => base.w_2_4(),
7001 "w_3_5" => base.w_3_5(),
7002 "w_2_5" => base.w_2_5(),
7003 _ => base.w_1_2(),
7004 };
7005
7006 modified.with_animation(
7007 ElementId::Integer(n),
7008 Animation::new(Duration::from_secs(2)).repeat(),
7009 move |tab, delta| {
7010 let delta = (delta - 0.15 * n as f32) / 0.7;
7011 let delta = 1.0 - (0.5 - delta).abs() * 2.;
7012 let delta = ease_in_out(delta.clamp(0., 1.));
7013 let delta = 0.1 + 0.9 * delta;
7014
7015 tab.bg(bg_color.opacity(delta))
7016 },
7017 )
7018 };
7019
7020 v_flex()
7021 .p_3()
7022 .gap_1()
7023 .rounded_b_md()
7024 .bg(cx.theme().colors().editor_background)
7025 .child(bar(0, "w_4_5"))
7026 .child(bar(1, "w_1_4"))
7027 .child(bar(2, "w_2_4"))
7028 .child(bar(3, "w_3_5"))
7029 .child(bar(4, "w_2_5"))
7030 .into_any_element()
7031 }
7032
7033 fn render_tool_call_label(
7034 &self,
7035 entry_ix: usize,
7036 tool_call: &ToolCall,
7037 is_edit: bool,
7038 has_failed: bool,
7039 has_revealed_diff: bool,
7040 use_card_layout: bool,
7041 window: &Window,
7042 cx: &Context<Self>,
7043 ) -> Div {
7044 let has_location = tool_call.locations.len() == 1;
7045 let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
7046 let is_subagent_tool_call = tool_call.is_subagent();
7047
7048 let file_icon = if has_location {
7049 FileIcons::get_icon(&tool_call.locations[0].path, cx)
7050 .map(|from_path| Icon::from_path(from_path).color(Color::Muted))
7051 .unwrap_or(Icon::new(IconName::ToolPencil).color(Color::Muted))
7052 } else {
7053 Icon::new(IconName::ToolPencil).color(Color::Muted)
7054 };
7055
7056 let tool_icon = if is_file && has_failed && has_revealed_diff {
7057 div()
7058 .id(entry_ix)
7059 .tooltip(Tooltip::text("Interrupted Edit"))
7060 .child(DecoratedIcon::new(
7061 file_icon,
7062 Some(
7063 IconDecoration::new(
7064 IconDecorationKind::Triangle,
7065 self.tool_card_header_bg(cx),
7066 cx,
7067 )
7068 .color(cx.theme().status().warning)
7069 .position(gpui::Point {
7070 x: px(-2.),
7071 y: px(-2.),
7072 }),
7073 ),
7074 ))
7075 .into_any_element()
7076 } else if is_file {
7077 div().child(file_icon).into_any_element()
7078 } else if is_subagent_tool_call {
7079 Icon::new(self.agent_icon)
7080 .size(IconSize::Small)
7081 .color(Color::Muted)
7082 .into_any_element()
7083 } else {
7084 Icon::new(match tool_call.kind {
7085 acp::ToolKind::Read => IconName::ToolSearch,
7086 acp::ToolKind::Edit => IconName::ToolPencil,
7087 acp::ToolKind::Delete => IconName::ToolDeleteFile,
7088 acp::ToolKind::Move => IconName::ArrowRightLeft,
7089 acp::ToolKind::Search => IconName::ToolSearch,
7090 acp::ToolKind::Execute => IconName::ToolTerminal,
7091 acp::ToolKind::Think => IconName::ToolThink,
7092 acp::ToolKind::Fetch => IconName::ToolWeb,
7093 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
7094 acp::ToolKind::Other | _ => IconName::ToolHammer,
7095 })
7096 .size(IconSize::Small)
7097 .color(Color::Muted)
7098 .into_any_element()
7099 };
7100
7101 let gradient_overlay = {
7102 div()
7103 .absolute()
7104 .top_0()
7105 .right_0()
7106 .w_12()
7107 .h_full()
7108 .map(|this| {
7109 if use_card_layout {
7110 this.bg(linear_gradient(
7111 90.,
7112 linear_color_stop(self.tool_card_header_bg(cx), 1.),
7113 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
7114 ))
7115 } else {
7116 this.bg(linear_gradient(
7117 90.,
7118 linear_color_stop(cx.theme().colors().panel_background, 1.),
7119 linear_color_stop(
7120 cx.theme().colors().panel_background.opacity(0.2),
7121 0.,
7122 ),
7123 ))
7124 }
7125 })
7126 };
7127
7128 h_flex()
7129 .relative()
7130 .w_full()
7131 .h(window.line_height() - px(2.))
7132 .text_size(self.tool_name_font_size())
7133 .gap_1p5()
7134 .when(has_location || use_card_layout, |this| this.px_1())
7135 .when(has_location, |this| {
7136 this.cursor(CursorStyle::PointingHand)
7137 .rounded(rems_from_px(3.)) // Concentric border radius
7138 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
7139 })
7140 .overflow_hidden()
7141 .child(tool_icon)
7142 .child(if has_location {
7143 h_flex()
7144 .id(("open-tool-call-location", entry_ix))
7145 .w_full()
7146 .map(|this| {
7147 if use_card_layout {
7148 this.text_color(cx.theme().colors().text)
7149 } else {
7150 this.text_color(cx.theme().colors().text_muted)
7151 }
7152 })
7153 .child(
7154 self.render_markdown(
7155 tool_call.label.clone(),
7156 MarkdownStyle {
7157 prevent_mouse_interaction: true,
7158 ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx)
7159 .with_muted_text(cx)
7160 },
7161 ),
7162 )
7163 .tooltip(Tooltip::text("Go to File"))
7164 .on_click(cx.listener(move |this, _, window, cx| {
7165 this.open_tool_call_location(entry_ix, 0, window, cx);
7166 }))
7167 .into_any_element()
7168 } else {
7169 h_flex()
7170 .w_full()
7171 .child(self.render_markdown(
7172 tool_call.label.clone(),
7173 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx),
7174 ))
7175 .into_any()
7176 })
7177 .when(!is_edit, |this| this.child(gradient_overlay))
7178 }
7179
7180 fn open_tool_call_location(
7181 &self,
7182 entry_ix: usize,
7183 location_ix: usize,
7184 window: &mut Window,
7185 cx: &mut Context<Self>,
7186 ) -> Option<()> {
7187 let (tool_call_location, agent_location) = self
7188 .thread
7189 .read(cx)
7190 .entries()
7191 .get(entry_ix)?
7192 .location(location_ix)?;
7193
7194 let project_path = self
7195 .project
7196 .upgrade()?
7197 .read(cx)
7198 .find_project_path(&tool_call_location.path, cx)?;
7199
7200 let open_task = self
7201 .workspace
7202 .update(cx, |workspace, cx| {
7203 workspace.open_path(project_path, None, true, window, cx)
7204 })
7205 .log_err()?;
7206 window
7207 .spawn(cx, async move |cx| {
7208 let item = open_task.await?;
7209
7210 let Some(active_editor) = item.downcast::<Editor>() else {
7211 return anyhow::Ok(());
7212 };
7213
7214 active_editor.update_in(cx, |editor, window, cx| {
7215 let snapshot = editor.buffer().read(cx).snapshot(cx);
7216 if snapshot.as_singleton().is_some()
7217 && let Some(anchor) = snapshot.anchor_in_excerpt(agent_location.position)
7218 {
7219 editor.change_selections(Default::default(), window, cx, |selections| {
7220 selections.select_anchor_ranges([anchor..anchor]);
7221 })
7222 } else {
7223 let row = tool_call_location.line.unwrap_or_default();
7224 editor.change_selections(Default::default(), window, cx, |selections| {
7225 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
7226 })
7227 }
7228 })?;
7229
7230 anyhow::Ok(())
7231 })
7232 .detach_and_log_err(cx);
7233
7234 None
7235 }
7236
7237 fn render_tool_call_content(
7238 &self,
7239 session_id: &acp::SessionId,
7240 entry_ix: usize,
7241 content: &ToolCallContent,
7242 context_ix: usize,
7243 tool_call: &ToolCall,
7244 card_layout: bool,
7245 is_image_tool_call: bool,
7246 has_failed: bool,
7247 focus_handle: &FocusHandle,
7248 window: &Window,
7249 cx: &Context<Self>,
7250 ) -> AnyElement {
7251 match content {
7252 ToolCallContent::ContentBlock(content) => {
7253 if let Some(resource_link) = content.resource_link() {
7254 self.render_resource_link(resource_link, cx)
7255 } else if let Some(markdown) = content.markdown() {
7256 self.render_markdown_output(
7257 markdown.clone(),
7258 tool_call.id.clone(),
7259 context_ix,
7260 card_layout,
7261 window,
7262 cx,
7263 )
7264 } else if let Some(image) = content.image() {
7265 let location = tool_call.locations.first().cloned();
7266 self.render_image_output(
7267 entry_ix,
7268 image.clone(),
7269 location,
7270 card_layout,
7271 is_image_tool_call,
7272 cx,
7273 )
7274 } else {
7275 Empty.into_any_element()
7276 }
7277 }
7278 ToolCallContent::Diff(diff) => {
7279 self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
7280 }
7281 ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call(
7282 session_id,
7283 entry_ix,
7284 terminal,
7285 tool_call,
7286 focus_handle,
7287 false,
7288 window,
7289 cx,
7290 ),
7291 }
7292 }
7293
7294 fn render_resource_link(
7295 &self,
7296 resource_link: &acp::ResourceLink,
7297 cx: &Context<Self>,
7298 ) -> AnyElement {
7299 let uri: SharedString = resource_link.uri.clone().into();
7300 let is_file = resource_link.uri.strip_prefix("file://");
7301
7302 let Some(project) = self.project.upgrade() else {
7303 return Empty.into_any_element();
7304 };
7305
7306 let label: SharedString = if let Some(abs_path) = is_file {
7307 if let Some(project_path) = project
7308 .read(cx)
7309 .project_path_for_absolute_path(&Path::new(abs_path), cx)
7310 && let Some(worktree) = project
7311 .read(cx)
7312 .worktree_for_id(project_path.worktree_id, cx)
7313 {
7314 worktree
7315 .read(cx)
7316 .full_path(&project_path.path)
7317 .to_string_lossy()
7318 .to_string()
7319 .into()
7320 } else {
7321 abs_path.to_string().into()
7322 }
7323 } else {
7324 uri.clone()
7325 };
7326
7327 let button_id = SharedString::from(format!("item-{}", uri));
7328
7329 div()
7330 .ml(rems(0.4))
7331 .pl_2p5()
7332 .border_l_1()
7333 .border_color(self.tool_card_border_color(cx))
7334 .overflow_hidden()
7335 .child(
7336 Button::new(button_id, label)
7337 .label_size(LabelSize::Small)
7338 .color(Color::Muted)
7339 .truncate(true)
7340 .when(is_file.is_none(), |this| {
7341 this.end_icon(
7342 Icon::new(IconName::ArrowUpRight)
7343 .size(IconSize::XSmall)
7344 .color(Color::Muted),
7345 )
7346 })
7347 .on_click(cx.listener({
7348 let workspace = self.workspace.clone();
7349 move |_, _, window, cx: &mut Context<Self>| {
7350 open_link(uri.clone(), &workspace, window, cx);
7351 }
7352 })),
7353 )
7354 .into_any_element()
7355 }
7356
7357 fn render_diff_editor(
7358 &self,
7359 entry_ix: usize,
7360 diff: &Entity<acp_thread::Diff>,
7361 tool_call: &ToolCall,
7362 has_failed: bool,
7363 cx: &Context<Self>,
7364 ) -> AnyElement {
7365 let tool_progress = matches!(
7366 &tool_call.status,
7367 ToolCallStatus::InProgress | ToolCallStatus::Pending
7368 );
7369
7370 let revealed_diff_editor = if let Some(entry) =
7371 self.entry_view_state.read(cx).entry(entry_ix)
7372 && let Some(editor) = entry.editor_for_diff(diff)
7373 && diff.read(cx).has_revealed_range(cx)
7374 {
7375 Some(editor)
7376 } else {
7377 None
7378 };
7379
7380 let show_top_border = !has_failed || revealed_diff_editor.is_some();
7381
7382 v_flex()
7383 .h_full()
7384 .when(show_top_border, |this| {
7385 this.border_t_1()
7386 .when(has_failed, |this| this.border_dashed())
7387 .border_color(self.tool_card_border_color(cx))
7388 })
7389 .child(if let Some(editor) = revealed_diff_editor {
7390 editor.into_any_element()
7391 } else if tool_progress && self.as_native_connection(cx).is_some() {
7392 self.render_diff_loading(cx)
7393 } else {
7394 Empty.into_any()
7395 })
7396 .into_any()
7397 }
7398
7399 fn render_markdown_output(
7400 &self,
7401 markdown: Entity<Markdown>,
7402 tool_call_id: acp::ToolCallId,
7403 context_ix: usize,
7404 card_layout: bool,
7405 window: &Window,
7406 cx: &Context<Self>,
7407 ) -> AnyElement {
7408 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
7409
7410 v_flex()
7411 .gap_2()
7412 .map(|this| {
7413 if card_layout {
7414 this.p_2().when(context_ix > 0, |this| {
7415 this.border_t_1()
7416 .border_color(self.tool_card_border_color(cx))
7417 })
7418 } else {
7419 this.ml(rems(0.4))
7420 .px_3p5()
7421 .border_l_1()
7422 .border_color(self.tool_card_border_color(cx))
7423 }
7424 })
7425 .text_xs()
7426 .text_color(cx.theme().colors().text_muted)
7427 .child(self.render_markdown(
7428 markdown,
7429 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
7430 ))
7431 .when(!card_layout, |this| {
7432 this.child(
7433 IconButton::new(button_id, IconName::ChevronUp)
7434 .full_width()
7435 .style(ButtonStyle::Outlined)
7436 .icon_color(Color::Muted)
7437 .on_click(cx.listener({
7438 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
7439 this.expanded_tool_calls.remove(&tool_call_id);
7440 cx.notify();
7441 }
7442 })),
7443 )
7444 })
7445 .into_any_element()
7446 }
7447
7448 fn render_image_output(
7449 &self,
7450 entry_ix: usize,
7451 image: Arc<gpui::Image>,
7452 location: Option<acp::ToolCallLocation>,
7453 card_layout: bool,
7454 show_dimensions: bool,
7455 cx: &Context<Self>,
7456 ) -> AnyElement {
7457 let dimensions_label = if show_dimensions {
7458 let format_name = match image.format() {
7459 gpui::ImageFormat::Png => "PNG",
7460 gpui::ImageFormat::Jpeg => "JPEG",
7461 gpui::ImageFormat::Webp => "WebP",
7462 gpui::ImageFormat::Gif => "GIF",
7463 gpui::ImageFormat::Svg => "SVG",
7464 gpui::ImageFormat::Bmp => "BMP",
7465 gpui::ImageFormat::Tiff => "TIFF",
7466 gpui::ImageFormat::Ico => "ICO",
7467 };
7468 let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
7469 .with_guessed_format()
7470 .ok()
7471 .and_then(|reader| reader.into_dimensions().ok());
7472 dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
7473 } else {
7474 None
7475 };
7476
7477 v_flex()
7478 .gap_2()
7479 .map(|this| {
7480 if card_layout {
7481 this
7482 } else {
7483 this.ml(rems(0.4))
7484 .px_3p5()
7485 .border_l_1()
7486 .border_color(self.tool_card_border_color(cx))
7487 }
7488 })
7489 .when(dimensions_label.is_some() || location.is_some(), |this| {
7490 this.child(
7491 h_flex()
7492 .w_full()
7493 .justify_between()
7494 .items_center()
7495 .children(dimensions_label.map(|label| {
7496 Label::new(label)
7497 .size(LabelSize::XSmall)
7498 .color(Color::Muted)
7499 .buffer_font(cx)
7500 }))
7501 .when_some(location, |this, _loc| {
7502 this.child(
7503 Button::new(("go-to-file", entry_ix), "Go to File")
7504 .label_size(LabelSize::Small)
7505 .on_click(cx.listener(move |this, _, window, cx| {
7506 this.open_tool_call_location(entry_ix, 0, window, cx);
7507 })),
7508 )
7509 }),
7510 )
7511 })
7512 .child(
7513 img(image)
7514 .max_w_96()
7515 .max_h_96()
7516 .object_fit(ObjectFit::ScaleDown),
7517 )
7518 .into_any_element()
7519 }
7520
7521 fn render_subagent_tool_call(
7522 &self,
7523 active_session_id: &acp::SessionId,
7524 entry_ix: usize,
7525 tool_call: &ToolCall,
7526 subagent_session_id: Option<acp::SessionId>,
7527 focus_handle: &FocusHandle,
7528 window: &Window,
7529 cx: &Context<Self>,
7530 ) -> Div {
7531 let subagent_thread_view = subagent_session_id.and_then(|id| {
7532 self.server_view
7533 .upgrade()
7534 .and_then(|server_view| server_view.read(cx).as_connected())
7535 .and_then(|connected| connected.threads.get(&id))
7536 });
7537
7538 let content = self.render_subagent_card(
7539 active_session_id,
7540 entry_ix,
7541 subagent_thread_view,
7542 tool_call,
7543 focus_handle,
7544 window,
7545 cx,
7546 );
7547
7548 v_flex().mx_5().my_1p5().gap_3().child(content)
7549 }
7550
7551 fn render_subagent_card(
7552 &self,
7553 active_session_id: &acp::SessionId,
7554 entry_ix: usize,
7555 thread_view: Option<&Entity<ThreadView>>,
7556 tool_call: &ToolCall,
7557 focus_handle: &FocusHandle,
7558 window: &Window,
7559 cx: &Context<Self>,
7560 ) -> AnyElement {
7561 let thread = thread_view
7562 .as_ref()
7563 .map(|view| view.read(cx).thread.clone());
7564 let subagent_session_id = thread
7565 .as_ref()
7566 .map(|thread| thread.read(cx).session_id().clone());
7567 let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log());
7568 let changed_buffers = action_log
7569 .map(|log| log.read(cx).changed_buffers(cx))
7570 .unwrap_or_default();
7571
7572 let is_pending_tool_call = thread
7573 .as_ref()
7574 .and_then(|thread| {
7575 self.conversation
7576 .read(cx)
7577 .pending_tool_call(thread.read(cx).session_id(), cx)
7578 })
7579 .is_some();
7580
7581 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
7582 let files_changed = changed_buffers.len();
7583 let diff_stats = DiffStats::all_files(&changed_buffers, cx);
7584
7585 let is_running = matches!(
7586 tool_call.status,
7587 ToolCallStatus::Pending
7588 | ToolCallStatus::InProgress
7589 | ToolCallStatus::WaitingForConfirmation { .. }
7590 );
7591
7592 let is_failed = matches!(
7593 tool_call.status,
7594 ToolCallStatus::Failed | ToolCallStatus::Rejected
7595 );
7596
7597 let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled)
7598 || tool_call.content.iter().any(|c| match c {
7599 ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
7600 markdown.read(cx).source() == "User canceled"
7601 }
7602 _ => false,
7603 });
7604
7605 let thread_title = thread
7606 .as_ref()
7607 .and_then(|t| t.read(cx).title())
7608 .filter(|t| !t.is_empty());
7609 let tool_call_label = tool_call.label.read(cx).source().to_string();
7610 let has_tool_call_label = !tool_call_label.is_empty();
7611
7612 let has_title = thread_title.is_some() || has_tool_call_label;
7613 let has_no_title_or_canceled = !has_title || is_failed || is_cancelled;
7614
7615 let title: SharedString = if let Some(thread_title) = thread_title {
7616 thread_title
7617 } else if !tool_call_label.is_empty() {
7618 tool_call_label.into()
7619 } else if is_cancelled {
7620 "Subagent Canceled".into()
7621 } else if is_failed {
7622 "Subagent Failed".into()
7623 } else {
7624 "Spawning Agent…".into()
7625 };
7626
7627 let card_header_id = format!("subagent-header-{}", entry_ix);
7628 let status_icon = format!("status-icon-{}", entry_ix);
7629 let diff_stat_id = format!("subagent-diff-{}", entry_ix);
7630
7631 let icon = h_flex().w_4().justify_center().child(if is_running {
7632 SpinnerLabel::new()
7633 .size(LabelSize::Small)
7634 .into_any_element()
7635 } else if is_cancelled {
7636 div()
7637 .id(status_icon)
7638 .child(
7639 Icon::new(IconName::Circle)
7640 .size(IconSize::Small)
7641 .color(Color::Custom(
7642 cx.theme().colors().icon_disabled.opacity(0.5),
7643 )),
7644 )
7645 .tooltip(Tooltip::text("Subagent Cancelled"))
7646 .into_any_element()
7647 } else if is_failed {
7648 div()
7649 .id(status_icon)
7650 .child(
7651 Icon::new(IconName::Close)
7652 .size(IconSize::Small)
7653 .color(Color::Error),
7654 )
7655 .tooltip(Tooltip::text("Subagent Failed"))
7656 .into_any_element()
7657 } else {
7658 Icon::new(IconName::Check)
7659 .size(IconSize::Small)
7660 .color(Color::Success)
7661 .into_any_element()
7662 });
7663
7664 let has_expandable_content = thread
7665 .as_ref()
7666 .map_or(false, |thread| !thread.read(cx).entries().is_empty());
7667
7668 let tooltip_meta_description = if is_expanded {
7669 "Click to Collapse"
7670 } else {
7671 "Click to Preview"
7672 };
7673
7674 let error_message = self.subagent_error_message(&tool_call.status, tool_call, cx);
7675
7676 v_flex()
7677 .w_full()
7678 .rounded_md()
7679 .border_1()
7680 .when(has_no_title_or_canceled, |this| this.border_dashed())
7681 .border_color(self.tool_card_border_color(cx))
7682 .overflow_hidden()
7683 .child(
7684 h_flex()
7685 .group(&card_header_id)
7686 .h_8()
7687 .p_1()
7688 .w_full()
7689 .justify_between()
7690 .when(!has_no_title_or_canceled, |this| {
7691 this.bg(self.tool_card_header_bg(cx))
7692 })
7693 .child(
7694 h_flex()
7695 .id(format!("subagent-title-{}", entry_ix))
7696 .px_1()
7697 .min_w_0()
7698 .size_full()
7699 .gap_2()
7700 .justify_between()
7701 .rounded_sm()
7702 .overflow_hidden()
7703 .child(
7704 h_flex()
7705 .min_w_0()
7706 .w_full()
7707 .gap_1p5()
7708 .child(icon)
7709 .child(
7710 Label::new(title.to_string())
7711 .size(LabelSize::Custom(self.tool_name_font_size()))
7712 .truncate(),
7713 )
7714 .when(files_changed > 0, |this| {
7715 this.child(
7716 Label::new(format!(
7717 "— {} {} changed",
7718 files_changed,
7719 if files_changed == 1 { "file" } else { "files" }
7720 ))
7721 .size(LabelSize::Custom(self.tool_name_font_size()))
7722 .color(Color::Muted),
7723 )
7724 .child(
7725 DiffStat::new(
7726 diff_stat_id.clone(),
7727 diff_stats.lines_added as usize,
7728 diff_stats.lines_removed as usize,
7729 )
7730 .label_size(LabelSize::Custom(
7731 self.tool_name_font_size(),
7732 )),
7733 )
7734 }),
7735 )
7736 .when(!has_no_title_or_canceled && !is_pending_tool_call, |this| {
7737 this.tooltip(move |_, cx| {
7738 Tooltip::with_meta(
7739 title.to_string(),
7740 None,
7741 tooltip_meta_description,
7742 cx,
7743 )
7744 })
7745 })
7746 .when(has_expandable_content && !is_pending_tool_call, |this| {
7747 this.cursor_pointer()
7748 .hover(|s| s.bg(cx.theme().colors().element_hover))
7749 .child(
7750 div().visible_on_hover(card_header_id).child(
7751 Icon::new(if is_expanded {
7752 IconName::ChevronUp
7753 } else {
7754 IconName::ChevronDown
7755 })
7756 .color(Color::Muted)
7757 .size(IconSize::Small),
7758 ),
7759 )
7760 .on_click(cx.listener({
7761 let tool_call_id = tool_call.id.clone();
7762 move |this, _, _, cx| {
7763 if this.expanded_tool_calls.contains(&tool_call_id) {
7764 this.expanded_tool_calls.remove(&tool_call_id);
7765 } else {
7766 this.expanded_tool_calls
7767 .insert(tool_call_id.clone());
7768 }
7769 let expanded =
7770 this.expanded_tool_calls.contains(&tool_call_id);
7771 telemetry::event!("Subagent Toggled", expanded);
7772 cx.notify();
7773 }
7774 }))
7775 }),
7776 )
7777 .when(is_running && subagent_session_id.is_some(), |buttons| {
7778 buttons.child(
7779 IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop)
7780 .icon_size(IconSize::Small)
7781 .icon_color(Color::Error)
7782 .tooltip(Tooltip::text("Stop Subagent"))
7783 .when_some(
7784 thread_view
7785 .as_ref()
7786 .map(|view| view.read(cx).thread.clone()),
7787 |this, thread| {
7788 this.on_click(cx.listener(
7789 move |_this, _event, _window, cx| {
7790 telemetry::event!("Subagent Stopped");
7791 thread.update(cx, |thread, cx| {
7792 thread.cancel(cx).detach();
7793 });
7794 },
7795 ))
7796 },
7797 ),
7798 )
7799 }),
7800 )
7801 .when_some(thread_view, |this, thread_view| {
7802 let thread = &thread_view.read(cx).thread;
7803 let pending_tool_call = self
7804 .conversation
7805 .read(cx)
7806 .pending_tool_call(thread.read(cx).session_id(), cx);
7807
7808 let session_id = thread.read(cx).session_id().clone();
7809
7810 let fullscreen_toggle = h_flex()
7811 .id(entry_ix)
7812 .py_1()
7813 .w_full()
7814 .justify_center()
7815 .border_t_1()
7816 .when(is_failed, |this| this.border_dashed())
7817 .border_color(self.tool_card_border_color(cx))
7818 .cursor_pointer()
7819 .hover(|s| s.bg(cx.theme().colors().element_hover))
7820 .child(
7821 Icon::new(IconName::Maximize)
7822 .color(Color::Muted)
7823 .size(IconSize::Small),
7824 )
7825 .tooltip(Tooltip::text("Make Subagent Full Screen"))
7826 .on_click(cx.listener(move |this, _event, window, cx| {
7827 telemetry::event!("Subagent Maximized");
7828 this.server_view
7829 .update(cx, |this, cx| {
7830 this.navigate_to_session(session_id.clone(), window, cx);
7831 })
7832 .ok();
7833 }));
7834
7835 if is_running && let Some((_, subagent_tool_call_id, _)) = pending_tool_call {
7836 if let Some((entry_ix, tool_call)) =
7837 thread.read(cx).tool_call(&subagent_tool_call_id)
7838 {
7839 this.child(Divider::horizontal().color(DividerColor::Border))
7840 .child(thread_view.read(cx).render_any_tool_call(
7841 active_session_id,
7842 entry_ix,
7843 tool_call,
7844 focus_handle,
7845 true,
7846 window,
7847 cx,
7848 ))
7849 .child(fullscreen_toggle)
7850 } else {
7851 this
7852 }
7853 } else {
7854 this.when(is_expanded, |this| {
7855 this.child(self.render_subagent_expanded_content(
7856 thread_view,
7857 tool_call,
7858 window,
7859 cx,
7860 ))
7861 .when_some(error_message, |this, message| {
7862 this.child(
7863 Callout::new()
7864 .severity(Severity::Error)
7865 .icon(IconName::XCircle)
7866 .title(message),
7867 )
7868 })
7869 .child(fullscreen_toggle)
7870 })
7871 }
7872 })
7873 .into_any_element()
7874 }
7875
7876 fn render_subagent_expanded_content(
7877 &self,
7878 thread_view: &Entity<ThreadView>,
7879 tool_call: &ToolCall,
7880 window: &Window,
7881 cx: &Context<Self>,
7882 ) -> impl IntoElement {
7883 const MAX_PREVIEW_ENTRIES: usize = 8;
7884
7885 let subagent_view = thread_view.read(cx);
7886 let session_id = subagent_view.thread.read(cx).session_id().clone();
7887
7888 let is_canceled_or_failed = matches!(
7889 tool_call.status,
7890 ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
7891 );
7892
7893 let editor_bg = cx.theme().colors().editor_background;
7894 let overlay = {
7895 div()
7896 .absolute()
7897 .inset_0()
7898 .size_full()
7899 .bg(linear_gradient(
7900 180.,
7901 linear_color_stop(editor_bg.opacity(0.5), 0.),
7902 linear_color_stop(editor_bg.opacity(0.), 0.1),
7903 ))
7904 .block_mouse_except_scroll()
7905 };
7906
7907 let entries = subagent_view.thread.read(cx).entries();
7908 let total_entries = entries.len();
7909 let mut entry_range = if let Some(info) = tool_call.subagent_session_info.as_ref() {
7910 info.message_start_index
7911 ..info
7912 .message_end_index
7913 .map(|i| (i + 1).min(total_entries))
7914 .unwrap_or(total_entries)
7915 } else {
7916 0..total_entries
7917 };
7918 entry_range.start = entry_range
7919 .end
7920 .saturating_sub(MAX_PREVIEW_ENTRIES)
7921 .max(entry_range.start);
7922 let start_ix = entry_range.start;
7923
7924 let scroll_handle = self
7925 .subagent_scroll_handles
7926 .borrow_mut()
7927 .entry(session_id.clone())
7928 .or_default()
7929 .clone();
7930
7931 scroll_handle.scroll_to_bottom();
7932
7933 let rendered_entries: Vec<AnyElement> = entries
7934 .get(entry_range)
7935 .unwrap_or_default()
7936 .iter()
7937 .enumerate()
7938 .map(|(i, entry)| {
7939 let actual_ix = start_ix + i;
7940 subagent_view.render_entry(actual_ix, total_entries, entry, window, cx)
7941 })
7942 .collect();
7943
7944 v_flex()
7945 .w_full()
7946 .border_t_1()
7947 .when(is_canceled_or_failed, |this| this.border_dashed())
7948 .border_color(self.tool_card_border_color(cx))
7949 .overflow_hidden()
7950 .child(
7951 div()
7952 .pb_1()
7953 .min_h_0()
7954 .id(format!("subagent-entries-{}", session_id))
7955 .track_scroll(&scroll_handle)
7956 .children(rendered_entries),
7957 )
7958 .h_56()
7959 .child(overlay)
7960 .into_any_element()
7961 }
7962
7963 fn subagent_error_message(
7964 &self,
7965 status: &ToolCallStatus,
7966 tool_call: &ToolCall,
7967 cx: &App,
7968 ) -> Option<SharedString> {
7969 if matches!(status, ToolCallStatus::Failed) {
7970 tool_call.content.iter().find_map(|content| {
7971 if let ToolCallContent::ContentBlock(block) = content {
7972 if let acp_thread::ContentBlock::Markdown { markdown } = block {
7973 let source = markdown.read(cx).source().to_string();
7974 if !source.is_empty() {
7975 if source == "User canceled" {
7976 return None;
7977 } else {
7978 return Some(SharedString::from(source));
7979 }
7980 }
7981 }
7982 }
7983 None
7984 })
7985 } else {
7986 None
7987 }
7988 }
7989
7990 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
7991 cx.theme()
7992 .colors()
7993 .element_background
7994 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
7995 }
7996
7997 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
7998 cx.theme().colors().border.opacity(0.8)
7999 }
8000
8001 fn tool_name_font_size(&self) -> Rems {
8002 rems_from_px(13.)
8003 }
8004
8005 pub(crate) fn render_thread_error(
8006 &mut self,
8007 window: &mut Window,
8008 cx: &mut Context<Self>,
8009 ) -> Option<Div> {
8010 let content = match self.thread_error.as_ref()? {
8011 ThreadError::Other { message, .. } => {
8012 self.render_any_thread_error(message.clone(), window, cx)
8013 }
8014 ThreadError::Refusal => self.render_refusal_error(cx),
8015 ThreadError::AuthenticationRequired(error) => {
8016 self.render_authentication_required_error(error.clone(), cx)
8017 }
8018 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
8019 };
8020
8021 Some(div().child(content))
8022 }
8023
8024 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
8025 let model_or_agent_name = self.current_model_name(cx);
8026 let refusal_message = format!(
8027 "{} refused to respond to this prompt. \
8028 This can happen when a model believes the prompt violates its content policy \
8029 or safety guidelines, so rephrasing it can sometimes address the issue.",
8030 model_or_agent_name
8031 );
8032
8033 Callout::new()
8034 .severity(Severity::Error)
8035 .title("Request Refused")
8036 .icon(IconName::XCircle)
8037 .description(refusal_message.clone())
8038 .actions_slot(self.create_copy_button(&refusal_message))
8039 .dismiss_action(self.dismiss_error_button(cx))
8040 }
8041
8042 fn render_authentication_required_error(
8043 &self,
8044 error: SharedString,
8045 cx: &mut Context<Self>,
8046 ) -> Callout {
8047 Callout::new()
8048 .severity(Severity::Error)
8049 .title("Authentication Required")
8050 .icon(IconName::XCircle)
8051 .description(error.clone())
8052 .actions_slot(
8053 h_flex()
8054 .gap_0p5()
8055 .child(self.authenticate_button(cx))
8056 .child(self.create_copy_button(error)),
8057 )
8058 .dismiss_action(self.dismiss_error_button(cx))
8059 }
8060
8061 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
8062 const ERROR_MESSAGE: &str =
8063 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
8064
8065 Callout::new()
8066 .severity(Severity::Error)
8067 .icon(IconName::XCircle)
8068 .title("Free Usage Exceeded")
8069 .description(ERROR_MESSAGE)
8070 .actions_slot(
8071 h_flex()
8072 .gap_0p5()
8073 .child(self.upgrade_button(cx))
8074 .child(self.create_copy_button(ERROR_MESSAGE)),
8075 )
8076 .dismiss_action(self.dismiss_error_button(cx))
8077 }
8078
8079 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8080 Button::new("upgrade", "Upgrade")
8081 .label_size(LabelSize::Small)
8082 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
8083 .on_click(cx.listener({
8084 move |this, _, _, cx| {
8085 this.clear_thread_error(cx);
8086 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
8087 }
8088 }))
8089 }
8090
8091 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8092 Button::new("authenticate", "Authenticate")
8093 .label_size(LabelSize::Small)
8094 .style(ButtonStyle::Filled)
8095 .on_click(cx.listener({
8096 move |this, _, window, cx| {
8097 let server_view = this.server_view.clone();
8098 let agent_name = this.agent_id.clone();
8099
8100 this.clear_thread_error(cx);
8101 if let Some(message) = this.in_flight_prompt.take() {
8102 this.message_editor.update(cx, |editor, cx| {
8103 editor.set_message(message, window, cx);
8104 });
8105 }
8106 let connection = this.thread.read(cx).connection().clone();
8107 window.defer(cx, |window, cx| {
8108 ConversationView::handle_auth_required(
8109 server_view,
8110 AuthRequired::new(),
8111 agent_name,
8112 connection,
8113 window,
8114 cx,
8115 );
8116 })
8117 }
8118 }))
8119 }
8120
8121 fn current_model_name(&self, cx: &App) -> SharedString {
8122 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
8123 // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
8124 // This provides better clarity about what refused the request
8125 if self.as_native_connection(cx).is_some() {
8126 self.model_selector
8127 .clone()
8128 .and_then(|selector| selector.read(cx).active_model(cx))
8129 .map(|model| model.name.clone())
8130 .unwrap_or_else(|| SharedString::from("The model"))
8131 } else {
8132 // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
8133 self.agent_id.0.clone()
8134 }
8135 }
8136
8137 fn render_any_thread_error(
8138 &mut self,
8139 error: SharedString,
8140 window: &mut Window,
8141 cx: &mut Context<'_, Self>,
8142 ) -> Callout {
8143 let can_resume = self.thread.read(cx).can_retry(cx);
8144
8145 let markdown = if let Some(markdown) = &self.thread_error_markdown {
8146 markdown.clone()
8147 } else {
8148 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
8149 self.thread_error_markdown = Some(markdown.clone());
8150 markdown
8151 };
8152
8153 let markdown_style =
8154 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx);
8155 let description = self
8156 .render_markdown(markdown, markdown_style)
8157 .into_any_element();
8158
8159 Callout::new()
8160 .severity(Severity::Error)
8161 .icon(IconName::XCircle)
8162 .title("An Error Happened")
8163 .description_slot(description)
8164 .actions_slot(
8165 h_flex()
8166 .gap_0p5()
8167 .when(can_resume, |this| {
8168 this.child(
8169 IconButton::new("retry", IconName::RotateCw)
8170 .icon_size(IconSize::Small)
8171 .tooltip(Tooltip::text("Retry Generation"))
8172 .on_click(cx.listener(|this, _, _window, cx| {
8173 this.retry_generation(cx);
8174 })),
8175 )
8176 })
8177 .child(self.create_copy_button(error.to_string())),
8178 )
8179 .dismiss_action(self.dismiss_error_button(cx))
8180 }
8181
8182 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
8183 let workspace = self.workspace.clone();
8184 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
8185 open_link(text, &workspace, window, cx);
8186 })
8187 }
8188
8189 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
8190 let message = message.into();
8191
8192 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
8193 }
8194
8195 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8196 IconButton::new("dismiss", IconName::Close)
8197 .icon_size(IconSize::Small)
8198 .tooltip(Tooltip::text("Dismiss"))
8199 .on_click(cx.listener({
8200 move |this, _, _, cx| {
8201 this.clear_thread_error(cx);
8202 cx.notify();
8203 }
8204 }))
8205 }
8206
8207 fn render_resume_notice(_cx: &Context<Self>) -> AnyElement {
8208 let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off.";
8209
8210 div()
8211 .px_2()
8212 .pt_2()
8213 .pb_3()
8214 .w_full()
8215 .child(
8216 Callout::new()
8217 .severity(Severity::Info)
8218 .icon(IconName::Info)
8219 .title("Resumed Session")
8220 .description(description),
8221 )
8222 .into_any_element()
8223 }
8224
8225 fn update_recent_history_from_cache(
8226 &mut self,
8227 history: &Entity<ThreadHistory>,
8228 cx: &mut Context<Self>,
8229 ) {
8230 self.recent_history_entries = history.read(cx).get_recent_sessions(3);
8231 self.hovered_recent_history_item = None;
8232 cx.notify();
8233 }
8234
8235 fn render_empty_state_section_header(
8236 &self,
8237 label: impl Into<SharedString>,
8238 action_slot: Option<AnyElement>,
8239 cx: &mut Context<Self>,
8240 ) -> impl IntoElement {
8241 div().pl_1().pr_1p5().child(
8242 h_flex()
8243 .mt_2()
8244 .pl_1p5()
8245 .pb_1()
8246 .w_full()
8247 .justify_between()
8248 .border_b_1()
8249 .border_color(cx.theme().colors().border_variant)
8250 .child(
8251 Label::new(label.into())
8252 .size(LabelSize::Small)
8253 .color(Color::Muted),
8254 )
8255 .children(action_slot),
8256 )
8257 }
8258
8259 fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
8260 let render_history = !self.recent_history_entries.is_empty();
8261
8262 v_flex()
8263 .size_full()
8264 .when(render_history, |this| {
8265 let recent_history = self.recent_history_entries.clone();
8266 this.justify_end().child(
8267 v_flex()
8268 .child(
8269 self.render_empty_state_section_header(
8270 "Recent",
8271 Some(
8272 Button::new("view-history", "View All")
8273 .style(ButtonStyle::Subtle)
8274 .label_size(LabelSize::Small)
8275 .key_binding(
8276 KeyBinding::for_action_in(
8277 &OpenHistory,
8278 &self.focus_handle(cx),
8279 cx,
8280 )
8281 .map(|kb| kb.size(rems_from_px(12.))),
8282 )
8283 .on_click(move |_event, window, cx| {
8284 window.dispatch_action(OpenHistory.boxed_clone(), cx);
8285 })
8286 .into_any_element(),
8287 ),
8288 cx,
8289 ),
8290 )
8291 .child(v_flex().p_1().pr_1p5().gap_1().children({
8292 let supports_delete = self
8293 .history
8294 .as_ref()
8295 .map_or(false, |h| h.read(cx).supports_delete());
8296 recent_history
8297 .into_iter()
8298 .enumerate()
8299 .map(move |(index, entry)| {
8300 // TODO: Add keyboard navigation.
8301 let is_hovered =
8302 self.hovered_recent_history_item == Some(index);
8303 crate::thread_history_view::HistoryEntryElement::new(
8304 entry,
8305 self.server_view.clone(),
8306 )
8307 .hovered(is_hovered)
8308 .supports_delete(supports_delete)
8309 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
8310 if *is_hovered {
8311 this.hovered_recent_history_item = Some(index);
8312 } else if this.hovered_recent_history_item == Some(index) {
8313 this.hovered_recent_history_item = None;
8314 }
8315 cx.notify();
8316 }))
8317 .into_any_element()
8318 })
8319 })),
8320 )
8321 })
8322 .into_any()
8323 }
8324
8325 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
8326 Callout::new()
8327 .icon(IconName::Warning)
8328 .severity(Severity::Warning)
8329 .title("Codex on Windows")
8330 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
8331 .actions_slot(
8332 Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({
8333 move |_, _, _window, cx| {
8334 #[cfg(windows)]
8335 _window.dispatch_action(
8336 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
8337 cx,
8338 );
8339 cx.notify();
8340 }
8341 })),
8342 )
8343 .dismiss_action(
8344 IconButton::new("dismiss", IconName::Close)
8345 .icon_size(IconSize::Small)
8346 .icon_color(Color::Muted)
8347 .tooltip(Tooltip::text("Dismiss Warning"))
8348 .on_click(cx.listener({
8349 move |this, _, _, cx| {
8350 this.show_codex_windows_warning = false;
8351 cx.notify();
8352 }
8353 })),
8354 )
8355 }
8356
8357 fn render_external_source_prompt_warning(&self, cx: &mut Context<Self>) -> Callout {
8358 Callout::new()
8359 .icon(IconName::Warning)
8360 .severity(Severity::Warning)
8361 .title("Review before sending")
8362 .description("This prompt was pre-filled by an external link. Read it carefully before you send it.")
8363 .dismiss_action(
8364 IconButton::new("dismiss-external-source-prompt-warning", IconName::Close)
8365 .icon_size(IconSize::Small)
8366 .icon_color(Color::Muted)
8367 .tooltip(Tooltip::text("Dismiss Warning"))
8368 .on_click(cx.listener({
8369 move |this, _, _, cx| {
8370 this.show_external_source_prompt_warning = false;
8371 cx.notify();
8372 }
8373 })),
8374 )
8375 }
8376
8377 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
8378 let server_view = self.server_view.clone();
8379 let has_version = !version.is_empty();
8380 let title = if has_version {
8381 "New version available"
8382 } else {
8383 "Agent update available"
8384 };
8385 let button_label = if has_version {
8386 format!("Update to v{}", version)
8387 } else {
8388 "Reconnect".to_string()
8389 };
8390
8391 v_flex().w_full().justify_end().child(
8392 h_flex()
8393 .p_2()
8394 .pr_3()
8395 .w_full()
8396 .gap_1p5()
8397 .border_t_1()
8398 .border_color(cx.theme().colors().border)
8399 .bg(cx.theme().colors().element_background)
8400 .child(
8401 h_flex()
8402 .flex_1()
8403 .gap_1p5()
8404 .child(
8405 Icon::new(IconName::Download)
8406 .color(Color::Accent)
8407 .size(IconSize::Small),
8408 )
8409 .child(Label::new(title).size(LabelSize::Small)),
8410 )
8411 .child(
8412 Button::new("update-button", button_label)
8413 .label_size(LabelSize::Small)
8414 .style(ButtonStyle::Tinted(TintColor::Accent))
8415 .on_click(move |_, window, cx| {
8416 server_view
8417 .update(cx, |view, cx| view.reset(window, cx))
8418 .ok();
8419 }),
8420 ),
8421 )
8422 }
8423
8424 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
8425 if self.token_limit_callout_dismissed {
8426 return None;
8427 }
8428
8429 let token_usage = self.thread.read(cx).token_usage()?;
8430 let ratio = token_usage.ratio();
8431
8432 let (severity, icon, title) = match ratio {
8433 acp_thread::TokenUsageRatio::Normal => return None,
8434 acp_thread::TokenUsageRatio::Warning => (
8435 Severity::Warning,
8436 IconName::Warning,
8437 "Thread reaching the token limit soon",
8438 ),
8439 acp_thread::TokenUsageRatio::Exceeded => (
8440 Severity::Error,
8441 IconName::XCircle,
8442 "Thread reached the token limit",
8443 ),
8444 };
8445
8446 let description = "To continue, start a new thread from a summary.";
8447
8448 Some(
8449 Callout::new()
8450 .severity(severity)
8451 .icon(icon)
8452 .title(title)
8453 .description(description)
8454 .actions_slot(
8455 h_flex().gap_0p5().child(
8456 Button::new("start-new-thread", "Start New Thread")
8457 .label_size(LabelSize::Small)
8458 .on_click(cx.listener(|this, _, window, cx| {
8459 let session_id = this.thread.read(cx).session_id().clone();
8460 window.dispatch_action(
8461 crate::NewNativeAgentThreadFromSummary {
8462 from_session_id: session_id,
8463 }
8464 .boxed_clone(),
8465 cx,
8466 );
8467 })),
8468 ),
8469 )
8470 .dismiss_action(self.dismiss_error_button(cx)),
8471 )
8472 }
8473
8474 fn open_permission_dropdown(
8475 &mut self,
8476 _: &crate::OpenPermissionDropdown,
8477 window: &mut Window,
8478 cx: &mut Context<Self>,
8479 ) {
8480 let menu_handle = self.permission_dropdown_handle.clone();
8481 window.defer(cx, move |window, cx| {
8482 menu_handle.toggle(window, cx);
8483 });
8484 }
8485
8486 fn open_add_context_menu(
8487 &mut self,
8488 _action: &OpenAddContextMenu,
8489 window: &mut Window,
8490 cx: &mut Context<Self>,
8491 ) {
8492 let menu_handle = self.add_context_menu_handle.clone();
8493 window.defer(cx, move |window, cx| {
8494 menu_handle.toggle(window, cx);
8495 });
8496 }
8497
8498 fn toggle_fast_mode(&mut self, cx: &mut Context<Self>) {
8499 if !self.fast_mode_available(cx) {
8500 return;
8501 }
8502 let Some(thread) = self.as_native_thread(cx) else {
8503 return;
8504 };
8505 thread.update(cx, |thread, cx| {
8506 thread.set_speed(
8507 thread
8508 .speed()
8509 .map(|speed| speed.toggle())
8510 .unwrap_or(Speed::Fast),
8511 cx,
8512 );
8513 });
8514 }
8515
8516 fn cycle_thinking_effort(&mut self, cx: &mut Context<Self>) {
8517 let Some(thread) = self.as_native_thread(cx) else {
8518 return;
8519 };
8520
8521 let (effort_levels, current_effort) = {
8522 let thread_ref = thread.read(cx);
8523 let Some(model) = thread_ref.model() else {
8524 return;
8525 };
8526 if !model.supports_thinking() || !thread_ref.thinking_enabled() {
8527 return;
8528 }
8529 let effort_levels = model.supported_effort_levels();
8530 if effort_levels.is_empty() {
8531 return;
8532 }
8533 let current_effort = thread_ref.thinking_effort().cloned();
8534 (effort_levels, current_effort)
8535 };
8536
8537 let current_index = current_effort.and_then(|current| {
8538 effort_levels
8539 .iter()
8540 .position(|level| level.value == current)
8541 });
8542 let next_index = match current_index {
8543 Some(index) => (index + 1) % effort_levels.len(),
8544 None => 0,
8545 };
8546 let next_effort = effort_levels[next_index].value.to_string();
8547
8548 thread.update(cx, |thread, cx| {
8549 thread.set_thinking_effort(Some(next_effort.clone()), cx);
8550
8551 let fs = thread.project().read(cx).fs().clone();
8552 update_settings_file(fs, cx, move |settings, _| {
8553 if let Some(agent) = settings.agent.as_mut()
8554 && let Some(default_model) = agent.default_model.as_mut()
8555 {
8556 default_model.effort = Some(next_effort);
8557 }
8558 });
8559 });
8560 }
8561
8562 fn toggle_thinking_effort_menu(
8563 &mut self,
8564 _action: &ToggleThinkingEffortMenu,
8565 window: &mut Window,
8566 cx: &mut Context<Self>,
8567 ) {
8568 let menu_handle = self.thinking_effort_menu_handle.clone();
8569 window.defer(cx, move |window, cx| {
8570 menu_handle.toggle(window, cx);
8571 });
8572 }
8573}
8574
8575impl Render for ThreadView {
8576 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
8577 let has_messages = self.list_state.item_count() > 0;
8578 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
8579
8580 let max_content_width = AgentSettings::get_global(cx).max_content_width;
8581
8582 let conversation = v_flex()
8583 .mx_auto()
8584 .max_w(max_content_width)
8585 .when(!v2_empty_state, |this| this.flex_1().size_full())
8586 .map(|this| {
8587 let this = this.when(self.resumed_without_history, |this| {
8588 this.child(Self::render_resume_notice(cx))
8589 });
8590 if has_messages {
8591 let list_state = self.list_state.clone();
8592 this.child(self.render_entries(cx))
8593 .vertical_scrollbar_for(&list_state, window, cx)
8594 .into_any()
8595 } else if v2_empty_state {
8596 this.into_any()
8597 } else {
8598 this.child(self.render_recent_history(cx)).into_any()
8599 }
8600 });
8601
8602 v_flex()
8603 .key_context("AcpThread")
8604 .track_focus(&self.focus_handle)
8605 .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
8606 if this.parent_id.is_none() {
8607 this.cancel_generation(cx);
8608 }
8609 }))
8610 .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
8611 if let Some(parent_session_id) = this.parent_id.clone() {
8612 this.server_view
8613 .update(cx, |view, cx| {
8614 view.navigate_to_session(parent_session_id, window, cx);
8615 })
8616 .ok();
8617 }
8618 }))
8619 .on_action(cx.listener(Self::keep_all))
8620 .on_action(cx.listener(Self::reject_all))
8621 .on_action(cx.listener(Self::undo_last_reject))
8622 .on_action(cx.listener(Self::allow_always))
8623 .on_action(cx.listener(Self::allow_once))
8624 .on_action(cx.listener(Self::reject_once))
8625 .on_action(cx.listener(Self::handle_authorize_tool_call))
8626 .on_action(cx.listener(Self::handle_select_permission_granularity))
8627 .on_action(cx.listener(Self::handle_toggle_command_pattern))
8628 .on_action(cx.listener(Self::open_permission_dropdown))
8629 .on_action(cx.listener(Self::open_add_context_menu))
8630 .on_action(cx.listener(Self::scroll_output_page_up))
8631 .on_action(cx.listener(Self::scroll_output_page_down))
8632 .on_action(cx.listener(Self::scroll_output_line_up))
8633 .on_action(cx.listener(Self::scroll_output_line_down))
8634 .on_action(cx.listener(Self::scroll_output_to_top))
8635 .on_action(cx.listener(Self::scroll_output_to_bottom))
8636 .on_action(cx.listener(Self::scroll_output_to_previous_message))
8637 .on_action(cx.listener(Self::scroll_output_to_next_message))
8638 .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {
8639 this.toggle_fast_mode(cx);
8640 }))
8641 .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
8642 if this.thread.read(cx).status() != ThreadStatus::Idle {
8643 return;
8644 }
8645 if let Some(thread) = this.as_native_thread(cx) {
8646 thread.update(cx, |thread, cx| {
8647 thread.set_thinking_enabled(!thread.thinking_enabled(), cx);
8648 });
8649 }
8650 }))
8651 .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
8652 if this.thread.read(cx).status() != ThreadStatus::Idle {
8653 return;
8654 }
8655 this.cycle_thinking_effort(cx);
8656 }))
8657 .on_action(
8658 cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| {
8659 if this.thread.read(cx).status() != ThreadStatus::Idle {
8660 return;
8661 }
8662 this.toggle_thinking_effort_menu(action, window, cx);
8663 }),
8664 )
8665 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
8666 this.send_queued_message_at_index(0, true, window, cx);
8667 }))
8668 .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
8669 this.remove_from_queue(0, cx);
8670 cx.notify();
8671 }))
8672 .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
8673 this.move_queued_message_to_main_editor(0, None, None, window, cx);
8674 }))
8675 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
8676 this.local_queued_messages.clear();
8677 this.sync_queue_flag_to_native_thread(cx);
8678 this.can_fast_track_queue = false;
8679 cx.notify();
8680 }))
8681 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
8682 if this.thread.read(cx).status() != ThreadStatus::Idle {
8683 return;
8684 }
8685 if let Some(config_options_view) = this.config_options_view.clone() {
8686 let handled = config_options_view.update(cx, |view, cx| {
8687 view.toggle_category_picker(
8688 acp::SessionConfigOptionCategory::Mode,
8689 window,
8690 cx,
8691 )
8692 });
8693 if handled {
8694 return;
8695 }
8696 }
8697
8698 if let Some(profile_selector) = this.profile_selector.clone() {
8699 profile_selector.read(cx).menu_handle().toggle(window, cx);
8700 } else if let Some(mode_selector) = this.mode_selector.clone() {
8701 mode_selector.read(cx).menu_handle().toggle(window, cx);
8702 }
8703 }))
8704 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
8705 if this.thread.read(cx).status() != ThreadStatus::Idle {
8706 return;
8707 }
8708 if let Some(config_options_view) = this.config_options_view.clone() {
8709 let handled = config_options_view.update(cx, |view, cx| {
8710 view.cycle_category_option(
8711 acp::SessionConfigOptionCategory::Mode,
8712 false,
8713 cx,
8714 )
8715 });
8716 if handled {
8717 return;
8718 }
8719 }
8720
8721 if let Some(profile_selector) = this.profile_selector.clone() {
8722 profile_selector.update(cx, |profile_selector, cx| {
8723 profile_selector.cycle_profile(cx);
8724 });
8725 } else if let Some(mode_selector) = this.mode_selector.clone() {
8726 mode_selector.update(cx, |mode_selector, cx| {
8727 mode_selector.cycle_mode(window, cx);
8728 });
8729 }
8730 }))
8731 .on_action(cx.listener(|this, _: &ToggleModelSelector, 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.toggle_category_picker(
8738 acp::SessionConfigOptionCategory::Model,
8739 window,
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
8750 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
8751 }
8752 }))
8753 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
8754 if this.thread.read(cx).status() != ThreadStatus::Idle {
8755 return;
8756 }
8757 if let Some(config_options_view) = this.config_options_view.clone() {
8758 let handled = config_options_view.update(cx, |view, cx| {
8759 view.cycle_category_option(
8760 acp::SessionConfigOptionCategory::Model,
8761 true,
8762 cx,
8763 )
8764 });
8765 if handled {
8766 return;
8767 }
8768 }
8769
8770 if let Some(model_selector) = this.model_selector.clone() {
8771 model_selector.update(cx, |model_selector, cx| {
8772 model_selector.cycle_favorite_models(window, cx);
8773 });
8774 }
8775 }))
8776 .size_full()
8777 .children(self.render_subagent_titlebar(cx))
8778 .child(conversation)
8779 .children(self.render_activity_bar(window, cx))
8780 .when(self.show_external_source_prompt_warning, |this| {
8781 this.child(self.render_external_source_prompt_warning(cx))
8782 })
8783 .when(self.show_codex_windows_warning, |this| {
8784 this.child(self.render_codex_windows_warning(cx))
8785 })
8786 .children(self.render_thread_retry_status_callout())
8787 .children(self.render_thread_error(window, cx))
8788 .when_some(
8789 match has_messages {
8790 true => None,
8791 false => self.new_server_version_available.clone(),
8792 },
8793 |this, version| this.child(self.render_new_version_callout(&version, cx)),
8794 )
8795 .children(self.render_token_limit_callout(cx))
8796 .child(self.render_message_editor(window, cx))
8797 }
8798}
8799
8800pub(crate) fn open_link(
8801 url: SharedString,
8802 workspace: &WeakEntity<Workspace>,
8803 window: &mut Window,
8804 cx: &mut App,
8805) {
8806 let Some(workspace) = workspace.upgrade() else {
8807 cx.open_url(&url);
8808 return;
8809 };
8810
8811 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() {
8812 workspace.update(cx, |workspace, cx| match mention {
8813 MentionUri::File { abs_path } => {
8814 let project = workspace.project();
8815 let Some(path) =
8816 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
8817 else {
8818 return;
8819 };
8820
8821 workspace
8822 .open_path(path, None, true, window, cx)
8823 .detach_and_log_err(cx);
8824 }
8825 MentionUri::PastedImage { .. } => {}
8826 MentionUri::Directory { abs_path } => {
8827 let project = workspace.project();
8828 let Some(entry_id) = project.update(cx, |project, cx| {
8829 let path = project.find_project_path(abs_path, cx)?;
8830 project.entry_for_path(&path, cx).map(|entry| entry.id)
8831 }) else {
8832 return;
8833 };
8834
8835 project.update(cx, |_, cx| {
8836 cx.emit(project::Event::RevealInProjectPanel(entry_id));
8837 });
8838 }
8839 MentionUri::Symbol {
8840 abs_path: path,
8841 line_range,
8842 ..
8843 }
8844 | MentionUri::Selection {
8845 abs_path: Some(path),
8846 line_range,
8847 } => {
8848 let project = workspace.project();
8849 let Some(path) =
8850 project.update(cx, |project, cx| project.find_project_path(path, cx))
8851 else {
8852 return;
8853 };
8854
8855 let item = workspace.open_path(path, None, true, window, cx);
8856 window
8857 .spawn(cx, async move |cx| {
8858 let Some(editor) = item.await?.downcast::<Editor>() else {
8859 return Ok(());
8860 };
8861 let range =
8862 Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
8863 editor
8864 .update_in(cx, |editor, window, cx| {
8865 editor.change_selections(
8866 SelectionEffects::scroll(Autoscroll::center()),
8867 window,
8868 cx,
8869 |s| s.select_ranges(vec![range]),
8870 );
8871 })
8872 .ok();
8873 anyhow::Ok(())
8874 })
8875 .detach_and_log_err(cx);
8876 }
8877 MentionUri::Selection { abs_path: None, .. } => {}
8878 MentionUri::Thread { id, name } => {
8879 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
8880 panel.update(cx, |panel, cx| {
8881 panel.open_thread(id, None, Some(name.into()), window, cx)
8882 });
8883 }
8884 }
8885 MentionUri::Rule { id, .. } => {
8886 let PromptId::User { uuid } = id else {
8887 return;
8888 };
8889 window.dispatch_action(
8890 Box::new(OpenRulesLibrary {
8891 prompt_to_select: Some(uuid.0),
8892 }),
8893 cx,
8894 )
8895 }
8896 MentionUri::Fetch { url } => {
8897 cx.open_url(url.as_str());
8898 }
8899 MentionUri::Diagnostics { .. } => {}
8900 MentionUri::TerminalSelection { .. } => {}
8901 MentionUri::GitDiff { .. } => {}
8902 MentionUri::MergeConflict { .. } => {}
8903 })
8904 } else {
8905 cx.open_url(&url);
8906 }
8907}