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 placeholder = placeholder_text(agent_display_name.as_ref(), false);
348
349 let history_subscription = history.as_ref().map(|h| {
350 cx.observe(h, |this, history, cx| {
351 this.update_recent_history_from_cache(&history, cx);
352 })
353 });
354
355 let mut should_auto_submit = false;
356 let mut show_external_source_prompt_warning = false;
357
358 let message_editor = cx.new(|cx| {
359 let mut editor = MessageEditor::new(
360 workspace.clone(),
361 project.clone(),
362 thread_store,
363 history.as_ref().map(|h| h.downgrade()),
364 prompt_store,
365 session_capabilities.clone(),
366 agent_id.clone(),
367 &placeholder,
368 editor::EditorMode::AutoHeight {
369 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
370 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
371 },
372 window,
373 cx,
374 );
375 if let Some(content) = initial_content {
376 match content {
377 AgentInitialContent::ThreadSummary { session_id, title } => {
378 editor.insert_thread_summary(session_id, title, window, cx);
379 }
380 AgentInitialContent::ContentBlock {
381 blocks,
382 auto_submit,
383 } => {
384 should_auto_submit = auto_submit;
385 editor.set_message(blocks, window, cx);
386 }
387 AgentInitialContent::FromExternalSource(prompt) => {
388 show_external_source_prompt_warning = true;
389 // SECURITY: Be explicit about not auto submitting prompt from external source.
390 should_auto_submit = false;
391 editor.set_message(
392 vec![acp::ContentBlock::Text(acp::TextContent::new(
393 prompt.into_string(),
394 ))],
395 window,
396 cx,
397 );
398 }
399 }
400 } else if let Some(draft) = thread.read(cx).draft_prompt() {
401 editor.set_message(draft.to_vec(), window, cx);
402 }
403 editor
404 });
405
406 let show_codex_windows_warning = cfg!(windows)
407 && project.upgrade().is_some_and(|p| p.read(cx).is_local())
408 && agent_id.as_ref() == "Codex";
409
410 let title_editor = {
411 let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx));
412 let editor = cx.new(|cx| {
413 let mut editor = Editor::single_line(window, cx);
414 if let Some(title) = thread.read(cx).title() {
415 editor.set_text(title, window, cx);
416 } else {
417 editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
418 }
419 editor.set_read_only(!can_edit);
420 editor
421 });
422 subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
423 editor
424 };
425
426 subscriptions.push(cx.subscribe_in(
427 &entry_view_state,
428 window,
429 Self::handle_entry_view_event,
430 ));
431
432 subscriptions.push(cx.subscribe_in(
433 &message_editor,
434 window,
435 Self::handle_message_editor_event,
436 ));
437
438 subscriptions.push(cx.observe(&message_editor, |this, editor, cx| {
439 let is_empty = editor.read(cx).text(cx).is_empty();
440 let draft_contents_task = if is_empty {
441 None
442 } else {
443 Some(editor.update(cx, |editor, cx| editor.draft_contents(cx)))
444 };
445 this._draft_resolve_task = Some(cx.spawn(async move |this, cx| {
446 let draft = if let Some(task) = draft_contents_task {
447 let blocks = task.await.ok().filter(|b| !b.is_empty());
448 blocks
449 } else {
450 None
451 };
452 this.update(cx, |this, cx| {
453 this.thread.update(cx, |thread, _cx| {
454 thread.set_draft_prompt(draft);
455 });
456 this.schedule_save(cx);
457 })
458 .ok();
459 }));
460 }));
461
462 let recent_history_entries = history
463 .as_ref()
464 .map(|h| h.read(cx).get_recent_sessions(3))
465 .unwrap_or_default();
466
467 let mut this = Self {
468 id,
469 parent_id,
470 focus_handle: cx.focus_handle(),
471 thread,
472 conversation,
473 server_view,
474 agent_icon,
475 agent_icon_from_external_svg,
476 agent_id,
477 workspace,
478 entry_view_state,
479 title_editor,
480 config_options_view,
481 mode_selector,
482 model_selector,
483 profile_selector,
484 list_state,
485 session_capabilities,
486 resumed_without_history,
487 _subscriptions: subscriptions,
488 permission_dropdown_handle: PopoverMenuHandle::default(),
489 thread_retry_status: None,
490 thread_error: None,
491 thread_error_markdown: None,
492 token_limit_callout_dismissed: false,
493 last_token_limit_telemetry: None,
494 thread_feedback: Default::default(),
495 expanded_tool_calls: HashSet::default(),
496 expanded_tool_call_raw_inputs: HashSet::default(),
497 expanded_thinking_blocks: HashSet::default(),
498 auto_expanded_thinking_block: None,
499 user_toggled_thinking_blocks: HashSet::default(),
500 subagent_scroll_handles: RefCell::new(HashMap::default()),
501 edits_expanded: false,
502 plan_expanded: false,
503 queue_expanded: true,
504 editor_expanded: false,
505 should_be_following: false,
506 editing_message: None,
507 local_queued_messages: Vec::new(),
508 queued_message_editors: Vec::new(),
509 queued_message_editor_subscriptions: Vec::new(),
510 last_synced_queue_length: 0,
511 turn_fields: TurnFields::default(),
512 discarded_partial_edits: HashSet::default(),
513 is_loading_contents: false,
514 new_server_version_available: None,
515 permission_selections: HashMap::default(),
516 resume_thread_metadata: None,
517 _cancel_task: None,
518 _save_task: None,
519 _draft_resolve_task: None,
520 skip_queue_processing_count: 0,
521 user_interrupted_generation: false,
522 can_fast_track_queue: false,
523 hovered_edited_file_buttons: None,
524 in_flight_prompt: None,
525 message_editor,
526 add_context_menu_handle: PopoverMenuHandle::default(),
527 thinking_effort_menu_handle: PopoverMenuHandle::default(),
528 project,
529 recent_history_entries,
530 hovered_recent_history_item: None,
531 show_external_source_prompt_warning,
532 history,
533 _history_subscription: history_subscription,
534 show_codex_windows_warning,
535 generating_indicator_in_list: false,
536 };
537
538 this.sync_generating_indicator(cx);
539 this.sync_editor_mode_for_empty_state(cx);
540 let list_state_for_scroll = this.list_state.clone();
541 let thread_view = cx.entity().downgrade();
542
543 this.list_state
544 .set_scroll_handler(move |_event, _window, cx| {
545 let list_state = list_state_for_scroll.clone();
546 let thread_view = thread_view.clone();
547 // N.B. We must defer because the scroll handler is called while the
548 // ListState's RefCell is mutably borrowed. Reading logical_scroll_top()
549 // directly would panic from a double borrow.
550 cx.defer(move |cx| {
551 let scroll_top = list_state.logical_scroll_top();
552 let _ = thread_view.update(cx, |this, cx| {
553 if let Some(thread) = this.as_native_thread(cx) {
554 thread.update(cx, |thread, _cx| {
555 thread.set_ui_scroll_position(Some(scroll_top));
556 });
557 }
558 this.schedule_save(cx);
559 });
560 });
561 });
562
563 if should_auto_submit {
564 this.send(window, cx);
565 }
566 this
567 }
568
569 /// Schedule a throttled save of the thread state (draft prompt, scroll position, etc.).
570 /// Multiple calls within `SERIALIZATION_THROTTLE_TIME` are coalesced into a single save.
571 fn schedule_save(&mut self, cx: &mut Context<Self>) {
572 self._save_task = Some(cx.spawn(async move |this, cx| {
573 cx.background_executor()
574 .timer(SERIALIZATION_THROTTLE_TIME)
575 .await;
576 this.update(cx, |this, cx| {
577 if let Some(thread) = this.as_native_thread(cx) {
578 thread.update(cx, |_thread, cx| cx.notify());
579 }
580 })
581 .ok();
582 }));
583 }
584
585 pub fn handle_message_editor_event(
586 &mut self,
587 _editor: &Entity<MessageEditor>,
588 event: &MessageEditorEvent,
589 window: &mut Window,
590 cx: &mut Context<Self>,
591 ) {
592 match event {
593 MessageEditorEvent::Send => self.send(window, cx),
594 MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
595 MessageEditorEvent::Cancel => self.cancel_generation(cx),
596 MessageEditorEvent::Focus => {
597 self.cancel_editing(&Default::default(), window, cx);
598 }
599 MessageEditorEvent::LostFocus => {}
600 MessageEditorEvent::InputAttempted { .. } => {}
601 }
602 }
603
604 pub(crate) fn as_native_connection(
605 &self,
606 cx: &App,
607 ) -> Option<Rc<agent::NativeAgentConnection>> {
608 let acp_thread = self.thread.read(cx);
609 acp_thread.connection().clone().downcast()
610 }
611
612 pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
613 let acp_thread = self.thread.read(cx);
614 self.as_native_connection(cx)?
615 .thread(acp_thread.session_id(), cx)
616 }
617
618 /// Resolves the message editor's contents into content blocks. For profiles
619 /// that do not enable any tools, directory mentions are expanded to inline
620 /// file contents since the agent can't read files on its own.
621 fn resolve_message_contents(
622 &self,
623 message_editor: &Entity<MessageEditor>,
624 cx: &mut App,
625 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
626 let expand = self.as_native_thread(cx).is_some_and(|thread| {
627 let thread = thread.read(cx);
628 AgentSettings::get_global(cx)
629 .profiles
630 .get(thread.profile())
631 .is_some_and(|profile| profile.tools.is_empty())
632 });
633 message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx))
634 }
635
636 pub fn current_model_id(&self, cx: &App) -> Option<String> {
637 let selector = self.model_selector.as_ref()?;
638 let model = selector.read(cx).active_model(cx)?;
639 Some(model.id.to_string())
640 }
641
642 pub fn current_mode_id(&self, cx: &App) -> Option<Arc<str>> {
643 if let Some(thread) = self.as_native_thread(cx) {
644 Some(thread.read(cx).profile().0.clone())
645 } else {
646 let mode_selector = self.mode_selector.as_ref()?;
647 Some(mode_selector.read(cx).mode().0)
648 }
649 }
650
651 fn is_subagent(&self) -> bool {
652 self.parent_id.is_some()
653 }
654
655 /// Returns the currently active editor, either for a message that is being
656 /// edited or the editor for a new message.
657 pub(crate) fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
658 if let Some(index) = self.editing_message
659 && let Some(editor) = self
660 .entry_view_state
661 .read(cx)
662 .entry(index)
663 .and_then(|entry| entry.message_editor())
664 .cloned()
665 {
666 editor
667 } else {
668 self.message_editor.clone()
669 }
670 }
671
672 pub fn has_queued_messages(&self) -> bool {
673 !self.local_queued_messages.is_empty()
674 }
675
676 pub fn is_imported_thread(&self, cx: &App) -> bool {
677 let Some(thread) = self.as_native_thread(cx) else {
678 return false;
679 };
680 thread.read(cx).is_imported()
681 }
682
683 // events
684
685 pub fn handle_entry_view_event(
686 &mut self,
687 _: &Entity<EntryViewState>,
688 event: &EntryViewEvent,
689 window: &mut Window,
690 cx: &mut Context<Self>,
691 ) {
692 match &event.view_event {
693 ViewEvent::NewDiff(tool_call_id) => {
694 if AgentSettings::get_global(cx).expand_edit_card {
695 self.expanded_tool_calls.insert(tool_call_id.clone());
696 }
697 }
698 ViewEvent::NewTerminal(tool_call_id) => {
699 if AgentSettings::get_global(cx).expand_terminal_card {
700 self.expanded_tool_calls.insert(tool_call_id.clone());
701 }
702 }
703 ViewEvent::TerminalMovedToBackground(tool_call_id) => {
704 self.expanded_tool_calls.remove(tool_call_id);
705 }
706 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
707 if let Some(AgentThreadEntry::UserMessage(user_message)) =
708 self.thread.read(cx).entries().get(event.entry_index)
709 && user_message.id.is_some()
710 && !self.is_subagent()
711 {
712 self.editing_message = Some(event.entry_index);
713 cx.notify();
714 }
715 }
716 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
717 if let Some(AgentThreadEntry::UserMessage(user_message)) =
718 self.thread.read(cx).entries().get(event.entry_index)
719 && user_message.id.is_some()
720 && !self.is_subagent()
721 {
722 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
723 self.editing_message = None;
724 cx.notify();
725 }
726 }
727 }
728 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {}
729 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
730 if !self.is_subagent() {
731 self.regenerate(event.entry_index, editor.clone(), window, cx);
732 }
733 }
734 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
735 self.cancel_editing(&Default::default(), window, cx);
736 }
737 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {}
738 ViewEvent::OpenDiffLocation {
739 path,
740 position,
741 split,
742 } => {
743 self.open_diff_location(path, *position, *split, window, cx);
744 }
745 }
746 }
747
748 fn open_diff_location(
749 &self,
750 path: &str,
751 position: Point,
752 split: bool,
753 window: &mut Window,
754 cx: &mut Context<Self>,
755 ) {
756 let Some(project) = self.project.upgrade() else {
757 return;
758 };
759 let Some(project_path) = project.read(cx).find_project_path(path, cx) else {
760 return;
761 };
762
763 let open_task = if split {
764 self.workspace
765 .update(cx, |workspace, cx| {
766 workspace.split_path(project_path, window, cx)
767 })
768 .log_err()
769 } else {
770 self.workspace
771 .update(cx, |workspace, cx| {
772 workspace.open_path(project_path, None, true, window, cx)
773 })
774 .log_err()
775 };
776
777 let Some(open_task) = open_task else {
778 return;
779 };
780
781 window
782 .spawn(cx, async move |cx| {
783 let item = open_task.await?;
784 let Some(editor) = item.downcast::<Editor>() else {
785 return anyhow::Ok(());
786 };
787 editor.update_in(cx, |editor, window, cx| {
788 editor.change_selections(
789 SelectionEffects::scroll(Autoscroll::center()),
790 window,
791 cx,
792 |selections| {
793 selections.select_ranges([position..position]);
794 },
795 );
796 })?;
797 anyhow::Ok(())
798 })
799 .detach_and_log_err(cx);
800 }
801
802 // turns
803
804 pub fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
805 self.turn_fields.turn_generation += 1;
806 let generation = self.turn_fields.turn_generation;
807 self.turn_fields.turn_started_at = Some(Instant::now());
808 self.turn_fields.last_turn_duration = None;
809 self.turn_fields.last_turn_tokens = None;
810 self.turn_fields.turn_tokens = Some(0);
811 self.turn_fields._turn_timer_task = Some(cx.spawn(async move |this, cx| {
812 loop {
813 cx.background_executor().timer(Duration::from_secs(1)).await;
814 if this.update(cx, |_, cx| cx.notify()).is_err() {
815 break;
816 }
817 }
818 }));
819 if self.parent_id.is_none() {
820 self.suppress_merge_conflict_notification(cx);
821 }
822 generation
823 }
824
825 pub fn stop_turn(&mut self, generation: usize, cx: &mut Context<Self>) {
826 if self.turn_fields.turn_generation != generation {
827 return;
828 }
829 self.turn_fields.last_turn_duration = self
830 .turn_fields
831 .turn_started_at
832 .take()
833 .map(|started| started.elapsed());
834 self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take();
835 self.turn_fields._turn_timer_task = None;
836 if self.parent_id.is_none() {
837 self.unsuppress_merge_conflict_notification(cx);
838 }
839 }
840
841 fn suppress_merge_conflict_notification(&self, cx: &mut Context<Self>) {
842 self.workspace
843 .update(cx, |workspace, cx| {
844 workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx);
845 })
846 .ok();
847 }
848
849 fn unsuppress_merge_conflict_notification(&self, cx: &mut Context<Self>) {
850 self.workspace
851 .update(cx, |workspace, _cx| {
852 workspace.unsuppress(workspace::merge_conflict_notification_id());
853 })
854 .ok();
855 }
856
857 pub fn update_turn_tokens(&mut self, cx: &App) {
858 if let Some(usage) = self.thread.read(cx).token_usage() {
859 if let Some(tokens) = &mut self.turn_fields.turn_tokens {
860 *tokens += usage.output_tokens;
861 }
862 }
863 }
864
865 // sending
866
867 fn clear_external_source_prompt_warning(&mut self, cx: &mut Context<Self>) {
868 if self.show_external_source_prompt_warning {
869 self.show_external_source_prompt_warning = false;
870 cx.notify();
871 }
872 }
873
874 pub fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
875 let thread = &self.thread;
876
877 if self.is_loading_contents {
878 return;
879 }
880
881 let message_editor = self.message_editor.clone();
882
883 // Intercept the first send so the agent panel can capture the full
884 // content blocks — needed for "Start thread in New Worktree",
885 // which must create a workspace before sending the message there.
886 let intercept_first_send = self.thread.read(cx).entries().is_empty()
887 && !message_editor.read(cx).is_empty(cx)
888 && self
889 .workspace
890 .upgrade()
891 .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
892 .is_some_and(|panel| {
893 panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree
894 });
895
896 if intercept_first_send {
897 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
898 let content_task = self.resolve_message_contents(&message_editor, cx);
899
900 cx.spawn(async move |this, cx| match content_task.await {
901 Ok((content, _tracked_buffers)) => {
902 if content.is_empty() {
903 return;
904 }
905
906 this.update(cx, |_, cx| {
907 cx.emit(AcpThreadViewEvent::FirstSendRequested { content });
908 })
909 .ok();
910 }
911 Err(error) => {
912 this.update(cx, |this, cx| {
913 this.handle_thread_error(error, cx);
914 })
915 .ok();
916 }
917 })
918 .detach();
919
920 return;
921 }
922
923 let is_editor_empty = message_editor.read(cx).is_empty(cx);
924 let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
925
926 let has_queued = self.has_queued_messages();
927 if is_editor_empty && self.can_fast_track_queue && has_queued {
928 self.can_fast_track_queue = false;
929 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
930 self.send_queued_message_at_index(0, true, window, cx);
931 return;
932 }
933
934 if is_editor_empty {
935 return;
936 }
937
938 if is_generating {
939 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
940 self.queue_message(message_editor, window, cx);
941 return;
942 }
943
944 let text = message_editor.read(cx).text(cx);
945 let text = text.trim();
946 if text == "/login" || text == "/logout" {
947 let connection = thread.read(cx).connection().clone();
948 let can_login = !connection.auth_methods().is_empty();
949 // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
950 let logout_supported = text == "/logout"
951 && self
952 .session_capabilities
953 .read()
954 .available_commands()
955 .iter()
956 .any(|command| command.name == "logout");
957 if can_login && !logout_supported {
958 message_editor.update(cx, |editor, cx| editor.clear(window, cx));
959 self.clear_external_source_prompt_warning(cx);
960
961 let connection = self.thread.read(cx).connection().clone();
962 window.defer(cx, {
963 let agent_id = self.agent_id.clone();
964 let server_view = self.server_view.clone();
965 move |window, cx| {
966 ConversationView::handle_auth_required(
967 server_view.clone(),
968 AuthRequired::new(),
969 agent_id,
970 connection,
971 window,
972 cx,
973 );
974 }
975 });
976 cx.notify();
977 return;
978 }
979 }
980
981 cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
982 self.send_impl(message_editor, window, cx)
983 }
984
985 pub fn send_impl(
986 &mut self,
987 message_editor: Entity<MessageEditor>,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) {
991 let contents = self.resolve_message_contents(&message_editor, cx);
992
993 self.thread_error.take();
994 self.thread_feedback.clear();
995 self.editing_message.take();
996
997 if self.should_be_following {
998 self.workspace
999 .update(cx, |workspace, cx| {
1000 workspace.follow(CollaboratorId::Agent, window, cx);
1001 })
1002 .ok();
1003 }
1004
1005 let contents_task = cx.spawn_in(window, async move |_this, cx| {
1006 let (contents, tracked_buffers) = contents.await?;
1007
1008 if contents.is_empty() {
1009 return Ok(None);
1010 }
1011
1012 let _ = cx.update(|window, cx| {
1013 message_editor.update(cx, |message_editor, cx| {
1014 message_editor.clear(window, cx);
1015 });
1016 });
1017
1018 Ok(Some((contents, tracked_buffers)))
1019 });
1020
1021 self.send_content(contents_task, window, cx);
1022 }
1023
1024 pub fn send_content(
1025 &mut self,
1026 contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
1027 window: &mut Window,
1028 cx: &mut Context<Self>,
1029 ) {
1030 let session_id = self.thread.read(cx).session_id().clone();
1031 let parent_session_id = self.thread.read(cx).parent_session_id().cloned();
1032 let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
1033 let is_first_message = self.thread.read(cx).entries().is_empty();
1034 let thread = self.thread.downgrade();
1035
1036 self.is_loading_contents = true;
1037
1038 let model_id = self.current_model_id(cx);
1039 let mode_id = self.current_mode_id(cx);
1040 let guard = cx.new(|_| ());
1041 cx.observe_release(&guard, |this, _guard, cx| {
1042 this.is_loading_contents = false;
1043 cx.notify();
1044 })
1045 .detach();
1046
1047 let task = cx.spawn_in(window, async move |this, cx| {
1048 let Some((contents, tracked_buffers)) = contents_task.await? else {
1049 return Ok(());
1050 };
1051
1052 let generation = this.update(cx, |this, cx| {
1053 this.clear_external_source_prompt_warning(cx);
1054 let generation = this.start_turn(cx);
1055 this.in_flight_prompt = Some(contents.clone());
1056 generation
1057 })?;
1058
1059 this.update_in(cx, |this, _window, cx| {
1060 this.set_editor_is_expanded(false, cx);
1061 })?;
1062
1063 let _ = this.update(cx, |this, cx| {
1064 this.list_state.scroll_to_end();
1065 cx.notify();
1066 });
1067
1068 let _stop_turn = defer({
1069 let this = this.clone();
1070 let mut cx = cx.clone();
1071 move || {
1072 this.update(&mut cx, |this, cx| {
1073 this.stop_turn(generation, cx);
1074 cx.notify();
1075 })
1076 .ok();
1077 }
1078 });
1079 if is_first_message && thread.read_with(cx, |thread, _cx| thread.title().is_none())? {
1080 let text: String = contents
1081 .iter()
1082 .filter_map(|block| match block {
1083 acp::ContentBlock::Text(text_content) => Some(text_content.text.clone()),
1084 acp::ContentBlock::ResourceLink(resource_link) => {
1085 Some(format!("@{}", resource_link.name))
1086 }
1087 _ => None,
1088 })
1089 .collect::<Vec<_>>()
1090 .join(" ");
1091 let text = text.lines().next().unwrap_or("").trim();
1092 if !text.is_empty() {
1093 let title: SharedString = util::truncate_and_trailoff(text, 200).into();
1094 thread.update(cx, |thread, cx| {
1095 thread.set_provisional_title(title, cx);
1096 })?;
1097 }
1098 }
1099
1100 let turn_start_time = Instant::now();
1101 let send = thread.update(cx, |thread, cx| {
1102 thread.action_log().update(cx, |action_log, cx| {
1103 for buffer in tracked_buffers {
1104 action_log.buffer_read(buffer, cx)
1105 }
1106 });
1107 drop(guard);
1108
1109 telemetry::event!(
1110 "Agent Message Sent",
1111 agent = agent_telemetry_id,
1112 session = session_id,
1113 parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()),
1114 model = model_id,
1115 mode = mode_id
1116 );
1117
1118 thread.send(contents, cx)
1119 })?;
1120
1121 let _ = this.update(cx, |this, cx| {
1122 this.sync_generating_indicator(cx);
1123 cx.notify();
1124 });
1125
1126 let res = send.await;
1127 let turn_time_ms = turn_start_time.elapsed().as_millis();
1128 drop(_stop_turn);
1129 let status = if res.is_ok() {
1130 let _ = this.update(cx, |this, _| this.in_flight_prompt.take());
1131 "success"
1132 } else {
1133 "failure"
1134 };
1135 telemetry::event!(
1136 "Agent Turn Completed",
1137 agent = agent_telemetry_id,
1138 session = session_id,
1139 parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()),
1140 model = model_id,
1141 mode = mode_id,
1142 status,
1143 turn_time_ms,
1144 );
1145 res.map(|_| ())
1146 });
1147
1148 cx.spawn(async move |this, cx| {
1149 if let Err(err) = task.await {
1150 this.update(cx, |this, cx| {
1151 this.handle_thread_error(err, cx);
1152 })
1153 .ok();
1154 } else {
1155 this.update(cx, |this, cx| {
1156 let should_be_following = this
1157 .workspace
1158 .update(cx, |workspace, _| {
1159 workspace.is_being_followed(CollaboratorId::Agent)
1160 })
1161 .unwrap_or_default();
1162 this.should_be_following = should_be_following;
1163 })
1164 .ok();
1165 }
1166 })
1167 .detach();
1168 }
1169
1170 pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1171 let thread = &self.thread;
1172
1173 if self.is_loading_contents {
1174 return;
1175 }
1176
1177 let message_editor = self.message_editor.clone();
1178 if thread.read(cx).status() == ThreadStatus::Idle {
1179 self.send_impl(message_editor, window, cx);
1180 return;
1181 }
1182
1183 self.stop_current_and_send_new_message(message_editor, window, cx);
1184 }
1185
1186 fn stop_current_and_send_new_message(
1187 &mut self,
1188 message_editor: Entity<MessageEditor>,
1189 window: &mut Window,
1190 cx: &mut Context<Self>,
1191 ) {
1192 let thread = self.thread.clone();
1193 self.skip_queue_processing_count = 0;
1194 self.user_interrupted_generation = true;
1195
1196 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1197
1198 cx.spawn_in(window, async move |this, cx| {
1199 cancelled.await;
1200
1201 this.update_in(cx, |this, window, cx| {
1202 this.send_impl(message_editor, window, cx);
1203 })
1204 .ok();
1205 })
1206 .detach();
1207 }
1208
1209 pub(crate) fn handle_thread_error(
1210 &mut self,
1211 error: impl Into<ThreadError>,
1212 cx: &mut Context<Self>,
1213 ) {
1214 let error = error.into();
1215 self.emit_thread_error_telemetry(&error, cx);
1216 self.thread_error = Some(error);
1217 cx.notify();
1218 }
1219
1220 fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context<Self>) {
1221 let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
1222 match error {
1223 ThreadError::PaymentRequired => (
1224 "payment_required",
1225 None,
1226 "You reached your free usage limit. Upgrade to Zed Pro for more prompts."
1227 .into(),
1228 ),
1229 ThreadError::Refusal => {
1230 let model_or_agent_name = self.current_model_name(cx);
1231 let message = format!(
1232 "{} 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.",
1233 model_or_agent_name
1234 );
1235 ("refusal", None, message.into())
1236 }
1237 ThreadError::AuthenticationRequired(message) => {
1238 ("authentication_required", None, message.clone())
1239 }
1240 ThreadError::Other {
1241 acp_error_code,
1242 message,
1243 } => ("other", acp_error_code.clone(), message.clone()),
1244 };
1245
1246 let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
1247 let session_id = self.thread.read(cx).session_id().clone();
1248 let parent_session_id = self
1249 .thread
1250 .read(cx)
1251 .parent_session_id()
1252 .map(|id| id.to_string());
1253
1254 telemetry::event!(
1255 "Agent Panel Error Shown",
1256 agent = agent_telemetry_id,
1257 session_id = session_id,
1258 parent_session_id = parent_session_id,
1259 kind = error_kind,
1260 acp_error_code = acp_error_code,
1261 message = message,
1262 );
1263 }
1264
1265 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1266 self.thread_retry_status.take();
1267 self.thread_error.take();
1268 self.user_interrupted_generation = true;
1269 self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx)));
1270 self.sync_generating_indicator(cx);
1271 cx.notify();
1272 }
1273
1274 pub fn retry_generation(&mut self, cx: &mut Context<Self>) {
1275 self.thread_error.take();
1276
1277 let thread = &self.thread;
1278 if !thread.read(cx).can_retry(cx) {
1279 return;
1280 }
1281
1282 let task = thread.update(cx, |thread, cx| thread.retry(cx));
1283 self.sync_generating_indicator(cx);
1284 cx.notify();
1285 cx.spawn(async move |this, cx| {
1286 let result = task.await;
1287
1288 this.update(cx, |this, cx| {
1289 if let Err(err) = result {
1290 this.handle_thread_error(err, cx);
1291 }
1292 })
1293 })
1294 .detach();
1295 }
1296
1297 pub fn regenerate(
1298 &mut self,
1299 entry_ix: usize,
1300 message_editor: Entity<MessageEditor>,
1301 window: &mut Window,
1302 cx: &mut Context<Self>,
1303 ) {
1304 if self.is_loading_contents {
1305 return;
1306 }
1307 let thread = self.thread.clone();
1308
1309 let Some(user_message_id) = thread.update(cx, |thread, _| {
1310 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1311 }) else {
1312 return;
1313 };
1314
1315 cx.spawn_in(window, async move |this, cx| {
1316 // Check if there are any edits from prompts before the one being regenerated.
1317 //
1318 // If there are, we keep/accept them since we're not regenerating the prompt that created them.
1319 //
1320 // If editing the prompt that generated the edits, they are auto-rejected
1321 // through the `rewind` function in the `acp_thread`.
1322 let has_earlier_edits = thread.read_with(cx, |thread, _| {
1323 thread
1324 .entries()
1325 .iter()
1326 .take(entry_ix)
1327 .any(|entry| entry.diffs().next().is_some())
1328 });
1329
1330 if has_earlier_edits {
1331 thread.update(cx, |thread, cx| {
1332 thread.action_log().update(cx, |action_log, cx| {
1333 action_log.keep_all_edits(None, cx);
1334 });
1335 });
1336 }
1337
1338 thread
1339 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))
1340 .await?;
1341 this.update_in(cx, |thread, window, cx| {
1342 thread.send_impl(message_editor, window, cx);
1343 thread.focus_handle(cx).focus(window, cx);
1344 })?;
1345 anyhow::Ok(())
1346 })
1347 .detach_and_log_err(cx);
1348 }
1349
1350 // message queueing
1351
1352 fn queue_message(
1353 &mut self,
1354 message_editor: Entity<MessageEditor>,
1355 window: &mut Window,
1356 cx: &mut Context<Self>,
1357 ) {
1358 let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle;
1359
1360 if is_idle {
1361 self.send_impl(message_editor, window, cx);
1362 return;
1363 }
1364
1365 let contents = self.resolve_message_contents(&message_editor, cx);
1366
1367 cx.spawn_in(window, async move |this, cx| {
1368 let (content, tracked_buffers) = contents.await?;
1369
1370 if content.is_empty() {
1371 return Ok::<(), anyhow::Error>(());
1372 }
1373
1374 this.update_in(cx, |this, window, cx| {
1375 this.add_to_queue(content, tracked_buffers, cx);
1376 this.can_fast_track_queue = true;
1377 message_editor.update(cx, |message_editor, cx| {
1378 message_editor.clear(window, cx);
1379 });
1380 cx.notify();
1381 })?;
1382 Ok(())
1383 })
1384 .detach_and_log_err(cx);
1385 }
1386
1387 pub fn add_to_queue(
1388 &mut self,
1389 content: Vec<acp::ContentBlock>,
1390 tracked_buffers: Vec<Entity<Buffer>>,
1391 cx: &mut Context<Self>,
1392 ) {
1393 self.local_queued_messages.push(QueuedMessage {
1394 content,
1395 tracked_buffers,
1396 });
1397 self.sync_queue_flag_to_native_thread(cx);
1398 }
1399
1400 pub fn remove_from_queue(
1401 &mut self,
1402 index: usize,
1403 cx: &mut Context<Self>,
1404 ) -> Option<QueuedMessage> {
1405 if index < self.local_queued_messages.len() {
1406 let removed = self.local_queued_messages.remove(index);
1407 self.sync_queue_flag_to_native_thread(cx);
1408 Some(removed)
1409 } else {
1410 None
1411 }
1412 }
1413
1414 pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<Self>) {
1415 if let Some(native_thread) = self.as_native_thread(cx) {
1416 let has_queued = self.has_queued_messages();
1417 native_thread.update(cx, |thread, _| {
1418 thread.set_has_queued_message(has_queued);
1419 });
1420 }
1421 }
1422
1423 pub fn send_queued_message_at_index(
1424 &mut self,
1425 index: usize,
1426 is_send_now: bool,
1427 window: &mut Window,
1428 cx: &mut Context<Self>,
1429 ) {
1430 let Some(queued) = self.remove_from_queue(index, cx) else {
1431 return;
1432 };
1433 let content = queued.content;
1434 let tracked_buffers = queued.tracked_buffers;
1435
1436 // Only increment skip count for "Send Now" operations (out-of-order sends)
1437 // Normal auto-processing from the Stopped handler doesn't need to skip.
1438 // We only skip the Stopped event from the cancelled generation, NOT the
1439 // Stopped event from the newly sent message (which should trigger queue processing).
1440 if is_send_now {
1441 let is_generating =
1442 self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
1443 self.skip_queue_processing_count += if is_generating { 1 } else { 0 };
1444 }
1445
1446 let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx));
1447
1448 let workspace = self.workspace.clone();
1449
1450 let should_be_following = self.should_be_following;
1451 let contents_task = cx.spawn_in(window, async move |_this, cx| {
1452 cancelled.await;
1453 if should_be_following {
1454 workspace
1455 .update_in(cx, |workspace, window, cx| {
1456 workspace.follow(CollaboratorId::Agent, window, cx);
1457 })
1458 .ok();
1459 }
1460
1461 Ok(Some((content, tracked_buffers)))
1462 });
1463
1464 self.send_content(contents_task, window, cx);
1465 }
1466
1467 pub fn move_queued_message_to_main_editor(
1468 &mut self,
1469 index: usize,
1470 inserted_text: Option<&str>,
1471 cursor_offset: Option<usize>,
1472 window: &mut Window,
1473 cx: &mut Context<Self>,
1474 ) -> bool {
1475 let Some(queued_message) = self.remove_from_queue(index, cx) else {
1476 return false;
1477 };
1478 let queued_content = queued_message.content;
1479 let message_editor = self.message_editor.clone();
1480 let inserted_text = inserted_text.map(ToOwned::to_owned);
1481
1482 window.focus(&message_editor.focus_handle(cx), cx);
1483
1484 if message_editor.read(cx).is_empty(cx) {
1485 message_editor.update(cx, |editor, cx| {
1486 editor.set_message(queued_content, window, cx);
1487 if let Some(offset) = cursor_offset {
1488 editor.set_cursor_offset(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 cx.notify();
1495 return true;
1496 }
1497
1498 // Adjust cursor offset accounting for existing content
1499 let existing_len = message_editor.read(cx).text(cx).len();
1500 let separator = "\n\n";
1501
1502 message_editor.update(cx, |editor, cx| {
1503 editor.append_message(queued_content, Some(separator), window, cx);
1504 if let Some(offset) = cursor_offset {
1505 let adjusted_offset = existing_len + separator.len() + offset;
1506 editor.set_cursor_offset(adjusted_offset, window, cx);
1507 }
1508 if let Some(inserted_text) = inserted_text.as_deref() {
1509 editor.insert_text(inserted_text, window, cx);
1510 }
1511 });
1512
1513 cx.notify();
1514 true
1515 }
1516
1517 // editor methods
1518
1519 pub fn expand_message_editor(
1520 &mut self,
1521 _: &ExpandMessageEditor,
1522 _window: &mut Window,
1523 cx: &mut Context<Self>,
1524 ) {
1525 self.set_editor_is_expanded(!self.editor_expanded, cx);
1526 cx.stop_propagation();
1527 cx.notify();
1528 }
1529
1530 pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
1531 self.editor_expanded = is_expanded;
1532 self.message_editor.update(cx, |editor, cx| {
1533 if is_expanded {
1534 editor.set_mode(
1535 EditorMode::Full {
1536 scale_ui_elements_with_buffer_font_size: false,
1537 show_active_line_background: false,
1538 sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
1539 },
1540 cx,
1541 )
1542 } else {
1543 let agent_settings = AgentSettings::get_global(cx);
1544 editor.set_mode(
1545 EditorMode::AutoHeight {
1546 min_lines: agent_settings.message_editor_min_lines,
1547 max_lines: Some(agent_settings.set_message_editor_max_lines()),
1548 },
1549 cx,
1550 )
1551 }
1552 });
1553 cx.notify();
1554 }
1555
1556 pub fn handle_title_editor_event(
1557 &mut self,
1558 title_editor: &Entity<Editor>,
1559 event: &EditorEvent,
1560 window: &mut Window,
1561 cx: &mut Context<Self>,
1562 ) {
1563 let thread = &self.thread;
1564
1565 match event {
1566 EditorEvent::BufferEdited => {
1567 // We only want to set the title if the user has actively edited
1568 // it. If the title editor is not focused, we programmatically
1569 // changed the text, so we don't want to set the title again.
1570 if !title_editor.read(cx).is_focused(window) {
1571 return;
1572 }
1573
1574 let new_title = title_editor.read(cx).text(cx);
1575 thread.update(cx, |thread, cx| {
1576 thread
1577 .set_title(new_title.into(), cx)
1578 .detach_and_log_err(cx);
1579 })
1580 }
1581 EditorEvent::Blurred => {
1582 if title_editor.read(cx).text(cx).is_empty() {
1583 title_editor.update(cx, |editor, cx| {
1584 editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
1585 });
1586 }
1587 }
1588 _ => {}
1589 }
1590 }
1591
1592 pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1593 if let Some(index) = self.editing_message.take()
1594 && let Some(editor) = &self
1595 .entry_view_state
1596 .read(cx)
1597 .entry(index)
1598 .and_then(|e| e.message_editor())
1599 .cloned()
1600 {
1601 editor.update(cx, |editor, cx| {
1602 if let Some(user_message) = self
1603 .thread
1604 .read(cx)
1605 .entries()
1606 .get(index)
1607 .and_then(|e| e.user_message())
1608 {
1609 editor.set_message(user_message.chunks.clone(), window, cx);
1610 }
1611 })
1612 };
1613 self.message_editor.focus_handle(cx).focus(window, cx);
1614 cx.notify();
1615 }
1616
1617 pub fn authorize_tool_call(
1618 &mut self,
1619 session_id: acp::SessionId,
1620 tool_call_id: acp::ToolCallId,
1621 outcome: SelectedPermissionOutcome,
1622 window: &mut Window,
1623 cx: &mut Context<Self>,
1624 ) {
1625 self.conversation.update(cx, |conversation, cx| {
1626 conversation.authorize_tool_call(session_id, tool_call_id, outcome, cx);
1627 });
1628 if self.should_be_following {
1629 self.workspace
1630 .update(cx, |workspace, cx| {
1631 workspace.follow(CollaboratorId::Agent, window, cx);
1632 })
1633 .ok();
1634 }
1635 cx.notify();
1636 }
1637
1638 pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
1639 self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
1640 }
1641
1642 pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
1643 self.authorize_pending_with_granularity(true, window, cx);
1644 }
1645
1646 pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
1647 self.authorize_pending_with_granularity(false, window, cx);
1648 }
1649
1650 pub fn authorize_pending_tool_call(
1651 &mut self,
1652 kind: acp::PermissionOptionKind,
1653 window: &mut Window,
1654 cx: &mut Context<Self>,
1655 ) -> Option<()> {
1656 self.conversation.update(cx, |conversation, cx| {
1657 conversation.authorize_pending_tool_call(&self.id, kind, cx)
1658 })?;
1659 if self.should_be_following {
1660 self.workspace
1661 .update(cx, |workspace, cx| {
1662 workspace.follow(CollaboratorId::Agent, window, cx);
1663 })
1664 .ok();
1665 }
1666 cx.notify();
1667 Some(())
1668 }
1669
1670 fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool {
1671 if let AgentThreadEntry::ToolCall(tool_call) = entry {
1672 matches!(
1673 tool_call.status,
1674 ToolCallStatus::WaitingForConfirmation { .. }
1675 )
1676 } else {
1677 false
1678 }
1679 }
1680
1681 fn handle_authorize_tool_call(
1682 &mut self,
1683 action: &AuthorizeToolCall,
1684 window: &mut Window,
1685 cx: &mut Context<Self>,
1686 ) {
1687 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1688 let option_id = acp::PermissionOptionId::new(action.option_id.clone());
1689 let option_kind = match action.option_kind.as_str() {
1690 "AllowOnce" => acp::PermissionOptionKind::AllowOnce,
1691 "AllowAlways" => acp::PermissionOptionKind::AllowAlways,
1692 "RejectOnce" => acp::PermissionOptionKind::RejectOnce,
1693 "RejectAlways" => acp::PermissionOptionKind::RejectAlways,
1694 _ => acp::PermissionOptionKind::AllowOnce,
1695 };
1696
1697 self.authorize_tool_call(
1698 self.id.clone(),
1699 tool_call_id,
1700 SelectedPermissionOutcome::new(option_id, option_kind),
1701 window,
1702 cx,
1703 );
1704 }
1705
1706 pub fn handle_select_permission_granularity(
1707 &mut self,
1708 action: &SelectPermissionGranularity,
1709 _window: &mut Window,
1710 cx: &mut Context<Self>,
1711 ) {
1712 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1713 self.permission_selections
1714 .insert(tool_call_id, PermissionSelection::Choice(action.index));
1715
1716 cx.notify();
1717 }
1718
1719 pub fn handle_toggle_command_pattern(
1720 &mut self,
1721 action: &crate::ToggleCommandPattern,
1722 _window: &mut Window,
1723 cx: &mut Context<Self>,
1724 ) {
1725 let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
1726
1727 match self.permission_selections.get_mut(&tool_call_id) {
1728 Some(PermissionSelection::SelectedPatterns(checked)) => {
1729 // Already in pattern mode — toggle the individual pattern.
1730 if let Some(pos) = checked.iter().position(|&i| i == action.pattern_index) {
1731 checked.swap_remove(pos);
1732 } else {
1733 checked.push(action.pattern_index);
1734 }
1735 }
1736 _ => {
1737 // First click: activate "Select options" with all patterns checked.
1738 let thread = self.thread.read(cx);
1739 let pattern_count = thread
1740 .entries()
1741 .iter()
1742 .find_map(|entry| {
1743 if let AgentThreadEntry::ToolCall(call) = entry {
1744 if call.id == tool_call_id {
1745 if let ToolCallStatus::WaitingForConfirmation { options, .. } =
1746 &call.status
1747 {
1748 if let PermissionOptions::DropdownWithPatterns {
1749 patterns,
1750 ..
1751 } = options
1752 {
1753 return Some(patterns.len());
1754 }
1755 }
1756 }
1757 }
1758 None
1759 })
1760 .unwrap_or(0);
1761 self.permission_selections.insert(
1762 tool_call_id,
1763 PermissionSelection::SelectedPatterns((0..pattern_count).collect()),
1764 );
1765 }
1766 }
1767 cx.notify();
1768 }
1769
1770 fn authorize_pending_with_granularity(
1771 &mut self,
1772 is_allow: bool,
1773 window: &mut Window,
1774 cx: &mut Context<Self>,
1775 ) -> Option<()> {
1776 let (session_id, tool_call_id, options) =
1777 self.conversation.read(cx).pending_tool_call(&self.id, cx)?;
1778 let options = options.clone();
1779 self.authorize_with_granularity(session_id, tool_call_id, &options, is_allow, window, cx)
1780 }
1781
1782 fn authorize_with_granularity(
1783 &mut self,
1784 session_id: acp::SessionId,
1785 tool_call_id: acp::ToolCallId,
1786 options: &PermissionOptions,
1787 is_allow: bool,
1788 window: &mut Window,
1789 cx: &mut Context<Self>,
1790 ) -> Option<()> {
1791 let choices = match options {
1792 PermissionOptions::Dropdown(choices) => choices.as_slice(),
1793 PermissionOptions::DropdownWithPatterns { choices, .. } => choices.as_slice(),
1794 _ => {
1795 let kind = if is_allow {
1796 acp::PermissionOptionKind::AllowOnce
1797 } else {
1798 acp::PermissionOptionKind::RejectOnce
1799 };
1800 return self.authorize_pending_tool_call(kind, window, cx);
1801 }
1802 };
1803
1804 let selection = self.permission_selections.get(&tool_call_id);
1805
1806 // When in per-command pattern mode, use the checked patterns.
1807 if let Some(PermissionSelection::SelectedPatterns(checked)) = selection {
1808 if let Some(outcome) = options.build_outcome_for_checked_patterns(checked, is_allow) {
1809 self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx);
1810 return Some(());
1811 }
1812 }
1813
1814 // Use the selected granularity choice ("Always for terminal" or "Only this time")
1815 let selected_index = selection
1816 .and_then(|s| s.choice_index())
1817 .unwrap_or_else(|| choices.len().saturating_sub(1));
1818
1819 let selected_choice = choices.get(selected_index).or(choices.last())?;
1820 let outcome = selected_choice.build_outcome(is_allow);
1821
1822 self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx);
1823
1824 Some(())
1825 }
1826
1827 // edits
1828
1829 pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
1830 let thread = &self.thread;
1831 let telemetry = ActionLogTelemetry::from(thread.read(cx));
1832 let action_log = thread.read(cx).action_log().clone();
1833 action_log.update(cx, |action_log, cx| {
1834 action_log.keep_all_edits(Some(telemetry), cx)
1835 });
1836 }
1837
1838 pub fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
1839 let thread = &self.thread;
1840 let telemetry = ActionLogTelemetry::from(thread.read(cx));
1841 let action_log = thread.read(cx).action_log().clone();
1842 let has_changes = action_log.read(cx).changed_buffers(cx).len() > 0;
1843
1844 action_log
1845 .update(cx, |action_log, cx| {
1846 action_log.reject_all_edits(Some(telemetry), cx)
1847 })
1848 .detach();
1849
1850 if has_changes {
1851 if let Some(workspace) = self.workspace.upgrade() {
1852 workspace.update(cx, |workspace, cx| {
1853 crate::ui::show_undo_reject_toast(workspace, action_log, cx);
1854 });
1855 }
1856 }
1857 }
1858
1859 pub fn undo_last_reject(
1860 &mut self,
1861 _: &UndoLastReject,
1862 _window: &mut Window,
1863 cx: &mut Context<Self>,
1864 ) {
1865 let thread = &self.thread;
1866 let action_log = thread.read(cx).action_log().clone();
1867 action_log
1868 .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
1869 .detach()
1870 }
1871
1872 pub fn open_edited_buffer(
1873 &mut self,
1874 buffer: &Entity<Buffer>,
1875 window: &mut Window,
1876 cx: &mut Context<Self>,
1877 ) {
1878 let thread = &self.thread;
1879
1880 let Some(diff) =
1881 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1882 else {
1883 return;
1884 };
1885
1886 diff.update(cx, |diff, cx| {
1887 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1888 })
1889 }
1890
1891 // thread stuff
1892
1893 fn share_thread(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1894 let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else {
1895 return;
1896 };
1897
1898 let client = project.read(cx).client();
1899 let workspace = self.workspace.clone();
1900 let session_id = thread.read(cx).id().to_string();
1901
1902 let load_task = thread.read(cx).to_db(cx);
1903
1904 cx.spawn(async move |_this, cx| {
1905 let db_thread = load_task.await;
1906
1907 let shared_thread = SharedThread::from_db_thread(&db_thread);
1908 let thread_data = shared_thread.to_bytes()?;
1909 let title = shared_thread.title.to_string();
1910
1911 client
1912 .request(proto::ShareAgentThread {
1913 session_id: session_id.clone(),
1914 title,
1915 thread_data,
1916 })
1917 .await?;
1918
1919 let share_url = client::zed_urls::shared_agent_thread_url(&session_id);
1920
1921 cx.update(|cx| {
1922 if let Some(workspace) = workspace.upgrade() {
1923 workspace.update(cx, |workspace, cx| {
1924 struct ThreadSharedToast;
1925 workspace.show_toast(
1926 Toast::new(
1927 NotificationId::unique::<ThreadSharedToast>(),
1928 "Thread shared!",
1929 )
1930 .on_click(
1931 "Copy URL",
1932 move |_window, cx| {
1933 cx.write_to_clipboard(ClipboardItem::new_string(
1934 share_url.clone(),
1935 ));
1936 },
1937 ),
1938 cx,
1939 );
1940 });
1941 }
1942 });
1943
1944 anyhow::Ok(())
1945 })
1946 .detach_and_log_err(cx);
1947 }
1948
1949 pub fn sync_thread(
1950 &mut self,
1951 project: Entity<Project>,
1952 server_view: Entity<ConversationView>,
1953 window: &mut Window,
1954 cx: &mut Context<Self>,
1955 ) {
1956 if !self.is_imported_thread(cx) {
1957 return;
1958 }
1959
1960 let Some(session_list) = self
1961 .as_native_connection(cx)
1962 .and_then(|connection| connection.session_list(cx))
1963 .and_then(|list| list.downcast::<NativeAgentSessionList>())
1964 else {
1965 return;
1966 };
1967 let thread_store = session_list.thread_store().clone();
1968
1969 let client = project.read(cx).client();
1970 let session_id = self.thread.read(cx).session_id().clone();
1971 cx.spawn_in(window, async move |this, cx| {
1972 let response = client
1973 .request(proto::GetSharedAgentThread {
1974 session_id: session_id.to_string(),
1975 })
1976 .await?;
1977
1978 let shared_thread = SharedThread::from_bytes(&response.thread_data)?;
1979
1980 let db_thread = shared_thread.to_db_thread();
1981
1982 thread_store
1983 .update(&mut cx.clone(), |store, cx| {
1984 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1985 })
1986 .await?;
1987
1988 server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?;
1989
1990 this.update_in(cx, |this, _window, cx| {
1991 if let Some(workspace) = this.workspace.upgrade() {
1992 workspace.update(cx, |workspace, cx| {
1993 struct ThreadSyncedToast;
1994 workspace.show_toast(
1995 Toast::new(
1996 NotificationId::unique::<ThreadSyncedToast>(),
1997 "Thread synced with latest version",
1998 )
1999 .autohide(),
2000 cx,
2001 );
2002 });
2003 }
2004 })?;
2005
2006 anyhow::Ok(())
2007 })
2008 .detach_and_log_err(cx);
2009 }
2010
2011 pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
2012 self.thread
2013 .update(cx, |thread, cx| {
2014 thread.restore_checkpoint(message_id.clone(), cx)
2015 })
2016 .detach_and_log_err(cx);
2017 }
2018
2019 pub fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
2020 self.thread_error = None;
2021 self.thread_error_markdown = None;
2022 self.token_limit_callout_dismissed = true;
2023 cx.notify();
2024 }
2025
2026 fn is_following(&self, cx: &App) -> bool {
2027 match self.thread.read(cx).status() {
2028 ThreadStatus::Generating => self
2029 .workspace
2030 .read_with(cx, |workspace, _| {
2031 workspace.is_being_followed(CollaboratorId::Agent)
2032 })
2033 .unwrap_or(false),
2034 _ => self.should_be_following,
2035 }
2036 }
2037
2038 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2039 let following = self.is_following(cx);
2040
2041 self.should_be_following = !following;
2042 if self.thread.read(cx).status() == ThreadStatus::Generating {
2043 self.workspace
2044 .update(cx, |workspace, cx| {
2045 if following {
2046 workspace.unfollow(CollaboratorId::Agent, window, cx);
2047 } else {
2048 workspace.follow(CollaboratorId::Agent, window, cx);
2049 }
2050 })
2051 .ok();
2052 }
2053
2054 telemetry::event!("Follow Agent Selected", following = !following);
2055 }
2056
2057 // other
2058
2059 pub fn render_thread_retry_status_callout(&self) -> Option<Callout> {
2060 let state = self.thread_retry_status.as_ref()?;
2061
2062 let next_attempt_in = state
2063 .duration
2064 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
2065 if next_attempt_in.is_zero() {
2066 return None;
2067 }
2068
2069 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
2070
2071 let retry_message = if state.max_attempts == 1 {
2072 if next_attempt_in_secs == 1 {
2073 "Retrying. Next attempt in 1 second.".to_string()
2074 } else {
2075 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
2076 }
2077 } else if next_attempt_in_secs == 1 {
2078 format!(
2079 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
2080 state.attempt, state.max_attempts,
2081 )
2082 } else {
2083 format!(
2084 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
2085 state.attempt, state.max_attempts,
2086 )
2087 };
2088
2089 Some(
2090 Callout::new()
2091 .icon(IconName::Warning)
2092 .severity(Severity::Warning)
2093 .title(state.last_error.clone())
2094 .description(retry_message),
2095 )
2096 }
2097
2098 pub fn handle_open_rules(
2099 &mut self,
2100 _: &ClickEvent,
2101 window: &mut Window,
2102 cx: &mut Context<Self>,
2103 ) {
2104 let Some(thread) = self.as_native_thread(cx) else {
2105 return;
2106 };
2107 let project_context = thread.read(cx).project_context().read(cx);
2108
2109 let project_entry_ids = project_context
2110 .worktrees
2111 .iter()
2112 .flat_map(|worktree| worktree.rules_file.as_ref())
2113 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
2114 .collect::<Vec<_>>();
2115
2116 self.workspace
2117 .update(cx, move |workspace, cx| {
2118 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
2119 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
2120 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
2121 let project = workspace.project().read(cx);
2122 let project_paths = project_entry_ids
2123 .into_iter()
2124 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
2125 .collect::<Vec<_>>();
2126 for project_path in project_paths {
2127 workspace
2128 .open_path(project_path, None, true, window, cx)
2129 .detach_and_log_err(cx);
2130 }
2131 })
2132 .ok();
2133 }
2134
2135 fn activity_bar_bg(&self, cx: &Context<Self>) -> Hsla {
2136 let editor_bg_color = cx.theme().colors().editor_background;
2137 let active_color = cx.theme().colors().element_selected;
2138 editor_bg_color.blend(active_color.opacity(0.3))
2139 }
2140
2141 pub fn render_activity_bar(
2142 &self,
2143 window: &mut Window,
2144 cx: &Context<Self>,
2145 ) -> Option<AnyElement> {
2146 let thread = self.thread.read(cx);
2147 let action_log = thread.action_log();
2148 let telemetry = ActionLogTelemetry::from(thread);
2149 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2150 let plan = thread.plan();
2151 let queue_is_empty = !self.has_queued_messages();
2152
2153 let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx);
2154 let has_subagents_awaiting = subagents_awaiting_permission.is_some();
2155
2156 if changed_buffers.is_empty()
2157 && plan.is_empty()
2158 && queue_is_empty
2159 && !has_subagents_awaiting
2160 {
2161 return None;
2162 }
2163
2164 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
2165 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
2166 // be, which blocks you from being able to accept or reject edits. This switches the
2167 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
2168 // block you from using the panel.
2169 let pending_edits = false;
2170
2171 let plan_expanded = self.plan_expanded;
2172 let edits_expanded = self.edits_expanded;
2173 let queue_expanded = self.queue_expanded;
2174
2175 v_flex()
2176 .mx_2()
2177 .bg(self.activity_bar_bg(cx))
2178 .border_1()
2179 .border_b_0()
2180 .border_color(cx.theme().colors().border)
2181 .rounded_t_md()
2182 .shadow(vec![gpui::BoxShadow {
2183 color: gpui::black().opacity(0.12),
2184 offset: point(px(1.), px(-1.)),
2185 blur_radius: px(2.),
2186 spread_radius: px(0.),
2187 }])
2188 .when_some(subagents_awaiting_permission, |this, element| {
2189 this.child(element)
2190 })
2191 .when(
2192 has_subagents_awaiting
2193 && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty),
2194 |this| this.child(Divider::horizontal().color(DividerColor::Border)),
2195 )
2196 .when(!plan.is_empty(), |this| {
2197 this.child(self.render_plan_summary(plan, window, cx))
2198 .when(plan_expanded, |parent| {
2199 parent.child(self.render_plan_entries(plan, window, cx))
2200 })
2201 })
2202 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2203 this.child(Divider::horizontal().color(DividerColor::Border))
2204 })
2205 .when(
2206 !changed_buffers.is_empty() && thread.parent_session_id().is_none(),
2207 |this| {
2208 this.child(self.render_edits_summary(
2209 &changed_buffers,
2210 edits_expanded,
2211 pending_edits,
2212 cx,
2213 ))
2214 .when(edits_expanded, |parent| {
2215 parent.child(self.render_edited_files(
2216 action_log,
2217 telemetry.clone(),
2218 &changed_buffers,
2219 pending_edits,
2220 cx,
2221 ))
2222 })
2223 },
2224 )
2225 .when(!queue_is_empty, |this| {
2226 this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| {
2227 this.child(Divider::horizontal().color(DividerColor::Border))
2228 })
2229 .child(self.render_message_queue_summary(window, cx))
2230 .when(queue_expanded, |parent| {
2231 parent.child(self.render_message_queue_entries(window, cx))
2232 })
2233 })
2234 .into_any()
2235 .into()
2236 }
2237
2238 fn render_edited_files(
2239 &self,
2240 action_log: &Entity<ActionLog>,
2241 telemetry: ActionLogTelemetry,
2242 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2243 pending_edits: bool,
2244 cx: &Context<Self>,
2245 ) -> impl IntoElement {
2246 let editor_bg_color = cx.theme().colors().editor_background;
2247
2248 // Sort edited files alphabetically for consistency with Git diff view
2249 let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect();
2250 sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| {
2251 let path_a = buffer_a.read(cx).file().map(|f| f.path().clone());
2252 let path_b = buffer_b.read(cx).file().map(|f| f.path().clone());
2253 path_a.cmp(&path_b)
2254 });
2255
2256 v_flex()
2257 .id("edited_files_list")
2258 .max_h_40()
2259 .overflow_y_scroll()
2260 .children(
2261 sorted_buffers
2262 .into_iter()
2263 .enumerate()
2264 .flat_map(|(index, (buffer, diff))| {
2265 let file = buffer.read(cx).file()?;
2266 let path = file.path();
2267 let path_style = file.path_style(cx);
2268 let separator = file.path_style(cx).primary_separator();
2269
2270 let file_path = path.parent().and_then(|parent| {
2271 if parent.is_empty() {
2272 None
2273 } else {
2274 Some(
2275 Label::new(format!(
2276 "{}{separator}",
2277 parent.display(path_style)
2278 ))
2279 .color(Color::Muted)
2280 .size(LabelSize::XSmall)
2281 .buffer_font(cx),
2282 )
2283 }
2284 });
2285
2286 let file_name = path.file_name().map(|name| {
2287 Label::new(name.to_string())
2288 .size(LabelSize::XSmall)
2289 .buffer_font(cx)
2290 .ml_1()
2291 });
2292
2293 let full_path = path.display(path_style).to_string();
2294
2295 let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
2296 .map(Icon::from_path)
2297 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2298 .unwrap_or_else(|| {
2299 Icon::new(IconName::File)
2300 .color(Color::Muted)
2301 .size(IconSize::Small)
2302 });
2303
2304 let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
2305
2306 let buttons = self.render_edited_files_buttons(
2307 index,
2308 buffer,
2309 action_log,
2310 &telemetry,
2311 pending_edits,
2312 editor_bg_color,
2313 cx,
2314 );
2315
2316 let element = h_flex()
2317 .group("edited-code")
2318 .id(("file-container", index))
2319 .relative()
2320 .min_w_0()
2321 .p_1p5()
2322 .gap_2()
2323 .justify_between()
2324 .bg(editor_bg_color)
2325 .when(index < changed_buffers.len() - 1, |parent| {
2326 parent.border_color(cx.theme().colors().border).border_b_1()
2327 })
2328 .child(
2329 h_flex()
2330 .id(("file-name-path", index))
2331 .cursor_pointer()
2332 .pr_0p5()
2333 .gap_0p5()
2334 .rounded_xs()
2335 .child(file_icon)
2336 .children(file_name)
2337 .children(file_path)
2338 .child(
2339 DiffStat::new(
2340 "file",
2341 file_stats.lines_added as usize,
2342 file_stats.lines_removed as usize,
2343 )
2344 .label_size(LabelSize::XSmall),
2345 )
2346 .hover(|s| s.bg(cx.theme().colors().element_hover))
2347 .tooltip({
2348 move |_, cx| {
2349 Tooltip::with_meta(
2350 "Go to File",
2351 None,
2352 full_path.clone(),
2353 cx,
2354 )
2355 }
2356 })
2357 .on_click({
2358 let buffer = buffer.clone();
2359 cx.listener(move |this, _, window, cx| {
2360 this.open_edited_buffer(&buffer, window, cx);
2361 })
2362 }),
2363 )
2364 .child(buttons);
2365
2366 Some(element)
2367 }),
2368 )
2369 .into_any_element()
2370 }
2371
2372 fn render_edited_files_buttons(
2373 &self,
2374 index: usize,
2375 buffer: &Entity<Buffer>,
2376 action_log: &Entity<ActionLog>,
2377 telemetry: &ActionLogTelemetry,
2378 pending_edits: bool,
2379 editor_bg_color: Hsla,
2380 cx: &Context<Self>,
2381 ) -> impl IntoElement {
2382 h_flex()
2383 .id("edited-buttons-container")
2384 .visible_on_hover("edited-code")
2385 .absolute()
2386 .right_0()
2387 .px_1()
2388 .gap_1()
2389 .bg(editor_bg_color)
2390 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
2391 if *is_hovered {
2392 this.hovered_edited_file_buttons = Some(index);
2393 } else if this.hovered_edited_file_buttons == Some(index) {
2394 this.hovered_edited_file_buttons = None;
2395 }
2396 cx.notify();
2397 }))
2398 .child(
2399 Button::new("review", "Review")
2400 .label_size(LabelSize::Small)
2401 .on_click({
2402 let buffer = buffer.clone();
2403 cx.listener(move |this, _, window, cx| {
2404 this.open_edited_buffer(&buffer, window, cx);
2405 })
2406 }),
2407 )
2408 .child(
2409 Button::new(("reject-file", index), "Reject")
2410 .label_size(LabelSize::Small)
2411 .disabled(pending_edits)
2412 .on_click({
2413 let buffer = buffer.clone();
2414 let action_log = action_log.clone();
2415 let telemetry = telemetry.clone();
2416 move |_, _, cx| {
2417 action_log.update(cx, |action_log, cx| {
2418 action_log
2419 .reject_edits_in_ranges(
2420 buffer.clone(),
2421 vec![Anchor::min_max_range_for_buffer(
2422 buffer.read(cx).remote_id(),
2423 )],
2424 Some(telemetry.clone()),
2425 cx,
2426 )
2427 .0
2428 .detach_and_log_err(cx);
2429 })
2430 }
2431 }),
2432 )
2433 .child(
2434 Button::new(("keep-file", index), "Keep")
2435 .label_size(LabelSize::Small)
2436 .disabled(pending_edits)
2437 .on_click({
2438 let buffer = buffer.clone();
2439 let action_log = action_log.clone();
2440 let telemetry = telemetry.clone();
2441 move |_, _, cx| {
2442 action_log.update(cx, |action_log, cx| {
2443 action_log.keep_edits_in_range(
2444 buffer.clone(),
2445 Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()),
2446 Some(telemetry.clone()),
2447 cx,
2448 );
2449 })
2450 }
2451 }),
2452 )
2453 }
2454
2455 fn render_subagents_awaiting_permission(&self, cx: &Context<Self>) -> Option<AnyElement> {
2456 let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx);
2457
2458 if awaiting.is_empty() {
2459 return None;
2460 }
2461
2462 let thread = self.thread.read(cx);
2463 let entries = thread.entries();
2464 let mut subagent_items: Vec<(SharedString, usize)> = Vec::new();
2465
2466 for (session_id, _) in &awaiting {
2467 for (entry_ix, entry) in entries.iter().enumerate() {
2468 if let AgentThreadEntry::ToolCall(tool_call) = entry {
2469 if let Some(info) = &tool_call.subagent_session_info {
2470 if &info.session_id == session_id {
2471 let subagent_summary: SharedString = {
2472 let summary_text = tool_call.label.read(cx).source().to_string();
2473 if !summary_text.is_empty() {
2474 summary_text.into()
2475 } else {
2476 "Subagent".into()
2477 }
2478 };
2479 subagent_items.push((subagent_summary, entry_ix));
2480 break;
2481 }
2482 }
2483 }
2484 }
2485 }
2486
2487 if subagent_items.is_empty() {
2488 return None;
2489 }
2490
2491 let item_count = subagent_items.len();
2492
2493 Some(
2494 v_flex()
2495 .child(
2496 h_flex()
2497 .py_1()
2498 .px_2()
2499 .w_full()
2500 .gap_1()
2501 .border_b_1()
2502 .border_color(cx.theme().colors().border)
2503 .child(
2504 Label::new("Subagents Awaiting Permission:")
2505 .size(LabelSize::Small)
2506 .color(Color::Muted),
2507 )
2508 .child(Label::new(item_count.to_string()).size(LabelSize::Small)),
2509 )
2510 .child(
2511 v_flex().children(subagent_items.into_iter().enumerate().map(
2512 |(ix, (label, entry_ix))| {
2513 let is_last = ix == item_count - 1;
2514 let group = format!("group-{}", entry_ix);
2515
2516 h_flex()
2517 .cursor_pointer()
2518 .id(format!("subagent-permission-{}", entry_ix))
2519 .group(&group)
2520 .p_1()
2521 .pl_2()
2522 .min_w_0()
2523 .w_full()
2524 .gap_1()
2525 .justify_between()
2526 .bg(cx.theme().colors().editor_background)
2527 .hover(|s| s.bg(cx.theme().colors().element_hover))
2528 .when(!is_last, |this| {
2529 this.border_b_1().border_color(cx.theme().colors().border)
2530 })
2531 .child(
2532 h_flex()
2533 .gap_1p5()
2534 .child(
2535 Icon::new(IconName::Circle)
2536 .size(IconSize::XSmall)
2537 .color(Color::Warning),
2538 )
2539 .child(
2540 Label::new(label)
2541 .size(LabelSize::Small)
2542 .color(Color::Muted)
2543 .truncate(),
2544 ),
2545 )
2546 .child(
2547 div().visible_on_hover(&group).child(
2548 Label::new("Scroll to Subagent")
2549 .size(LabelSize::Small)
2550 .color(Color::Muted)
2551 .truncate(),
2552 ),
2553 )
2554 .on_click(cx.listener(move |this, _, _, cx| {
2555 this.list_state.scroll_to(ListOffset {
2556 item_ix: entry_ix,
2557 offset_in_item: px(0.0),
2558 });
2559 cx.notify();
2560 }))
2561 },
2562 )),
2563 )
2564 .into_any(),
2565 )
2566 }
2567
2568 fn render_message_queue_summary(
2569 &self,
2570 _window: &mut Window,
2571 cx: &Context<Self>,
2572 ) -> impl IntoElement {
2573 let queue_count = self.local_queued_messages.len();
2574 let title: SharedString = if queue_count == 1 {
2575 "1 Queued Message".into()
2576 } else {
2577 format!("{} Queued Messages", queue_count).into()
2578 };
2579
2580 h_flex()
2581 .p_1()
2582 .w_full()
2583 .gap_1()
2584 .justify_between()
2585 .when(self.queue_expanded, |this| {
2586 this.border_b_1().border_color(cx.theme().colors().border)
2587 })
2588 .child(
2589 h_flex()
2590 .id("queue_summary")
2591 .gap_1()
2592 .child(Disclosure::new("queue_disclosure", self.queue_expanded))
2593 .child(Label::new(title).size(LabelSize::Small).color(Color::Muted))
2594 .on_click(cx.listener(|this, _, _, cx| {
2595 this.queue_expanded = !this.queue_expanded;
2596 cx.notify();
2597 })),
2598 )
2599 .child(
2600 Button::new("clear_queue", "Clear All")
2601 .label_size(LabelSize::Small)
2602 .key_binding(
2603 KeyBinding::for_action(&ClearMessageQueue, cx)
2604 .map(|kb| kb.size(rems_from_px(12.))),
2605 )
2606 .on_click(cx.listener(|this, _, _, cx| {
2607 this.clear_queue(cx);
2608 this.can_fast_track_queue = false;
2609 cx.notify();
2610 })),
2611 )
2612 .into_any_element()
2613 }
2614
2615 fn clear_queue(&mut self, cx: &mut Context<Self>) {
2616 self.local_queued_messages.clear();
2617 self.sync_queue_flag_to_native_thread(cx);
2618 }
2619
2620 fn render_plan_summary(
2621 &self,
2622 plan: &Plan,
2623 window: &mut Window,
2624 cx: &Context<Self>,
2625 ) -> impl IntoElement {
2626 let plan_expanded = self.plan_expanded;
2627 let stats = plan.stats();
2628
2629 let title = if let Some(entry) = stats.in_progress_entry
2630 && !plan_expanded
2631 {
2632 h_flex()
2633 .cursor_default()
2634 .relative()
2635 .w_full()
2636 .gap_1()
2637 .truncate()
2638 .child(
2639 Label::new("Current:")
2640 .size(LabelSize::Small)
2641 .color(Color::Muted),
2642 )
2643 .child(
2644 div()
2645 .text_xs()
2646 .text_color(cx.theme().colors().text_muted)
2647 .line_clamp(1)
2648 .child(MarkdownElement::new(
2649 entry.content.clone(),
2650 plan_label_markdown_style(&entry.status, window, cx),
2651 )),
2652 )
2653 .when(stats.pending > 0, |this| {
2654 this.child(
2655 h_flex()
2656 .absolute()
2657 .top_0()
2658 .right_0()
2659 .h_full()
2660 .child(div().min_w_8().h_full().bg(linear_gradient(
2661 90.,
2662 linear_color_stop(self.activity_bar_bg(cx), 1.),
2663 linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.),
2664 )))
2665 .child(
2666 div().pr_0p5().bg(self.activity_bar_bg(cx)).child(
2667 Label::new(format!("{} left", stats.pending))
2668 .size(LabelSize::Small)
2669 .color(Color::Muted),
2670 ),
2671 ),
2672 )
2673 })
2674 } else {
2675 let status_label = if stats.pending == 0 {
2676 "All Done".to_string()
2677 } else if stats.completed == 0 {
2678 format!("{} Tasks", plan.entries.len())
2679 } else {
2680 format!("{}/{}", stats.completed, plan.entries.len())
2681 };
2682
2683 h_flex()
2684 .w_full()
2685 .gap_1()
2686 .justify_between()
2687 .child(
2688 Label::new("Plan")
2689 .size(LabelSize::Small)
2690 .color(Color::Muted),
2691 )
2692 .child(
2693 Label::new(status_label)
2694 .size(LabelSize::Small)
2695 .color(Color::Muted)
2696 .mr_1(),
2697 )
2698 };
2699
2700 h_flex()
2701 .id("plan_summary")
2702 .p_1()
2703 .w_full()
2704 .gap_1()
2705 .when(plan_expanded, |this| {
2706 this.border_b_1().border_color(cx.theme().colors().border)
2707 })
2708 .child(Disclosure::new("plan_disclosure", plan_expanded))
2709 .child(title.flex_1())
2710 .child(
2711 IconButton::new("dismiss-plan", IconName::Close)
2712 .icon_size(IconSize::XSmall)
2713 .shape(ui::IconButtonShape::Square)
2714 .tooltip(Tooltip::text("Clear plan"))
2715 .on_click(cx.listener(|this, _, _, cx| {
2716 this.thread.update(cx, |thread, cx| thread.clear_plan(cx));
2717 cx.stop_propagation();
2718 })),
2719 )
2720 .on_click(cx.listener(|this, _, _, cx| {
2721 this.plan_expanded = !this.plan_expanded;
2722 cx.notify();
2723 }))
2724 .into_any_element()
2725 }
2726
2727 fn render_plan_entries(
2728 &self,
2729 plan: &Plan,
2730 window: &mut Window,
2731 cx: &Context<Self>,
2732 ) -> impl IntoElement {
2733 v_flex()
2734 .id("plan_items_list")
2735 .max_h_40()
2736 .overflow_y_scroll()
2737 .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
2738 let element = h_flex()
2739 .py_1()
2740 .px_2()
2741 .gap_2()
2742 .justify_between()
2743 .bg(cx.theme().colors().editor_background)
2744 .when(index < plan.entries.len() - 1, |parent| {
2745 parent.border_color(cx.theme().colors().border).border_b_1()
2746 })
2747 .child(
2748 h_flex()
2749 .id(("plan_entry", index))
2750 .gap_1p5()
2751 .max_w_full()
2752 .overflow_x_scroll()
2753 .text_xs()
2754 .text_color(cx.theme().colors().text_muted)
2755 .child(match entry.status {
2756 acp::PlanEntryStatus::InProgress => {
2757 Icon::new(IconName::TodoProgress)
2758 .size(IconSize::Small)
2759 .color(Color::Accent)
2760 .with_rotate_animation(2)
2761 .into_any_element()
2762 }
2763 acp::PlanEntryStatus::Completed => {
2764 Icon::new(IconName::TodoComplete)
2765 .size(IconSize::Small)
2766 .color(Color::Success)
2767 .into_any_element()
2768 }
2769 acp::PlanEntryStatus::Pending | _ => {
2770 Icon::new(IconName::TodoPending)
2771 .size(IconSize::Small)
2772 .color(Color::Muted)
2773 .into_any_element()
2774 }
2775 })
2776 .child(MarkdownElement::new(
2777 entry.content.clone(),
2778 plan_label_markdown_style(&entry.status, window, cx),
2779 )),
2780 );
2781
2782 Some(element)
2783 }))
2784 .into_any_element()
2785 }
2786
2787 fn render_completed_plan(
2788 &self,
2789 entries: &[PlanEntry],
2790 window: &Window,
2791 cx: &Context<Self>,
2792 ) -> AnyElement {
2793 v_flex()
2794 .px_5()
2795 .py_1p5()
2796 .w_full()
2797 .child(
2798 v_flex()
2799 .w_full()
2800 .rounded_md()
2801 .border_1()
2802 .border_color(self.tool_card_border_color(cx))
2803 .child(
2804 h_flex()
2805 .px_2()
2806 .py_1()
2807 .gap_1()
2808 .bg(self.tool_card_header_bg(cx))
2809 .border_b_1()
2810 .border_color(self.tool_card_border_color(cx))
2811 .child(
2812 Label::new("Completed Plan")
2813 .size(LabelSize::Small)
2814 .color(Color::Muted),
2815 )
2816 .child(
2817 Label::new(format!(
2818 "— {} {}",
2819 entries.len(),
2820 if entries.len() == 1 { "step" } else { "steps" }
2821 ))
2822 .size(LabelSize::Small)
2823 .color(Color::Muted),
2824 ),
2825 )
2826 .child(
2827 v_flex().children(entries.iter().enumerate().map(|(index, entry)| {
2828 h_flex()
2829 .py_1()
2830 .px_2()
2831 .gap_1p5()
2832 .when(index < entries.len() - 1, |this| {
2833 this.border_b_1().border_color(cx.theme().colors().border)
2834 })
2835 .child(
2836 Icon::new(IconName::TodoComplete)
2837 .size(IconSize::Small)
2838 .color(Color::Success),
2839 )
2840 .child(
2841 div()
2842 .max_w_full()
2843 .overflow_x_hidden()
2844 .text_xs()
2845 .text_color(cx.theme().colors().text_muted)
2846 .child(MarkdownElement::new(
2847 entry.content.clone(),
2848 default_markdown_style(window, cx),
2849 )),
2850 )
2851 })),
2852 ),
2853 )
2854 .into_any()
2855 }
2856
2857 fn render_edits_summary(
2858 &self,
2859 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2860 expanded: bool,
2861 pending_edits: bool,
2862 cx: &Context<Self>,
2863 ) -> Div {
2864 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
2865
2866 let focus_handle = self.focus_handle(cx);
2867
2868 h_flex()
2869 .p_1()
2870 .justify_between()
2871 .flex_wrap()
2872 .when(expanded, |this| {
2873 this.border_b_1().border_color(cx.theme().colors().border)
2874 })
2875 .child(
2876 h_flex()
2877 .id("edits-container")
2878 .cursor_pointer()
2879 .gap_1()
2880 .child(Disclosure::new("edits-disclosure", expanded))
2881 .map(|this| {
2882 if pending_edits {
2883 this.child(
2884 Label::new(format!(
2885 "Editing {} {}…",
2886 changed_buffers.len(),
2887 if changed_buffers.len() == 1 {
2888 "file"
2889 } else {
2890 "files"
2891 }
2892 ))
2893 .color(Color::Muted)
2894 .size(LabelSize::Small)
2895 .with_animation(
2896 "edit-label",
2897 Animation::new(Duration::from_secs(2))
2898 .repeat()
2899 .with_easing(pulsating_between(0.3, 0.7)),
2900 |label, delta| label.alpha(delta),
2901 ),
2902 )
2903 } else {
2904 let stats = DiffStats::all_files(changed_buffers, cx);
2905 let dot_divider = || {
2906 Label::new("•")
2907 .size(LabelSize::XSmall)
2908 .color(Color::Disabled)
2909 };
2910
2911 this.child(
2912 Label::new("Edits")
2913 .size(LabelSize::Small)
2914 .color(Color::Muted),
2915 )
2916 .child(dot_divider())
2917 .child(
2918 Label::new(format!(
2919 "{} {}",
2920 changed_buffers.len(),
2921 if changed_buffers.len() == 1 {
2922 "file"
2923 } else {
2924 "files"
2925 }
2926 ))
2927 .size(LabelSize::Small)
2928 .color(Color::Muted),
2929 )
2930 .child(dot_divider())
2931 .child(DiffStat::new(
2932 "total",
2933 stats.lines_added as usize,
2934 stats.lines_removed as usize,
2935 ))
2936 }
2937 })
2938 .on_click(cx.listener(|this, _, _, cx| {
2939 this.edits_expanded = !this.edits_expanded;
2940 cx.notify();
2941 })),
2942 )
2943 .child(
2944 h_flex()
2945 .gap_1()
2946 .child(
2947 IconButton::new("review-changes", IconName::ListTodo)
2948 .icon_size(IconSize::Small)
2949 .tooltip({
2950 let focus_handle = focus_handle.clone();
2951 move |_window, cx| {
2952 Tooltip::for_action_in(
2953 "Review Changes",
2954 &OpenAgentDiff,
2955 &focus_handle,
2956 cx,
2957 )
2958 }
2959 })
2960 .on_click(cx.listener(|_, _, window, cx| {
2961 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
2962 })),
2963 )
2964 .child(Divider::vertical().color(DividerColor::Border))
2965 .child(
2966 Button::new("reject-all-changes", "Reject All")
2967 .label_size(LabelSize::Small)
2968 .disabled(pending_edits)
2969 .when(pending_edits, |this| {
2970 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2971 })
2972 .key_binding(
2973 KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx)
2974 .map(|kb| kb.size(rems_from_px(12.))),
2975 )
2976 .on_click(cx.listener(move |this, _, window, cx| {
2977 this.reject_all(&RejectAll, window, cx);
2978 })),
2979 )
2980 .child(
2981 Button::new("keep-all-changes", "Keep All")
2982 .label_size(LabelSize::Small)
2983 .disabled(pending_edits)
2984 .when(pending_edits, |this| {
2985 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2986 })
2987 .key_binding(
2988 KeyBinding::for_action_in(&KeepAll, &focus_handle, cx)
2989 .map(|kb| kb.size(rems_from_px(12.))),
2990 )
2991 .on_click(cx.listener(move |this, _, window, cx| {
2992 this.keep_all(&KeepAll, window, cx);
2993 })),
2994 ),
2995 )
2996 }
2997
2998 fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool {
2999 let Some(parent_session_id) = self.parent_id.as_ref() else {
3000 return false;
3001 };
3002
3003 let my_session_id = self.thread.read(cx).session_id().clone();
3004
3005 self.server_view
3006 .upgrade()
3007 .and_then(|sv| sv.read(cx).thread_view(parent_session_id))
3008 .is_some_and(|parent_view| {
3009 parent_view
3010 .read(cx)
3011 .thread
3012 .read(cx)
3013 .tool_call_for_subagent(&my_session_id)
3014 .is_some_and(|tc| {
3015 matches!(
3016 tc.status,
3017 ToolCallStatus::Canceled
3018 | ToolCallStatus::Failed
3019 | ToolCallStatus::Rejected
3020 )
3021 })
3022 })
3023 }
3024
3025 pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
3026 let Some(parent_session_id) = self.parent_id.clone() else {
3027 return None;
3028 };
3029
3030 let server_view = self.server_view.clone();
3031 let thread = self.thread.clone();
3032 let is_done = thread.read(cx).status() == ThreadStatus::Idle;
3033 let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx);
3034
3035 Some(
3036 h_flex()
3037 .h(Tab::container_height(cx))
3038 .pl_2()
3039 .pr_1p5()
3040 .w_full()
3041 .justify_between()
3042 .gap_1()
3043 .border_b_1()
3044 .when(is_done && is_canceled_or_failed, |this| {
3045 this.border_dashed()
3046 })
3047 .border_color(cx.theme().colors().border)
3048 .bg(cx.theme().colors().editor_background.opacity(0.2))
3049 .child(
3050 h_flex()
3051 .flex_1()
3052 .gap_2()
3053 .child(
3054 Icon::new(IconName::ForwardArrowUp)
3055 .size(IconSize::Small)
3056 .color(Color::Muted),
3057 )
3058 .child(self.title_editor.clone())
3059 .when(is_done && is_canceled_or_failed, |this| {
3060 this.child(Icon::new(IconName::Close).color(Color::Error))
3061 })
3062 .when(is_done && !is_canceled_or_failed, |this| {
3063 this.child(Icon::new(IconName::Check).color(Color::Success))
3064 }),
3065 )
3066 .child(
3067 h_flex()
3068 .gap_0p5()
3069 .when(!is_done, |this| {
3070 this.child(
3071 IconButton::new("stop_subagent", IconName::Stop)
3072 .icon_size(IconSize::Small)
3073 .icon_color(Color::Error)
3074 .tooltip(Tooltip::text("Stop Subagent"))
3075 .on_click(move |_, _, cx| {
3076 thread.update(cx, |thread, cx| {
3077 thread.cancel(cx).detach();
3078 });
3079 }),
3080 )
3081 })
3082 .child(
3083 IconButton::new("minimize_subagent", IconName::Minimize)
3084 .icon_size(IconSize::Small)
3085 .tooltip(Tooltip::text("Minimize Subagent"))
3086 .on_click(move |_, window, cx| {
3087 let _ = server_view.update(cx, |server_view, cx| {
3088 server_view.navigate_to_session(
3089 parent_session_id.clone(),
3090 window,
3091 cx,
3092 );
3093 });
3094 }),
3095 ),
3096 ),
3097 )
3098 }
3099
3100 pub(crate) fn render_message_editor(
3101 &mut self,
3102 window: &mut Window,
3103 cx: &mut Context<Self>,
3104 ) -> AnyElement {
3105 if self.is_subagent() {
3106 return div().into_any_element();
3107 }
3108
3109 let focus_handle = self.message_editor.focus_handle(cx);
3110 let editor_bg_color = cx.theme().colors().editor_background;
3111 let editor_expanded = self.editor_expanded;
3112 let has_messages = self.list_state.item_count() > 0;
3113 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
3114 let (expand_icon, expand_tooltip) = if editor_expanded {
3115 (IconName::Minimize, "Minimize Message Editor")
3116 } else {
3117 (IconName::Maximize, "Expand Message Editor")
3118 };
3119
3120 v_flex()
3121 .on_action(cx.listener(Self::expand_message_editor))
3122 .p_2()
3123 .gap_2()
3124 .when(!v2_empty_state, |this| {
3125 this.border_t_1().border_color(cx.theme().colors().border)
3126 })
3127 .bg(editor_bg_color)
3128 .when(v2_empty_state, |this| this.flex_1().size_full())
3129 .when(editor_expanded && !v2_empty_state, |this| {
3130 this.h(vh(0.8, window)).size_full().justify_between()
3131 })
3132 .child(
3133 v_flex()
3134 .relative()
3135 .size_full()
3136 .when(v2_empty_state, |this| this.flex_1())
3137 .pt_1()
3138 .pr_2p5()
3139 .child(self.message_editor.clone())
3140 .when(!v2_empty_state, |this| {
3141 this.child(
3142 h_flex()
3143 .absolute()
3144 .top_0()
3145 .right_0()
3146 .opacity(0.5)
3147 .hover(|this| this.opacity(1.0))
3148 .child(
3149 IconButton::new("toggle-height", expand_icon)
3150 .icon_size(IconSize::Small)
3151 .icon_color(Color::Muted)
3152 .tooltip({
3153 move |_window, cx| {
3154 Tooltip::for_action_in(
3155 expand_tooltip,
3156 &ExpandMessageEditor,
3157 &focus_handle,
3158 cx,
3159 )
3160 }
3161 })
3162 .on_click(cx.listener(|this, _, window, cx| {
3163 this.expand_message_editor(
3164 &ExpandMessageEditor,
3165 window,
3166 cx,
3167 );
3168 })),
3169 ),
3170 )
3171 }),
3172 )
3173 .child(
3174 h_flex()
3175 .flex_none()
3176 .flex_wrap()
3177 .justify_between()
3178 .child(
3179 h_flex()
3180 .gap_0p5()
3181 .child(self.render_add_context_button(cx))
3182 .child(self.render_follow_toggle(cx))
3183 .children(self.render_fast_mode_control(cx))
3184 .children(self.render_thinking_control(cx)),
3185 )
3186 .child(
3187 h_flex()
3188 .gap_1()
3189 .children(self.render_token_usage(cx))
3190 .children(self.profile_selector.clone())
3191 .map(|this| {
3192 // Either config_options_view OR (mode_selector + model_selector)
3193 match self.config_options_view.clone() {
3194 Some(config_view) => this.child(config_view),
3195 None => this
3196 .children(self.mode_selector.clone())
3197 .children(self.model_selector.clone()),
3198 }
3199 })
3200 .child(self.render_send_button(cx)),
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 // Setting follow mode to Tail will scroll to the bottom of the list
4940 self.list_state.set_follow_mode(gpui::FollowMode::Tail);
4941 cx.notify();
4942 }
4943
4944 fn handle_feedback_click(
4945 &mut self,
4946 feedback: ThreadFeedback,
4947 window: &mut Window,
4948 cx: &mut Context<Self>,
4949 ) {
4950 self.thread_feedback
4951 .submit(self.thread.clone(), feedback, window, cx);
4952 cx.notify();
4953 }
4954
4955 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4956 let thread = self.thread.clone();
4957 self.thread_feedback.submit_comments(thread, cx);
4958 cx.notify();
4959 }
4960
4961 pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
4962 self.list_state.set_follow_mode(gpui::FollowMode::Normal);
4963 self.list_state.scroll_to(ListOffset::default());
4964 cx.notify();
4965 }
4966
4967 fn scroll_output_page_up(
4968 &mut self,
4969 _: &ScrollOutputPageUp,
4970 _window: &mut Window,
4971 cx: &mut Context<Self>,
4972 ) {
4973 let page_height = self.list_state.viewport_bounds().size.height;
4974 self.manual_scroll_by(-page_height * 0.9, cx);
4975 }
4976
4977 fn scroll_output_page_down(
4978 &mut self,
4979 _: &ScrollOutputPageDown,
4980 _window: &mut Window,
4981 cx: &mut Context<Self>,
4982 ) {
4983 let page_height = self.list_state.viewport_bounds().size.height;
4984 self.manual_scroll_by(page_height * 0.9, cx);
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.manual_scroll_by(-window.line_height() * 3., cx);
4994 }
4995
4996 fn scroll_output_line_down(
4997 &mut self,
4998 _: &ScrollOutputLineDown,
4999 window: &mut Window,
5000 cx: &mut Context<Self>,
5001 ) {
5002 self.manual_scroll_by(window.line_height() * 3., cx);
5003 }
5004
5005 fn manual_scroll_by(&mut self, amount: Pixels, cx: &mut Context<Self>) {
5006 self.list_state.scroll_by(amount);
5007 let mode = if self.list_state.is_at_bottom() {
5008 gpui::FollowMode::Tail
5009 } else {
5010 gpui::FollowMode::Normal
5011 };
5012 self.list_state.set_follow_mode(mode);
5013 cx.notify();
5014 }
5015
5016 fn scroll_output_to_top(
5017 &mut self,
5018 _: &ScrollOutputToTop,
5019 _window: &mut Window,
5020 cx: &mut Context<Self>,
5021 ) {
5022 self.scroll_to_top(cx);
5023 }
5024
5025 fn scroll_output_to_bottom(
5026 &mut self,
5027 _: &ScrollOutputToBottom,
5028 _window: &mut Window,
5029 cx: &mut Context<Self>,
5030 ) {
5031 self.scroll_to_end(cx);
5032 }
5033
5034 fn scroll_output_to_previous_message(
5035 &mut self,
5036 _: &ScrollOutputToPreviousMessage,
5037 _window: &mut Window,
5038 cx: &mut Context<Self>,
5039 ) {
5040 let entries = self.thread.read(cx).entries();
5041 let current_ix = self.list_state.logical_scroll_top().item_ix;
5042 if let Some(target_ix) = (0..current_ix)
5043 .rev()
5044 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5045 {
5046 self.list_state.set_follow_mode(gpui::FollowMode::Normal);
5047 self.list_state.scroll_to(ListOffset {
5048 item_ix: target_ix,
5049 offset_in_item: px(0.),
5050 });
5051 cx.notify();
5052 }
5053 }
5054
5055 fn scroll_output_to_next_message(
5056 &mut self,
5057 _: &ScrollOutputToNextMessage,
5058 _window: &mut Window,
5059 cx: &mut Context<Self>,
5060 ) {
5061 let entries = self.thread.read(cx).entries();
5062 let current_ix = self.list_state.logical_scroll_top().item_ix;
5063 if let Some(target_ix) = (current_ix + 1..entries.len())
5064 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5065 {
5066 self.list_state.set_follow_mode(gpui::FollowMode::Normal);
5067 self.list_state.scroll_to(ListOffset {
5068 item_ix: target_ix,
5069 offset_in_item: px(0.),
5070 });
5071 cx.notify();
5072 }
5073 }
5074
5075 pub fn open_thread_as_markdown(
5076 &self,
5077 workspace: Entity<Workspace>,
5078 window: &mut Window,
5079 cx: &mut App,
5080 ) -> Task<Result<()>> {
5081 let markdown_language_task = workspace
5082 .read(cx)
5083 .app_state()
5084 .languages
5085 .language_for_name("Markdown");
5086
5087 let thread = self.thread.read(cx);
5088 let thread_title = thread
5089 .title()
5090 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
5091 .to_string();
5092 let markdown = thread.to_markdown(cx);
5093
5094 let project = workspace.read(cx).project().clone();
5095 window.spawn(cx, async move |cx| {
5096 let markdown_language = markdown_language_task.await?;
5097
5098 let buffer = project
5099 .update(cx, |project, cx| {
5100 project.create_buffer(Some(markdown_language), false, cx)
5101 })
5102 .await?;
5103
5104 buffer.update(cx, |buffer, cx| {
5105 buffer.set_text(markdown, cx);
5106 buffer.set_capability(language::Capability::ReadWrite, cx);
5107 });
5108
5109 workspace.update_in(cx, |workspace, window, cx| {
5110 let buffer = cx
5111 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
5112
5113 workspace.add_item_to_active_pane(
5114 Box::new(cx.new(|cx| {
5115 let mut editor =
5116 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5117 editor.set_breadcrumb_header(thread_title);
5118 editor
5119 })),
5120 None,
5121 true,
5122 window,
5123 cx,
5124 );
5125 })?;
5126 anyhow::Ok(())
5127 })
5128 }
5129
5130 pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context<Self>) {
5131 let has_messages = self.list_state.item_count() > 0;
5132 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
5133
5134 let mode = if v2_empty_state {
5135 EditorMode::Full {
5136 scale_ui_elements_with_buffer_font_size: false,
5137 show_active_line_background: false,
5138 sizing_behavior: SizingBehavior::Default,
5139 }
5140 } else {
5141 EditorMode::AutoHeight {
5142 min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
5143 max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()),
5144 }
5145 };
5146 self.message_editor.update(cx, |editor, cx| {
5147 editor.set_mode(mode, cx);
5148 });
5149 }
5150
5151 /// Ensures the list item count includes (or excludes) an extra item for the generating indicator
5152 pub(crate) fn sync_generating_indicator(&mut self, cx: &App) {
5153 let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating);
5154
5155 if is_generating && !self.generating_indicator_in_list {
5156 let entries_count = self.thread.read(cx).entries().len();
5157 self.list_state.splice(entries_count..entries_count, 1);
5158 self.generating_indicator_in_list = true;
5159 } else if !is_generating && self.generating_indicator_in_list {
5160 let entries_count = self.thread.read(cx).entries().len();
5161 self.list_state.splice(entries_count..entries_count + 1, 0);
5162 self.generating_indicator_in_list = false;
5163 }
5164 }
5165
5166 fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
5167 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
5168 let elapsed_label = show_stats
5169 .then(|| {
5170 self.turn_fields.turn_started_at.and_then(|started_at| {
5171 let elapsed = started_at.elapsed();
5172 (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
5173 })
5174 })
5175 .flatten();
5176
5177 let is_blocked_on_terminal_command =
5178 !confirmation && self.is_blocked_on_terminal_command(cx);
5179 let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls();
5180
5181 let turn_tokens_label = elapsed_label
5182 .is_some()
5183 .then(|| {
5184 self.turn_fields
5185 .turn_tokens
5186 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
5187 .map(|tokens| crate::humanize_token_count(tokens))
5188 })
5189 .flatten();
5190
5191 let arrow_icon = if is_waiting {
5192 IconName::ArrowUp
5193 } else {
5194 IconName::ArrowDown
5195 };
5196
5197 h_flex()
5198 .id("generating-spinner")
5199 .py_2()
5200 .px(rems_from_px(22.))
5201 .gap_2()
5202 .map(|this| {
5203 if confirmation {
5204 this.child(
5205 h_flex()
5206 .w_2()
5207 .child(SpinnerLabel::sand().size(LabelSize::Small)),
5208 )
5209 .child(
5210 div().min_w(rems(8.)).child(
5211 LoadingLabel::new("Awaiting Confirmation")
5212 .size(LabelSize::Small)
5213 .color(Color::Muted),
5214 ),
5215 )
5216 } else if is_blocked_on_terminal_command {
5217 this
5218 } else {
5219 this.child(SpinnerLabel::new().size(LabelSize::Small))
5220 }
5221 })
5222 .when_some(elapsed_label, |this, elapsed| {
5223 this.child(
5224 Label::new(elapsed)
5225 .size(LabelSize::Small)
5226 .color(Color::Muted),
5227 )
5228 })
5229 .when_some(turn_tokens_label, |this, tokens| {
5230 this.child(
5231 h_flex()
5232 .gap_0p5()
5233 .child(
5234 Icon::new(arrow_icon)
5235 .size(IconSize::XSmall)
5236 .color(Color::Muted),
5237 )
5238 .child(
5239 Label::new(format!("{} tokens", tokens))
5240 .size(LabelSize::Small)
5241 .color(Color::Muted),
5242 ),
5243 )
5244 })
5245 .into_any_element()
5246 }
5247
5248 pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context<Self>) {
5249 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5250
5251 if !matches!(
5252 thinking_display,
5253 ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview
5254 ) {
5255 return;
5256 }
5257
5258 let key = {
5259 let thread = self.thread.read(cx);
5260 if thread.status() != ThreadStatus::Generating {
5261 return;
5262 }
5263 let entries = thread.entries();
5264 let last_ix = entries.len().saturating_sub(1);
5265 match entries.get(last_ix) {
5266 Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() {
5267 Some(AssistantMessageChunk::Thought { .. }) => {
5268 Some((last_ix, msg.chunks.len() - 1))
5269 }
5270 _ => None,
5271 },
5272 _ => None,
5273 }
5274 };
5275
5276 if let Some(key) = key {
5277 if self.auto_expanded_thinking_block != Some(key) {
5278 self.auto_expanded_thinking_block = Some(key);
5279 self.expanded_thinking_blocks.insert(key);
5280 cx.notify();
5281 }
5282 } else if self.auto_expanded_thinking_block.is_some() {
5283 if thinking_display == ThinkingBlockDisplay::Auto {
5284 if let Some(key) = self.auto_expanded_thinking_block {
5285 if !self.user_toggled_thinking_blocks.contains(&key) {
5286 self.expanded_thinking_blocks.remove(&key);
5287 }
5288 }
5289 }
5290 self.auto_expanded_thinking_block = None;
5291 cx.notify();
5292 }
5293 }
5294
5295 pub(crate) fn clear_auto_expand_tracking(&mut self) {
5296 self.auto_expanded_thinking_block = None;
5297 }
5298
5299 fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context<Self>) {
5300 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5301
5302 match thinking_display {
5303 ThinkingBlockDisplay::Auto => {
5304 let is_open = self.expanded_thinking_blocks.contains(&key)
5305 || self.user_toggled_thinking_blocks.contains(&key);
5306
5307 if is_open {
5308 self.expanded_thinking_blocks.remove(&key);
5309 self.user_toggled_thinking_blocks.remove(&key);
5310 } else {
5311 self.expanded_thinking_blocks.insert(key);
5312 self.user_toggled_thinking_blocks.insert(key);
5313 }
5314 }
5315 ThinkingBlockDisplay::Preview => {
5316 let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key);
5317 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5318
5319 if is_user_expanded {
5320 self.user_toggled_thinking_blocks.remove(&key);
5321 self.expanded_thinking_blocks.remove(&key);
5322 } else if is_in_expanded_set {
5323 self.user_toggled_thinking_blocks.insert(key);
5324 } else {
5325 self.expanded_thinking_blocks.insert(key);
5326 self.user_toggled_thinking_blocks.insert(key);
5327 }
5328 }
5329 ThinkingBlockDisplay::AlwaysExpanded => {
5330 if self.user_toggled_thinking_blocks.contains(&key) {
5331 self.user_toggled_thinking_blocks.remove(&key);
5332 } else {
5333 self.user_toggled_thinking_blocks.insert(key);
5334 }
5335 }
5336 ThinkingBlockDisplay::AlwaysCollapsed => {
5337 if self.user_toggled_thinking_blocks.contains(&key) {
5338 self.user_toggled_thinking_blocks.remove(&key);
5339 self.expanded_thinking_blocks.remove(&key);
5340 } else {
5341 self.expanded_thinking_blocks.insert(key);
5342 self.user_toggled_thinking_blocks.insert(key);
5343 }
5344 }
5345 }
5346
5347 cx.notify();
5348 }
5349
5350 fn render_thinking_block(
5351 &self,
5352 entry_ix: usize,
5353 chunk_ix: usize,
5354 chunk: Entity<Markdown>,
5355 window: &Window,
5356 cx: &Context<Self>,
5357 ) -> AnyElement {
5358 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
5359 let card_header_id = SharedString::from("inner-card-header");
5360
5361 let key = (entry_ix, chunk_ix);
5362
5363 let thinking_display = AgentSettings::get_global(cx).thinking_display;
5364 let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key);
5365 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
5366
5367 let (is_open, is_constrained) = match thinking_display {
5368 ThinkingBlockDisplay::Auto => {
5369 let is_open = is_user_toggled || is_in_expanded_set;
5370 (is_open, false)
5371 }
5372 ThinkingBlockDisplay::Preview => {
5373 let is_open = is_user_toggled || is_in_expanded_set;
5374 let is_constrained = is_in_expanded_set && !is_user_toggled;
5375 (is_open, is_constrained)
5376 }
5377 ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false),
5378 ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false),
5379 };
5380
5381 let should_auto_scroll = self.auto_expanded_thinking_block == Some(key);
5382
5383 let scroll_handle = self
5384 .entry_view_state
5385 .read(cx)
5386 .entry(entry_ix)
5387 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
5388
5389 if should_auto_scroll {
5390 if let Some(ref handle) = scroll_handle {
5391 handle.scroll_to_bottom();
5392 }
5393 }
5394
5395 let panel_bg = cx.theme().colors().panel_background;
5396
5397 v_flex()
5398 .gap_1()
5399 .child(
5400 h_flex()
5401 .id(header_id)
5402 .group(&card_header_id)
5403 .relative()
5404 .w_full()
5405 .pr_1()
5406 .justify_between()
5407 .child(
5408 h_flex()
5409 .h(window.line_height() - px(2.))
5410 .gap_1p5()
5411 .overflow_hidden()
5412 .child(
5413 Icon::new(IconName::ToolThink)
5414 .size(IconSize::Small)
5415 .color(Color::Muted),
5416 )
5417 .child(
5418 div()
5419 .text_size(self.tool_name_font_size())
5420 .text_color(cx.theme().colors().text_muted)
5421 .child("Thinking"),
5422 ),
5423 )
5424 .child(
5425 Disclosure::new(("expand", entry_ix), is_open)
5426 .opened_icon(IconName::ChevronUp)
5427 .closed_icon(IconName::ChevronDown)
5428 .visible_on_hover(&card_header_id)
5429 .on_click(cx.listener(
5430 move |this, _event: &ClickEvent, _window, cx| {
5431 this.toggle_thinking_block_expansion(key, cx);
5432 },
5433 )),
5434 )
5435 .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
5436 this.toggle_thinking_block_expansion(key, cx);
5437 })),
5438 )
5439 .when(is_open, |this| {
5440 this.child(
5441 div()
5442 .when(is_constrained, |this| this.relative())
5443 .child(
5444 div()
5445 .id(("thinking-content", chunk_ix))
5446 .ml_1p5()
5447 .pl_3p5()
5448 .border_l_1()
5449 .border_color(self.tool_card_border_color(cx))
5450 .when(is_constrained, |this| this.max_h_64())
5451 .when_some(scroll_handle, |this, scroll_handle| {
5452 this.track_scroll(&scroll_handle)
5453 })
5454 .overflow_hidden()
5455 .child(self.render_markdown(
5456 chunk,
5457 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
5458 )),
5459 )
5460 .when(is_constrained, |this| {
5461 this.child(
5462 div()
5463 .absolute()
5464 .inset_0()
5465 .size_full()
5466 .bg(linear_gradient(
5467 180.,
5468 linear_color_stop(panel_bg.opacity(0.8), 0.),
5469 linear_color_stop(panel_bg.opacity(0.), 0.1),
5470 ))
5471 .block_mouse_except_scroll(),
5472 )
5473 }),
5474 )
5475 })
5476 .into_any_element()
5477 }
5478
5479 fn render_message_context_menu(
5480 &self,
5481 entry_ix: usize,
5482 message_body: AnyElement,
5483 cx: &Context<Self>,
5484 ) -> AnyElement {
5485 let entity = cx.entity();
5486 let workspace = self.workspace.clone();
5487
5488 right_click_menu(format!("agent_context_menu-{}", entry_ix))
5489 .trigger(move |_, _, _| message_body)
5490 .menu(move |window, cx| {
5491 let focus = window.focused(cx);
5492 let entity = entity.clone();
5493 let workspace = workspace.clone();
5494
5495 ContextMenu::build(window, cx, move |menu, _, cx| {
5496 let this = entity.read(cx);
5497 let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
5498
5499 let has_selection = this
5500 .thread
5501 .read(cx)
5502 .entries()
5503 .get(entry_ix)
5504 .and_then(|entry| match &entry {
5505 AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
5506 _ => None,
5507 })
5508 .map(|chunks| {
5509 chunks.iter().any(|chunk| {
5510 let md = match chunk {
5511 AssistantMessageChunk::Message { block } => block.markdown(),
5512 AssistantMessageChunk::Thought { block } => block.markdown(),
5513 };
5514 md.map_or(false, |m| m.read(cx).selected_text().is_some())
5515 })
5516 })
5517 .unwrap_or(false);
5518
5519 let copy_this_agent_response =
5520 ContextMenuEntry::new("Copy This Agent Response").handler({
5521 let entity = entity.clone();
5522 move |_, cx| {
5523 entity.update(cx, |this, cx| {
5524 let entries = this.thread.read(cx).entries();
5525 if let Some(text) =
5526 Self::get_agent_message_content(entries, entry_ix, cx)
5527 {
5528 cx.write_to_clipboard(ClipboardItem::new_string(text));
5529 }
5530 });
5531 }
5532 });
5533
5534 let scroll_item = if is_at_top {
5535 ContextMenuEntry::new("Scroll to Bottom").handler({
5536 let entity = entity.clone();
5537 move |_, cx| {
5538 entity.update(cx, |this, cx| {
5539 this.scroll_to_end(cx);
5540 });
5541 }
5542 })
5543 } else {
5544 ContextMenuEntry::new("Scroll to Top").handler({
5545 let entity = entity.clone();
5546 move |_, cx| {
5547 entity.update(cx, |this, cx| {
5548 this.scroll_to_top(cx);
5549 });
5550 }
5551 })
5552 };
5553
5554 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
5555 .handler({
5556 let entity = entity.clone();
5557 let workspace = workspace.clone();
5558 move |window, cx| {
5559 if let Some(workspace) = workspace.upgrade() {
5560 entity
5561 .update(cx, |this, cx| {
5562 this.open_thread_as_markdown(workspace, window, cx)
5563 })
5564 .detach_and_log_err(cx);
5565 }
5566 }
5567 });
5568
5569 menu.when_some(focus, |menu, focus| menu.context(focus))
5570 .action_disabled_when(
5571 !has_selection,
5572 "Copy Selection",
5573 Box::new(markdown::CopyAsMarkdown),
5574 )
5575 .item(copy_this_agent_response)
5576 .separator()
5577 .item(scroll_item)
5578 .item(open_thread_as_markdown)
5579 })
5580 })
5581 .into_any_element()
5582 }
5583
5584 fn get_agent_message_content(
5585 entries: &[AgentThreadEntry],
5586 entry_index: usize,
5587 cx: &App,
5588 ) -> Option<String> {
5589 let entry = entries.get(entry_index)?;
5590 if matches!(entry, AgentThreadEntry::UserMessage(_)) {
5591 return None;
5592 }
5593
5594 let start_index = (0..entry_index)
5595 .rev()
5596 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5597 .map(|i| i + 1)
5598 .unwrap_or(0);
5599
5600 let end_index = (entry_index + 1..entries.len())
5601 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
5602 .map(|i| i - 1)
5603 .unwrap_or(entries.len() - 1);
5604
5605 let parts: Vec<String> = (start_index..=end_index)
5606 .filter_map(|i| entries.get(i))
5607 .filter_map(|entry| {
5608 if let AgentThreadEntry::AssistantMessage(message) = entry {
5609 let text: String = message
5610 .chunks
5611 .iter()
5612 .filter_map(|chunk| match chunk {
5613 AssistantMessageChunk::Message { block } => {
5614 let markdown = block.to_markdown(cx);
5615 if markdown.trim().is_empty() {
5616 None
5617 } else {
5618 Some(markdown.to_string())
5619 }
5620 }
5621 AssistantMessageChunk::Thought { .. } => None,
5622 })
5623 .collect::<Vec<_>>()
5624 .join("\n\n");
5625
5626 if text.is_empty() { None } else { Some(text) }
5627 } else {
5628 None
5629 }
5630 })
5631 .collect();
5632
5633 let text = parts.join("\n\n");
5634 if text.is_empty() { None } else { Some(text) }
5635 }
5636
5637 fn is_blocked_on_terminal_command(&self, cx: &App) -> bool {
5638 let thread = self.thread.read(cx);
5639 if !matches!(thread.status(), ThreadStatus::Generating) {
5640 return false;
5641 }
5642
5643 let mut has_running_terminal_call = false;
5644
5645 for entry in thread.entries().iter().rev() {
5646 match entry {
5647 AgentThreadEntry::UserMessage(_) => break,
5648 AgentThreadEntry::ToolCall(tool_call)
5649 if matches!(
5650 tool_call.status,
5651 ToolCallStatus::InProgress | ToolCallStatus::Pending
5652 ) =>
5653 {
5654 if matches!(tool_call.kind, acp::ToolKind::Execute) {
5655 has_running_terminal_call = true;
5656 } else {
5657 return false;
5658 }
5659 }
5660 AgentThreadEntry::ToolCall(_)
5661 | AgentThreadEntry::AssistantMessage(_)
5662 | AgentThreadEntry::CompletedPlan(_) => {}
5663 }
5664 }
5665
5666 has_running_terminal_call
5667 }
5668
5669 fn render_collapsible_command(
5670 &self,
5671 group: SharedString,
5672 is_preview: bool,
5673 command_source: &str,
5674 cx: &Context<Self>,
5675 ) -> Div {
5676 v_flex()
5677 .group(group.clone())
5678 .p_1p5()
5679 .bg(self.tool_card_header_bg(cx))
5680 .when(is_preview, |this| {
5681 this.pt_1().child(
5682 // Wrapping this label on a container with 24px height to avoid
5683 // layout shift when it changes from being a preview label
5684 // to the actual path where the command will run in
5685 h_flex().h_6().child(
5686 Label::new("Run Command")
5687 .buffer_font(cx)
5688 .size(LabelSize::XSmall)
5689 .color(Color::Muted),
5690 ),
5691 )
5692 })
5693 .children(command_source.lines().map(|line| {
5694 let text: SharedString = if line.is_empty() {
5695 " ".into()
5696 } else {
5697 line.to_string().into()
5698 };
5699
5700 Label::new(text).buffer_font(cx).size(LabelSize::Small)
5701 }))
5702 .child(
5703 div().absolute().top_1().right_1().child(
5704 CopyButton::new("copy-command", command_source.to_string())
5705 .tooltip_label("Copy Command")
5706 .visible_on_hover(group),
5707 ),
5708 )
5709 }
5710
5711 fn render_terminal_tool_call(
5712 &self,
5713 active_session_id: &acp::SessionId,
5714 entry_ix: usize,
5715 terminal: &Entity<acp_thread::Terminal>,
5716 tool_call: &ToolCall,
5717 focus_handle: &FocusHandle,
5718 is_subagent: bool,
5719 window: &Window,
5720 cx: &Context<Self>,
5721 ) -> AnyElement {
5722 let terminal_data = terminal.read(cx);
5723 let working_dir = terminal_data.working_dir();
5724 let command = terminal_data.command();
5725 let started_at = terminal_data.started_at();
5726
5727 let tool_failed = matches!(
5728 &tool_call.status,
5729 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
5730 );
5731
5732 let confirmation_options = match &tool_call.status {
5733 ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options),
5734 _ => None,
5735 };
5736 let needs_confirmation = confirmation_options.is_some();
5737
5738 let output = terminal_data.output();
5739 let command_finished = output.is_some()
5740 && !matches!(
5741 tool_call.status,
5742 ToolCallStatus::InProgress | ToolCallStatus::Pending
5743 );
5744 let truncated_output =
5745 output.is_some_and(|output| output.original_content_len > output.content.len());
5746 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
5747
5748 let command_failed = command_finished
5749 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
5750
5751 let time_elapsed = if let Some(output) = output {
5752 output.ended_at.duration_since(started_at)
5753 } else {
5754 started_at.elapsed()
5755 };
5756
5757 let header_id =
5758 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
5759 let header_group = SharedString::from(format!(
5760 "terminal-tool-header-group-{}",
5761 terminal.entity_id()
5762 ));
5763 let header_bg = cx
5764 .theme()
5765 .colors()
5766 .element_background
5767 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
5768 let border_color = cx.theme().colors().border.opacity(0.6);
5769
5770 let working_dir = working_dir
5771 .as_ref()
5772 .map(|path| path.display().to_string())
5773 .unwrap_or_else(|| "current directory".to_string());
5774
5775 // Since the command's source is wrapped in a markdown code block
5776 // (```\n...\n```), we need to strip that so we're left with only the
5777 // command's content.
5778 let command_source = command.read(cx).source();
5779 let command_content = command_source
5780 .strip_prefix("```\n")
5781 .and_then(|s| s.strip_suffix("\n```"))
5782 .unwrap_or(&command_source);
5783
5784 let command_element =
5785 self.render_collapsible_command(header_group.clone(), false, command_content, cx);
5786
5787 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
5788
5789 let header = h_flex()
5790 .id(header_id)
5791 .pt_1()
5792 .pl_1p5()
5793 .pr_1()
5794 .flex_none()
5795 .gap_1()
5796 .justify_between()
5797 .rounded_t_md()
5798 .child(
5799 div()
5800 .id(("command-target-path", terminal.entity_id()))
5801 .w_full()
5802 .max_w_full()
5803 .overflow_x_scroll()
5804 .child(
5805 Label::new(working_dir)
5806 .buffer_font(cx)
5807 .size(LabelSize::XSmall)
5808 .color(Color::Muted),
5809 ),
5810 )
5811 .child(
5812 Disclosure::new(
5813 SharedString::from(format!(
5814 "terminal-tool-disclosure-{}",
5815 terminal.entity_id()
5816 )),
5817 is_expanded,
5818 )
5819 .opened_icon(IconName::ChevronUp)
5820 .closed_icon(IconName::ChevronDown)
5821 .visible_on_hover(&header_group)
5822 .on_click(cx.listener({
5823 let id = tool_call.id.clone();
5824 move |this, _event, _window, cx| {
5825 if is_expanded {
5826 this.expanded_tool_calls.remove(&id);
5827 } else {
5828 this.expanded_tool_calls.insert(id.clone());
5829 }
5830 cx.notify();
5831 }
5832 })),
5833 )
5834 .when(time_elapsed > Duration::from_secs(10), |header| {
5835 header.child(
5836 Label::new(format!("({})", duration_alt_display(time_elapsed)))
5837 .buffer_font(cx)
5838 .color(Color::Muted)
5839 .size(LabelSize::XSmall),
5840 )
5841 })
5842 .when(!command_finished && !needs_confirmation, |header| {
5843 header
5844 .gap_1p5()
5845 .child(
5846 Icon::new(IconName::ArrowCircle)
5847 .size(IconSize::XSmall)
5848 .color(Color::Muted)
5849 .with_rotate_animation(2)
5850 )
5851 .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border)))
5852 .child(
5853 IconButton::new(
5854 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
5855 IconName::Stop
5856 )
5857 .icon_size(IconSize::Small)
5858 .icon_color(Color::Error)
5859 .tooltip(move |_window, cx| {
5860 Tooltip::with_meta(
5861 "Stop This Command",
5862 None,
5863 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
5864 cx,
5865 )
5866 })
5867 .on_click({
5868 let terminal = terminal.clone();
5869 cx.listener(move |this, _event, _window, cx| {
5870 terminal.update(cx, |terminal, cx| {
5871 terminal.stop_by_user(cx);
5872 });
5873 if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
5874 this.cancel_generation(cx);
5875 }
5876 })
5877 }),
5878 )
5879 })
5880 .when(truncated_output, |header| {
5881 let tooltip = if let Some(output) = output {
5882 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
5883 format!("Output exceeded terminal max lines and was \
5884 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
5885 } else {
5886 format!(
5887 "Output is {} long, and to avoid unexpected token usage, \
5888 only {} was sent back to the agent.",
5889 format_file_size(output.original_content_len as u64, true),
5890 format_file_size(output.content.len() as u64, true)
5891 )
5892 }
5893 } else {
5894 "Output was truncated".to_string()
5895 };
5896
5897 header.child(
5898 h_flex()
5899 .id(("terminal-tool-truncated-label", terminal.entity_id()))
5900 .gap_1()
5901 .child(
5902 Icon::new(IconName::Info)
5903 .size(IconSize::XSmall)
5904 .color(Color::Ignored),
5905 )
5906 .child(
5907 Label::new("Truncated")
5908 .color(Color::Muted)
5909 .size(LabelSize::XSmall),
5910 )
5911 .tooltip(Tooltip::text(tooltip)),
5912 )
5913 })
5914 .when(tool_failed || command_failed, |header| {
5915 header.child(
5916 div()
5917 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
5918 .child(
5919 Icon::new(IconName::Close)
5920 .size(IconSize::Small)
5921 .color(Color::Error),
5922 )
5923 .when_some(output.and_then(|o| o.exit_status), |this, status| {
5924 this.tooltip(Tooltip::text(format!(
5925 "Exited with code {}",
5926 status.code().unwrap_or(-1),
5927 )))
5928 }),
5929 )
5930 })
5931;
5932
5933 let terminal_view = self
5934 .entry_view_state
5935 .read(cx)
5936 .entry(entry_ix)
5937 .and_then(|entry| entry.terminal(terminal));
5938
5939 v_flex()
5940 .when(!is_subagent, |this| {
5941 this.my_1p5()
5942 .mx_5()
5943 .border_1()
5944 .when(tool_failed || command_failed, |card| card.border_dashed())
5945 .border_color(border_color)
5946 .rounded_md()
5947 })
5948 .overflow_hidden()
5949 .child(
5950 v_flex()
5951 .group(&header_group)
5952 .bg(header_bg)
5953 .text_xs()
5954 .child(header)
5955 .child(command_element),
5956 )
5957 .when(is_expanded && terminal_view.is_some(), |this| {
5958 this.child(
5959 div()
5960 .pt_2()
5961 .border_t_1()
5962 .when(tool_failed || command_failed, |card| card.border_dashed())
5963 .border_color(border_color)
5964 .bg(cx.theme().colors().editor_background)
5965 .rounded_b_md()
5966 .text_ui_sm(cx)
5967 .h_full()
5968 .children(terminal_view.map(|terminal_view| {
5969 let element = if terminal_view
5970 .read(cx)
5971 .content_mode(window, cx)
5972 .is_scrollable()
5973 {
5974 div().h_72().child(terminal_view).into_any_element()
5975 } else {
5976 terminal_view.into_any_element()
5977 };
5978
5979 div()
5980 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
5981 window.dispatch_action(NewThread.boxed_clone(), cx);
5982 cx.stop_propagation();
5983 }))
5984 .child(element)
5985 .into_any_element()
5986 })),
5987 )
5988 })
5989 .when_some(confirmation_options, |this, options| {
5990 let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
5991 this.child(self.render_permission_buttons(
5992 self.id.clone(),
5993 is_first,
5994 options,
5995 entry_ix,
5996 tool_call.id.clone(),
5997 focus_handle,
5998 cx,
5999 ))
6000 })
6001 .into_any()
6002 }
6003
6004 fn is_first_tool_call(
6005 &self,
6006 active_session_id: &acp::SessionId,
6007 tool_call_id: &acp::ToolCallId,
6008 cx: &App,
6009 ) -> bool {
6010 self.conversation
6011 .read(cx)
6012 .pending_tool_call(active_session_id, cx)
6013 .map_or(false, |(pending_session_id, pending_tool_call_id, _)| {
6014 self.id == pending_session_id && tool_call_id == &pending_tool_call_id
6015 })
6016 }
6017
6018 fn render_any_tool_call(
6019 &self,
6020 active_session_id: &acp::SessionId,
6021 entry_ix: usize,
6022 tool_call: &ToolCall,
6023 focus_handle: &FocusHandle,
6024 is_subagent: bool,
6025 window: &Window,
6026 cx: &Context<Self>,
6027 ) -> Div {
6028 let has_terminals = tool_call.terminals().next().is_some();
6029
6030 div().w_full().map(|this| {
6031 if tool_call.is_subagent() {
6032 this.child(
6033 self.render_subagent_tool_call(
6034 active_session_id,
6035 entry_ix,
6036 tool_call,
6037 tool_call
6038 .subagent_session_info
6039 .as_ref()
6040 .map(|i| i.session_id.clone()),
6041 focus_handle,
6042 window,
6043 cx,
6044 ),
6045 )
6046 } else if has_terminals {
6047 this.children(tool_call.terminals().map(|terminal| {
6048 self.render_terminal_tool_call(
6049 active_session_id,
6050 entry_ix,
6051 terminal,
6052 tool_call,
6053 focus_handle,
6054 is_subagent,
6055 window,
6056 cx,
6057 )
6058 }))
6059 } else {
6060 this.child(self.render_tool_call(
6061 active_session_id,
6062 entry_ix,
6063 tool_call,
6064 focus_handle,
6065 is_subagent,
6066 window,
6067 cx,
6068 ))
6069 }
6070 })
6071 }
6072
6073 fn render_tool_call(
6074 &self,
6075 active_session_id: &acp::SessionId,
6076 entry_ix: usize,
6077 tool_call: &ToolCall,
6078 focus_handle: &FocusHandle,
6079 is_subagent: bool,
6080 window: &Window,
6081 cx: &Context<Self>,
6082 ) -> Div {
6083 let has_location = tool_call.locations.len() == 1;
6084 let card_header_id = SharedString::from("inner-tool-call-header");
6085
6086 let failed_or_canceled = match &tool_call.status {
6087 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
6088 _ => false,
6089 };
6090
6091 let needs_confirmation = matches!(
6092 tool_call.status,
6093 ToolCallStatus::WaitingForConfirmation { .. }
6094 );
6095 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
6096
6097 let is_edit =
6098 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
6099
6100 let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
6101 let (has_revealed_diff, tool_call_output_focus, tool_call_output_focus_handle) = tool_call
6102 .diffs()
6103 .next()
6104 .and_then(|diff| {
6105 let editor = self
6106 .entry_view_state
6107 .read(cx)
6108 .entry(entry_ix)
6109 .and_then(|entry| entry.editor_for_diff(diff))?;
6110 let has_revealed_diff = diff.read(cx).has_revealed_range(cx);
6111 let has_focus = editor.read(cx).is_focused(window);
6112 let focus_handle = editor.focus_handle(cx);
6113 Some((has_revealed_diff, has_focus, focus_handle))
6114 })
6115 .unwrap_or_else(|| (false, false, focus_handle.clone()));
6116
6117 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
6118
6119 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
6120 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
6121 let mut is_open = self.expanded_tool_calls.contains(&tool_call.id);
6122
6123 is_open |= needs_confirmation;
6124
6125 let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
6126
6127 let input_output_header = |label: SharedString| {
6128 Label::new(label)
6129 .size(LabelSize::XSmall)
6130 .color(Color::Muted)
6131 .buffer_font(cx)
6132 };
6133
6134 let tool_output_display = if is_open {
6135 match &tool_call.status {
6136 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
6137 .w_full()
6138 .children(
6139 tool_call
6140 .content
6141 .iter()
6142 .enumerate()
6143 .map(|(content_ix, content)| {
6144 div()
6145 .child(self.render_tool_call_content(
6146 active_session_id,
6147 entry_ix,
6148 content,
6149 content_ix,
6150 tool_call,
6151 use_card_layout,
6152 has_image_content,
6153 failed_or_canceled,
6154 focus_handle,
6155 window,
6156 cx,
6157 ))
6158 .into_any_element()
6159 }),
6160 )
6161 .when(should_show_raw_input, |this| {
6162 let is_raw_input_expanded =
6163 self.expanded_tool_call_raw_inputs.contains(&tool_call.id);
6164
6165 let input_header = if is_raw_input_expanded {
6166 "Raw Input:"
6167 } else {
6168 "View Raw Input"
6169 };
6170
6171 this.child(
6172 v_flex()
6173 .p_2()
6174 .gap_1()
6175 .border_t_1()
6176 .border_color(self.tool_card_border_color(cx))
6177 .child(
6178 h_flex()
6179 .id("disclosure_container")
6180 .pl_0p5()
6181 .gap_1()
6182 .justify_between()
6183 .rounded_xs()
6184 .hover(|s| s.bg(cx.theme().colors().element_hover))
6185 .child(input_output_header(input_header.into()))
6186 .child(
6187 Disclosure::new(
6188 ("raw-input-disclosure", entry_ix),
6189 is_raw_input_expanded,
6190 )
6191 .opened_icon(IconName::ChevronUp)
6192 .closed_icon(IconName::ChevronDown),
6193 )
6194 .on_click(cx.listener({
6195 let id = tool_call.id.clone();
6196
6197 move |this: &mut Self, _, _, cx| {
6198 if this.expanded_tool_call_raw_inputs.contains(&id)
6199 {
6200 this.expanded_tool_call_raw_inputs.remove(&id);
6201 } else {
6202 this.expanded_tool_call_raw_inputs
6203 .insert(id.clone());
6204 }
6205 cx.notify();
6206 }
6207 })),
6208 )
6209 .when(is_raw_input_expanded, |this| {
6210 this.children(tool_call.raw_input_markdown.clone().map(
6211 |input| {
6212 self.render_markdown(
6213 input,
6214 MarkdownStyle::themed(
6215 MarkdownFont::Agent,
6216 window,
6217 cx,
6218 ),
6219 )
6220 },
6221 ))
6222 }),
6223 )
6224 })
6225 .child(self.render_permission_buttons(
6226 self.id.clone(),
6227 self.is_first_tool_call(active_session_id, &tool_call.id, cx),
6228 options,
6229 entry_ix,
6230 tool_call.id.clone(),
6231 focus_handle,
6232 cx,
6233 ))
6234 .into_any(),
6235 ToolCallStatus::Pending | ToolCallStatus::InProgress
6236 if is_edit
6237 && tool_call.content.is_empty()
6238 && self.as_native_connection(cx).is_some() =>
6239 {
6240 self.render_diff_loading(cx)
6241 }
6242 ToolCallStatus::Pending
6243 | ToolCallStatus::InProgress
6244 | ToolCallStatus::Completed
6245 | ToolCallStatus::Failed
6246 | ToolCallStatus::Canceled => v_flex()
6247 .when(should_show_raw_input, |this| {
6248 this.mt_1p5().w_full().child(
6249 v_flex()
6250 .ml(rems(0.4))
6251 .px_3p5()
6252 .pb_1()
6253 .gap_1()
6254 .border_l_1()
6255 .border_color(self.tool_card_border_color(cx))
6256 .child(input_output_header("Raw Input:".into()))
6257 .children(tool_call.raw_input_markdown.clone().map(|input| {
6258 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
6259 self.render_markdown(
6260 input,
6261 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
6262 ),
6263 )
6264 }))
6265 .child(input_output_header("Output:".into())),
6266 )
6267 })
6268 .children(
6269 tool_call
6270 .content
6271 .iter()
6272 .enumerate()
6273 .map(|(content_ix, content)| {
6274 div().id(("tool-call-output", entry_ix)).child(
6275 self.render_tool_call_content(
6276 active_session_id,
6277 entry_ix,
6278 content,
6279 content_ix,
6280 tool_call,
6281 use_card_layout,
6282 has_image_content,
6283 failed_or_canceled,
6284 focus_handle,
6285 window,
6286 cx,
6287 ),
6288 )
6289 }),
6290 )
6291 .into_any(),
6292 ToolCallStatus::Rejected => Empty.into_any(),
6293 }
6294 .into()
6295 } else {
6296 None
6297 };
6298
6299 v_flex()
6300 .map(|this| {
6301 if is_subagent {
6302 this
6303 } else if use_card_layout {
6304 this.my_1p5()
6305 .rounded_md()
6306 .border_1()
6307 .when(failed_or_canceled, |this| this.border_dashed())
6308 .border_color(self.tool_card_border_color(cx))
6309 .bg(cx.theme().colors().editor_background)
6310 .overflow_hidden()
6311 } else {
6312 this.my_1()
6313 }
6314 })
6315 .when(!is_subagent, |this| {
6316 this.map(|this| {
6317 if has_location && !use_card_layout {
6318 this.ml_4()
6319 } else {
6320 this.ml_5()
6321 }
6322 })
6323 .mr_5()
6324 })
6325 .map(|this| {
6326 if is_terminal_tool {
6327 let label_source = tool_call.label.read(cx).source();
6328 this.child(self.render_collapsible_command(
6329 card_header_id.clone(),
6330 true,
6331 label_source,
6332 cx,
6333 ))
6334 } else {
6335 this.child(
6336 h_flex()
6337 .group(&card_header_id)
6338 .relative()
6339 .w_full()
6340 .justify_between()
6341 .when(use_card_layout, |this| {
6342 this.p_0p5()
6343 .rounded_t(rems_from_px(5.))
6344 .bg(self.tool_card_header_bg(cx))
6345 })
6346 .child(self.render_tool_call_label(
6347 entry_ix,
6348 tool_call,
6349 is_edit,
6350 is_cancelled_edit,
6351 has_revealed_diff,
6352 use_card_layout,
6353 window,
6354 cx,
6355 ))
6356 .child(
6357 h_flex()
6358 .when(is_collapsible || failed_or_canceled, |this| {
6359 let diff_for_discard = if has_revealed_diff
6360 && is_cancelled_edit
6361 && cx.has_flag::<AgentV2FeatureFlag>()
6362 {
6363 tool_call.diffs().next().cloned()
6364 } else {
6365 None
6366 };
6367
6368 this.child(
6369 h_flex()
6370 .pr_0p5()
6371 .gap_1()
6372 .when(is_collapsible, |this| {
6373 this.child(
6374 Disclosure::new(
6375 ("expand-output", entry_ix),
6376 is_open,
6377 )
6378 .opened_icon(IconName::ChevronUp)
6379 .closed_icon(IconName::ChevronDown)
6380 .visible_on_hover(&card_header_id)
6381 .on_click(cx.listener({
6382 let id = tool_call.id.clone();
6383 move |this: &mut Self,
6384 _,
6385 _,
6386 cx: &mut Context<Self>| {
6387 if is_open {
6388 this.expanded_tool_calls
6389 .remove(&id);
6390 } else {
6391 this.expanded_tool_calls
6392 .insert(id.clone());
6393 }
6394 cx.notify();
6395 }
6396 })),
6397 )
6398 })
6399 .when(failed_or_canceled, |this| {
6400 if is_cancelled_edit && !has_revealed_diff {
6401 this.child(
6402 div()
6403 .id(entry_ix)
6404 .tooltip(Tooltip::text(
6405 "Interrupted Edit",
6406 ))
6407 .child(
6408 Icon::new(IconName::XCircle)
6409 .color(Color::Muted)
6410 .size(IconSize::Small),
6411 ),
6412 )
6413 } else if is_cancelled_edit {
6414 this
6415 } else {
6416 this.child(
6417 Icon::new(IconName::Close)
6418 .color(Color::Error)
6419 .size(IconSize::Small),
6420 )
6421 }
6422 })
6423 .when_some(diff_for_discard, |this, diff| {
6424 let tool_call_id = tool_call.id.clone();
6425 let is_discarded = self
6426 .discarded_partial_edits
6427 .contains(&tool_call_id);
6428
6429 this.when(!is_discarded, |this| {
6430 this.child(
6431 IconButton::new(
6432 ("discard-partial-edit", entry_ix),
6433 IconName::Undo,
6434 )
6435 .icon_size(IconSize::Small)
6436 .tooltip(move |_, cx| {
6437 Tooltip::with_meta(
6438 "Discard Interrupted Edit",
6439 None,
6440 "You can discard this interrupted partial edit and restore the original file content.",
6441 cx,
6442 )
6443 })
6444 .on_click(cx.listener({
6445 let tool_call_id =
6446 tool_call_id.clone();
6447 move |this, _, _window, cx| {
6448 let diff_data = diff.read(cx);
6449 let base_text = diff_data
6450 .base_text()
6451 .clone();
6452 let buffer =
6453 diff_data.buffer().clone();
6454 buffer.update(
6455 cx,
6456 |buffer, cx| {
6457 buffer.set_text(
6458 base_text.as_ref(),
6459 cx,
6460 );
6461 },
6462 );
6463 this.discarded_partial_edits
6464 .insert(
6465 tool_call_id.clone(),
6466 );
6467 cx.notify();
6468 }
6469 })),
6470 )
6471 })
6472 }),
6473 )
6474 })
6475 .when(tool_call_output_focus, |this| {
6476 this.child(
6477 Button::new("open-file-button", "Open File")
6478 .style(ButtonStyle::Outlined)
6479 .label_size(LabelSize::Small)
6480 .key_binding(
6481 KeyBinding::for_action_in(&OpenExcerpts, &tool_call_output_focus_handle, cx)
6482 .map(|s| s.size(rems_from_px(12.))),
6483 )
6484 .on_click(|_, window, cx| {
6485 window.dispatch_action(
6486 Box::new(OpenExcerpts),
6487 cx,
6488 )
6489 }),
6490 )
6491 }),
6492 )
6493
6494 )
6495 }
6496 })
6497 .children(tool_output_display)
6498 }
6499
6500 fn render_permission_buttons(
6501 &self,
6502 session_id: acp::SessionId,
6503 is_first: bool,
6504 options: &PermissionOptions,
6505 entry_ix: usize,
6506 tool_call_id: acp::ToolCallId,
6507 focus_handle: &FocusHandle,
6508 cx: &Context<Self>,
6509 ) -> Div {
6510 match options {
6511 PermissionOptions::Flat(options) => self.render_permission_buttons_flat(
6512 session_id,
6513 is_first,
6514 options,
6515 entry_ix,
6516 tool_call_id,
6517 focus_handle,
6518 cx,
6519 ),
6520 PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
6521 is_first,
6522 choices,
6523 None,
6524 entry_ix,
6525 tool_call_id,
6526 focus_handle,
6527 cx,
6528 ),
6529 PermissionOptions::DropdownWithPatterns {
6530 choices,
6531 patterns,
6532 tool_name,
6533 } => self.render_permission_buttons_with_dropdown(
6534 is_first,
6535 choices,
6536 Some((patterns, tool_name)),
6537 entry_ix,
6538 tool_call_id,
6539 focus_handle,
6540 cx,
6541 ),
6542 }
6543 }
6544
6545 fn render_permission_buttons_with_dropdown(
6546 &self,
6547 is_first: bool,
6548 choices: &[PermissionOptionChoice],
6549 patterns: Option<(&[PermissionPattern], &str)>,
6550 entry_ix: usize,
6551 tool_call_id: acp::ToolCallId,
6552 focus_handle: &FocusHandle,
6553 cx: &Context<Self>,
6554 ) -> Div {
6555 let selection = self.permission_selections.get(&tool_call_id);
6556
6557 let selected_index = selection
6558 .and_then(|s| s.choice_index())
6559 .unwrap_or_else(|| choices.len().saturating_sub(1));
6560
6561 let dropdown_label: SharedString =
6562 if matches!(selection, Some(PermissionSelection::SelectedPatterns(_))) {
6563 "Always for selected commands".into()
6564 } else {
6565 choices
6566 .get(selected_index)
6567 .or(choices.last())
6568 .map(|choice| choice.label())
6569 .unwrap_or_else(|| "Only this time".into())
6570 };
6571
6572 let dropdown = if let Some((pattern_list, tool_name)) = patterns {
6573 self.render_permission_granularity_dropdown_with_patterns(
6574 choices,
6575 pattern_list,
6576 tool_name,
6577 dropdown_label,
6578 entry_ix,
6579 tool_call_id.clone(),
6580 is_first,
6581 cx,
6582 )
6583 } else {
6584 self.render_permission_granularity_dropdown(
6585 choices,
6586 dropdown_label,
6587 entry_ix,
6588 tool_call_id.clone(),
6589 selected_index,
6590 is_first,
6591 cx,
6592 )
6593 };
6594
6595 h_flex()
6596 .w_full()
6597 .p_1()
6598 .gap_2()
6599 .justify_between()
6600 .border_t_1()
6601 .border_color(self.tool_card_border_color(cx))
6602 .child(
6603 h_flex()
6604 .gap_0p5()
6605 .child(
6606 Button::new(("allow-btn", entry_ix), "Allow")
6607 .start_icon(
6608 Icon::new(IconName::Check)
6609 .size(IconSize::XSmall)
6610 .color(Color::Success),
6611 )
6612 .label_size(LabelSize::Small)
6613 .when(is_first, |this| {
6614 this.key_binding(
6615 KeyBinding::for_action_in(
6616 &AllowOnce as &dyn Action,
6617 focus_handle,
6618 cx,
6619 )
6620 .map(|kb| kb.size(rems_from_px(12.))),
6621 )
6622 })
6623 .on_click(cx.listener({
6624 move |this, _, window, cx| {
6625 this.authorize_pending_with_granularity(true, window, cx);
6626 }
6627 })),
6628 )
6629 .child(
6630 Button::new(("deny-btn", entry_ix), "Deny")
6631 .start_icon(
6632 Icon::new(IconName::Close)
6633 .size(IconSize::XSmall)
6634 .color(Color::Error),
6635 )
6636 .label_size(LabelSize::Small)
6637 .when(is_first, |this| {
6638 this.key_binding(
6639 KeyBinding::for_action_in(
6640 &RejectOnce as &dyn Action,
6641 focus_handle,
6642 cx,
6643 )
6644 .map(|kb| kb.size(rems_from_px(12.))),
6645 )
6646 })
6647 .on_click(cx.listener({
6648 move |this, _, window, cx| {
6649 this.authorize_pending_with_granularity(false, window, cx);
6650 }
6651 })),
6652 ),
6653 )
6654 .child(dropdown)
6655 }
6656
6657 fn render_permission_granularity_dropdown(
6658 &self,
6659 choices: &[PermissionOptionChoice],
6660 current_label: SharedString,
6661 entry_ix: usize,
6662 tool_call_id: acp::ToolCallId,
6663 selected_index: usize,
6664 is_first: bool,
6665 cx: &Context<Self>,
6666 ) -> AnyElement {
6667 let menu_options: Vec<(usize, SharedString)> = choices
6668 .iter()
6669 .enumerate()
6670 .map(|(i, choice)| (i, choice.label()))
6671 .collect();
6672
6673 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6674
6675 PopoverMenu::new(("permission-granularity", entry_ix))
6676 .with_handle(permission_dropdown_handle)
6677 .trigger(
6678 Button::new(("granularity-trigger", entry_ix), current_label)
6679 .end_icon(
6680 Icon::new(IconName::ChevronDown)
6681 .size(IconSize::XSmall)
6682 .color(Color::Muted),
6683 )
6684 .label_size(LabelSize::Small)
6685 .when(is_first, |this| {
6686 this.key_binding(
6687 KeyBinding::for_action_in(
6688 &crate::OpenPermissionDropdown as &dyn Action,
6689 &self.focus_handle(cx),
6690 cx,
6691 )
6692 .map(|kb| kb.size(rems_from_px(12.))),
6693 )
6694 }),
6695 )
6696 .menu(move |window, cx| {
6697 let tool_call_id = tool_call_id.clone();
6698 let options = menu_options.clone();
6699
6700 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
6701 for (index, display_name) in options.iter() {
6702 let display_name = display_name.clone();
6703 let index = *index;
6704 let tool_call_id_for_entry = tool_call_id.clone();
6705 let is_selected = index == selected_index;
6706 menu = menu.toggleable_entry(
6707 display_name,
6708 is_selected,
6709 IconPosition::End,
6710 None,
6711 move |window, cx| {
6712 window.dispatch_action(
6713 SelectPermissionGranularity {
6714 tool_call_id: tool_call_id_for_entry.0.to_string(),
6715 index,
6716 }
6717 .boxed_clone(),
6718 cx,
6719 );
6720 },
6721 );
6722 }
6723
6724 menu
6725 }))
6726 })
6727 .into_any_element()
6728 }
6729
6730 fn render_permission_granularity_dropdown_with_patterns(
6731 &self,
6732 choices: &[PermissionOptionChoice],
6733 patterns: &[PermissionPattern],
6734 _tool_name: &str,
6735 current_label: SharedString,
6736 entry_ix: usize,
6737 tool_call_id: acp::ToolCallId,
6738 is_first: bool,
6739 cx: &Context<Self>,
6740 ) -> AnyElement {
6741 let default_choice_index = choices.len().saturating_sub(1);
6742 let menu_options: Vec<(usize, SharedString)> = choices
6743 .iter()
6744 .enumerate()
6745 .map(|(i, choice)| (i, choice.label()))
6746 .collect();
6747
6748 let pattern_options: Vec<(usize, SharedString)> = patterns
6749 .iter()
6750 .enumerate()
6751 .map(|(i, cp)| {
6752 (
6753 i,
6754 SharedString::from(format!("Always for `{}` commands", cp.display_name)),
6755 )
6756 })
6757 .collect();
6758
6759 let pattern_count = patterns.len();
6760 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6761 let view = cx.entity().downgrade();
6762
6763 PopoverMenu::new(("permission-granularity", entry_ix))
6764 .with_handle(permission_dropdown_handle.clone())
6765 .anchor(Corner::TopRight)
6766 .attach(Corner::BottomRight)
6767 .trigger(
6768 Button::new(("granularity-trigger", entry_ix), current_label)
6769 .end_icon(
6770 Icon::new(IconName::ChevronDown)
6771 .size(IconSize::XSmall)
6772 .color(Color::Muted),
6773 )
6774 .label_size(LabelSize::Small)
6775 .when(is_first, |this| {
6776 this.key_binding(
6777 KeyBinding::for_action_in(
6778 &crate::OpenPermissionDropdown as &dyn Action,
6779 &self.focus_handle(cx),
6780 cx,
6781 )
6782 .map(|kb| kb.size(rems_from_px(12.))),
6783 )
6784 }),
6785 )
6786 .menu(move |window, cx| {
6787 let tool_call_id = tool_call_id.clone();
6788 let options = menu_options.clone();
6789 let patterns = pattern_options.clone();
6790 let view = view.clone();
6791 let dropdown_handle = permission_dropdown_handle.clone();
6792
6793 Some(ContextMenu::build_persistent(
6794 window,
6795 cx,
6796 move |menu, _window, cx| {
6797 let mut menu = menu;
6798
6799 // Read fresh selection state from the view on each rebuild.
6800 let selection: Option<PermissionSelection> = view.upgrade().and_then(|v| {
6801 let view = v.read(cx);
6802 view.permission_selections.get(&tool_call_id).cloned()
6803 });
6804
6805 let is_pattern_mode =
6806 matches!(selection, Some(PermissionSelection::SelectedPatterns(_)));
6807
6808 // Granularity choices: "Always for terminal", "Only this time"
6809 for (index, display_name) in options.iter() {
6810 let display_name = display_name.clone();
6811 let index = *index;
6812 let tool_call_id_for_entry = tool_call_id.clone();
6813 let is_selected = !is_pattern_mode
6814 && selection
6815 .as_ref()
6816 .and_then(|s| s.choice_index())
6817 .map_or(index == default_choice_index, |ci| ci == index);
6818
6819 let view = view.clone();
6820 menu = menu.toggleable_entry(
6821 display_name,
6822 is_selected,
6823 IconPosition::End,
6824 None,
6825 move |_window, cx| {
6826 view.update(cx, |this, cx| {
6827 this.permission_selections.insert(
6828 tool_call_id_for_entry.clone(),
6829 PermissionSelection::Choice(index),
6830 );
6831 cx.notify();
6832 })
6833 .log_err();
6834 },
6835 );
6836 }
6837
6838 menu = menu.separator().header("Select Options…");
6839
6840 for (pattern_index, label) in patterns.iter() {
6841 let label = label.clone();
6842 let pattern_index = *pattern_index;
6843 let tool_call_id_for_pattern = tool_call_id.clone();
6844 let is_checked = selection
6845 .as_ref()
6846 .is_some_and(|s| s.is_pattern_checked(pattern_index));
6847
6848 let view = view.clone();
6849 menu = menu.toggleable_entry(
6850 label,
6851 is_checked,
6852 IconPosition::End,
6853 None,
6854 move |_window, cx| {
6855 view.update(cx, |this, cx| {
6856 let selection = this
6857 .permission_selections
6858 .get_mut(&tool_call_id_for_pattern);
6859
6860 match selection {
6861 Some(PermissionSelection::SelectedPatterns(_)) => {
6862 // Already in pattern mode — toggle.
6863 this.permission_selections
6864 .get_mut(&tool_call_id_for_pattern)
6865 .expect("just matched above")
6866 .toggle_pattern(pattern_index);
6867 }
6868 _ => {
6869 // First click: activate pattern mode
6870 // with all patterns checked.
6871 this.permission_selections.insert(
6872 tool_call_id_for_pattern.clone(),
6873 PermissionSelection::SelectedPatterns(
6874 (0..pattern_count).collect(),
6875 ),
6876 );
6877 }
6878 }
6879 cx.notify();
6880 })
6881 .log_err();
6882 },
6883 );
6884 }
6885
6886 let any_patterns_checked = selection
6887 .as_ref()
6888 .is_some_and(|s| s.has_any_checked_patterns());
6889 let dropdown_handle = dropdown_handle.clone();
6890 menu = menu.custom_row(move |_window, _cx| {
6891 div()
6892 .py_1()
6893 .w_full()
6894 .child(
6895 Button::new("apply-patterns", "Apply")
6896 .full_width()
6897 .style(ButtonStyle::Outlined)
6898 .label_size(LabelSize::Small)
6899 .disabled(!any_patterns_checked)
6900 .on_click({
6901 let dropdown_handle = dropdown_handle.clone();
6902 move |_event, _window, cx| {
6903 dropdown_handle.hide(cx);
6904 }
6905 }),
6906 )
6907 .into_any_element()
6908 });
6909
6910 menu
6911 },
6912 ))
6913 })
6914 .into_any_element()
6915 }
6916
6917 fn render_permission_buttons_flat(
6918 &self,
6919 session_id: acp::SessionId,
6920 is_first: bool,
6921 options: &[acp::PermissionOption],
6922 entry_ix: usize,
6923 tool_call_id: acp::ToolCallId,
6924 focus_handle: &FocusHandle,
6925 cx: &Context<Self>,
6926 ) -> Div {
6927 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = ArrayVec::new();
6928
6929 div()
6930 .p_1()
6931 .border_t_1()
6932 .border_color(self.tool_card_border_color(cx))
6933 .w_full()
6934 .v_flex()
6935 .gap_0p5()
6936 .children(options.iter().map(move |option| {
6937 let option_id = SharedString::from(option.option_id.0.clone());
6938 Button::new((option_id, entry_ix), option.name.clone())
6939 .map(|this| {
6940 let (icon, action) = match option.kind {
6941 acp::PermissionOptionKind::AllowOnce => (
6942 Icon::new(IconName::Check)
6943 .size(IconSize::XSmall)
6944 .color(Color::Success),
6945 Some(&AllowOnce as &dyn Action),
6946 ),
6947 acp::PermissionOptionKind::AllowAlways => (
6948 Icon::new(IconName::CheckDouble)
6949 .size(IconSize::XSmall)
6950 .color(Color::Success),
6951 Some(&AllowAlways as &dyn Action),
6952 ),
6953 acp::PermissionOptionKind::RejectOnce => (
6954 Icon::new(IconName::Close)
6955 .size(IconSize::XSmall)
6956 .color(Color::Error),
6957 Some(&RejectOnce as &dyn Action),
6958 ),
6959 acp::PermissionOptionKind::RejectAlways | _ => (
6960 Icon::new(IconName::Close)
6961 .size(IconSize::XSmall)
6962 .color(Color::Error),
6963 None,
6964 ),
6965 };
6966
6967 let this = this.start_icon(icon);
6968
6969 let Some(action) = action else {
6970 return this;
6971 };
6972
6973 if !is_first || seen_kinds.contains(&option.kind) {
6974 return this;
6975 }
6976
6977 seen_kinds.push(option.kind).unwrap();
6978
6979 this.key_binding(
6980 KeyBinding::for_action_in(action, focus_handle, cx)
6981 .map(|kb| kb.size(rems_from_px(12.))),
6982 )
6983 })
6984 .label_size(LabelSize::Small)
6985 .on_click(cx.listener({
6986 let session_id = session_id.clone();
6987 let tool_call_id = tool_call_id.clone();
6988 let option_id = option.option_id.clone();
6989 let option_kind = option.kind;
6990 move |this, _, window, cx| {
6991 this.authorize_tool_call(
6992 session_id.clone(),
6993 tool_call_id.clone(),
6994 SelectedPermissionOutcome::new(option_id.clone(), option_kind),
6995 window,
6996 cx,
6997 );
6998 }
6999 }))
7000 }))
7001 }
7002
7003 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
7004 let bar = |n: u64, width_class: &str| {
7005 let bg_color = cx.theme().colors().element_active;
7006 let base = h_flex().h_1().rounded_full();
7007
7008 let modified = match width_class {
7009 "w_4_5" => base.w_3_4(),
7010 "w_1_4" => base.w_1_4(),
7011 "w_2_4" => base.w_2_4(),
7012 "w_3_5" => base.w_3_5(),
7013 "w_2_5" => base.w_2_5(),
7014 _ => base.w_1_2(),
7015 };
7016
7017 modified.with_animation(
7018 ElementId::Integer(n),
7019 Animation::new(Duration::from_secs(2)).repeat(),
7020 move |tab, delta| {
7021 let delta = (delta - 0.15 * n as f32) / 0.7;
7022 let delta = 1.0 - (0.5 - delta).abs() * 2.;
7023 let delta = ease_in_out(delta.clamp(0., 1.));
7024 let delta = 0.1 + 0.9 * delta;
7025
7026 tab.bg(bg_color.opacity(delta))
7027 },
7028 )
7029 };
7030
7031 v_flex()
7032 .p_3()
7033 .gap_1()
7034 .rounded_b_md()
7035 .bg(cx.theme().colors().editor_background)
7036 .child(bar(0, "w_4_5"))
7037 .child(bar(1, "w_1_4"))
7038 .child(bar(2, "w_2_4"))
7039 .child(bar(3, "w_3_5"))
7040 .child(bar(4, "w_2_5"))
7041 .into_any_element()
7042 }
7043
7044 fn render_tool_call_label(
7045 &self,
7046 entry_ix: usize,
7047 tool_call: &ToolCall,
7048 is_edit: bool,
7049 has_failed: bool,
7050 has_revealed_diff: bool,
7051 use_card_layout: bool,
7052 window: &Window,
7053 cx: &Context<Self>,
7054 ) -> Div {
7055 let has_location = tool_call.locations.len() == 1;
7056 let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
7057 let is_subagent_tool_call = tool_call.is_subagent();
7058
7059 let file_icon = if has_location {
7060 FileIcons::get_icon(&tool_call.locations[0].path, cx)
7061 .map(|from_path| Icon::from_path(from_path).color(Color::Muted))
7062 .unwrap_or(Icon::new(IconName::ToolPencil).color(Color::Muted))
7063 } else {
7064 Icon::new(IconName::ToolPencil).color(Color::Muted)
7065 };
7066
7067 let tool_icon = if is_file && has_failed && has_revealed_diff {
7068 div()
7069 .id(entry_ix)
7070 .tooltip(Tooltip::text("Interrupted Edit"))
7071 .child(DecoratedIcon::new(
7072 file_icon,
7073 Some(
7074 IconDecoration::new(
7075 IconDecorationKind::Triangle,
7076 self.tool_card_header_bg(cx),
7077 cx,
7078 )
7079 .color(cx.theme().status().warning)
7080 .position(gpui::Point {
7081 x: px(-2.),
7082 y: px(-2.),
7083 }),
7084 ),
7085 ))
7086 .into_any_element()
7087 } else if is_file {
7088 div().child(file_icon).into_any_element()
7089 } else if is_subagent_tool_call {
7090 Icon::new(self.agent_icon)
7091 .size(IconSize::Small)
7092 .color(Color::Muted)
7093 .into_any_element()
7094 } else {
7095 Icon::new(match tool_call.kind {
7096 acp::ToolKind::Read => IconName::ToolSearch,
7097 acp::ToolKind::Edit => IconName::ToolPencil,
7098 acp::ToolKind::Delete => IconName::ToolDeleteFile,
7099 acp::ToolKind::Move => IconName::ArrowRightLeft,
7100 acp::ToolKind::Search => IconName::ToolSearch,
7101 acp::ToolKind::Execute => IconName::ToolTerminal,
7102 acp::ToolKind::Think => IconName::ToolThink,
7103 acp::ToolKind::Fetch => IconName::ToolWeb,
7104 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
7105 acp::ToolKind::Other | _ => IconName::ToolHammer,
7106 })
7107 .size(IconSize::Small)
7108 .color(Color::Muted)
7109 .into_any_element()
7110 };
7111
7112 let gradient_overlay = {
7113 div()
7114 .absolute()
7115 .top_0()
7116 .right_0()
7117 .w_12()
7118 .h_full()
7119 .map(|this| {
7120 if use_card_layout {
7121 this.bg(linear_gradient(
7122 90.,
7123 linear_color_stop(self.tool_card_header_bg(cx), 1.),
7124 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
7125 ))
7126 } else {
7127 this.bg(linear_gradient(
7128 90.,
7129 linear_color_stop(cx.theme().colors().panel_background, 1.),
7130 linear_color_stop(
7131 cx.theme().colors().panel_background.opacity(0.2),
7132 0.,
7133 ),
7134 ))
7135 }
7136 })
7137 };
7138
7139 h_flex()
7140 .relative()
7141 .w_full()
7142 .h(window.line_height() - px(2.))
7143 .text_size(self.tool_name_font_size())
7144 .gap_1p5()
7145 .when(has_location || use_card_layout, |this| this.px_1())
7146 .when(has_location, |this| {
7147 this.cursor(CursorStyle::PointingHand)
7148 .rounded(rems_from_px(3.)) // Concentric border radius
7149 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
7150 })
7151 .overflow_hidden()
7152 .child(tool_icon)
7153 .child(if has_location {
7154 h_flex()
7155 .id(("open-tool-call-location", entry_ix))
7156 .w_full()
7157 .map(|this| {
7158 if use_card_layout {
7159 this.text_color(cx.theme().colors().text)
7160 } else {
7161 this.text_color(cx.theme().colors().text_muted)
7162 }
7163 })
7164 .child(
7165 self.render_markdown(
7166 tool_call.label.clone(),
7167 MarkdownStyle {
7168 prevent_mouse_interaction: true,
7169 ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx)
7170 .with_muted_text(cx)
7171 },
7172 ),
7173 )
7174 .tooltip(Tooltip::text("Go to File"))
7175 .on_click(cx.listener(move |this, _, window, cx| {
7176 this.open_tool_call_location(entry_ix, 0, window, cx);
7177 }))
7178 .into_any_element()
7179 } else {
7180 h_flex()
7181 .w_full()
7182 .child(self.render_markdown(
7183 tool_call.label.clone(),
7184 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx),
7185 ))
7186 .into_any()
7187 })
7188 .when(!is_edit, |this| this.child(gradient_overlay))
7189 }
7190
7191 fn open_tool_call_location(
7192 &self,
7193 entry_ix: usize,
7194 location_ix: usize,
7195 window: &mut Window,
7196 cx: &mut Context<Self>,
7197 ) -> Option<()> {
7198 let (tool_call_location, agent_location) = self
7199 .thread
7200 .read(cx)
7201 .entries()
7202 .get(entry_ix)?
7203 .location(location_ix)?;
7204
7205 let project_path = self
7206 .project
7207 .upgrade()?
7208 .read(cx)
7209 .find_project_path(&tool_call_location.path, cx)?;
7210
7211 let open_task = self
7212 .workspace
7213 .update(cx, |workspace, cx| {
7214 workspace.open_path(project_path, None, true, window, cx)
7215 })
7216 .log_err()?;
7217 window
7218 .spawn(cx, async move |cx| {
7219 let item = open_task.await?;
7220
7221 let Some(active_editor) = item.downcast::<Editor>() else {
7222 return anyhow::Ok(());
7223 };
7224
7225 active_editor.update_in(cx, |editor, window, cx| {
7226 let snapshot = editor.buffer().read(cx).snapshot(cx);
7227 if snapshot.as_singleton().is_some()
7228 && let Some(anchor) = snapshot.anchor_in_excerpt(agent_location.position)
7229 {
7230 editor.change_selections(Default::default(), window, cx, |selections| {
7231 selections.select_anchor_ranges([anchor..anchor]);
7232 })
7233 } else {
7234 let row = tool_call_location.line.unwrap_or_default();
7235 editor.change_selections(Default::default(), window, cx, |selections| {
7236 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
7237 })
7238 }
7239 })?;
7240
7241 anyhow::Ok(())
7242 })
7243 .detach_and_log_err(cx);
7244
7245 None
7246 }
7247
7248 fn render_tool_call_content(
7249 &self,
7250 session_id: &acp::SessionId,
7251 entry_ix: usize,
7252 content: &ToolCallContent,
7253 context_ix: usize,
7254 tool_call: &ToolCall,
7255 card_layout: bool,
7256 is_image_tool_call: bool,
7257 has_failed: bool,
7258 focus_handle: &FocusHandle,
7259 window: &Window,
7260 cx: &Context<Self>,
7261 ) -> AnyElement {
7262 match content {
7263 ToolCallContent::ContentBlock(content) => {
7264 if let Some(resource_link) = content.resource_link() {
7265 self.render_resource_link(resource_link, cx)
7266 } else if let Some(markdown) = content.markdown() {
7267 self.render_markdown_output(
7268 markdown.clone(),
7269 tool_call.id.clone(),
7270 context_ix,
7271 card_layout,
7272 window,
7273 cx,
7274 )
7275 } else if let Some(image) = content.image() {
7276 let location = tool_call.locations.first().cloned();
7277 self.render_image_output(
7278 entry_ix,
7279 image.clone(),
7280 location,
7281 card_layout,
7282 is_image_tool_call,
7283 cx,
7284 )
7285 } else {
7286 Empty.into_any_element()
7287 }
7288 }
7289 ToolCallContent::Diff(diff) => {
7290 self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
7291 }
7292 ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call(
7293 session_id,
7294 entry_ix,
7295 terminal,
7296 tool_call,
7297 focus_handle,
7298 false,
7299 window,
7300 cx,
7301 ),
7302 }
7303 }
7304
7305 fn render_resource_link(
7306 &self,
7307 resource_link: &acp::ResourceLink,
7308 cx: &Context<Self>,
7309 ) -> AnyElement {
7310 let uri: SharedString = resource_link.uri.clone().into();
7311 let is_file = resource_link.uri.strip_prefix("file://");
7312
7313 let Some(project) = self.project.upgrade() else {
7314 return Empty.into_any_element();
7315 };
7316
7317 let label: SharedString = if let Some(abs_path) = is_file {
7318 if let Some(project_path) = project
7319 .read(cx)
7320 .project_path_for_absolute_path(&Path::new(abs_path), cx)
7321 && let Some(worktree) = project
7322 .read(cx)
7323 .worktree_for_id(project_path.worktree_id, cx)
7324 {
7325 worktree
7326 .read(cx)
7327 .full_path(&project_path.path)
7328 .to_string_lossy()
7329 .to_string()
7330 .into()
7331 } else {
7332 abs_path.to_string().into()
7333 }
7334 } else {
7335 uri.clone()
7336 };
7337
7338 let button_id = SharedString::from(format!("item-{}", uri));
7339
7340 div()
7341 .ml(rems(0.4))
7342 .pl_2p5()
7343 .border_l_1()
7344 .border_color(self.tool_card_border_color(cx))
7345 .overflow_hidden()
7346 .child(
7347 Button::new(button_id, label)
7348 .label_size(LabelSize::Small)
7349 .color(Color::Muted)
7350 .truncate(true)
7351 .when(is_file.is_none(), |this| {
7352 this.end_icon(
7353 Icon::new(IconName::ArrowUpRight)
7354 .size(IconSize::XSmall)
7355 .color(Color::Muted),
7356 )
7357 })
7358 .on_click(cx.listener({
7359 let workspace = self.workspace.clone();
7360 move |_, _, window, cx: &mut Context<Self>| {
7361 open_link(uri.clone(), &workspace, window, cx);
7362 }
7363 })),
7364 )
7365 .into_any_element()
7366 }
7367
7368 fn render_diff_editor(
7369 &self,
7370 entry_ix: usize,
7371 diff: &Entity<acp_thread::Diff>,
7372 tool_call: &ToolCall,
7373 has_failed: bool,
7374 cx: &Context<Self>,
7375 ) -> AnyElement {
7376 let tool_progress = matches!(
7377 &tool_call.status,
7378 ToolCallStatus::InProgress | ToolCallStatus::Pending
7379 );
7380
7381 let revealed_diff_editor = if let Some(entry) =
7382 self.entry_view_state.read(cx).entry(entry_ix)
7383 && let Some(editor) = entry.editor_for_diff(diff)
7384 && diff.read(cx).has_revealed_range(cx)
7385 {
7386 Some(editor)
7387 } else {
7388 None
7389 };
7390
7391 let show_top_border = !has_failed || revealed_diff_editor.is_some();
7392
7393 v_flex()
7394 .h_full()
7395 .when(show_top_border, |this| {
7396 this.border_t_1()
7397 .when(has_failed, |this| this.border_dashed())
7398 .border_color(self.tool_card_border_color(cx))
7399 })
7400 .child(if let Some(editor) = revealed_diff_editor {
7401 editor.into_any_element()
7402 } else if tool_progress && self.as_native_connection(cx).is_some() {
7403 self.render_diff_loading(cx)
7404 } else {
7405 Empty.into_any()
7406 })
7407 .into_any()
7408 }
7409
7410 fn render_markdown_output(
7411 &self,
7412 markdown: Entity<Markdown>,
7413 tool_call_id: acp::ToolCallId,
7414 context_ix: usize,
7415 card_layout: bool,
7416 window: &Window,
7417 cx: &Context<Self>,
7418 ) -> AnyElement {
7419 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
7420
7421 v_flex()
7422 .gap_2()
7423 .map(|this| {
7424 if card_layout {
7425 this.when(context_ix > 0, |this| {
7426 this.pt_2()
7427 .border_t_1()
7428 .border_color(self.tool_card_border_color(cx))
7429 })
7430 } else {
7431 this.ml(rems(0.4))
7432 .px_3p5()
7433 .border_l_1()
7434 .border_color(self.tool_card_border_color(cx))
7435 }
7436 })
7437 .text_xs()
7438 .text_color(cx.theme().colors().text_muted)
7439 .child(self.render_markdown(
7440 markdown,
7441 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
7442 ))
7443 .when(!card_layout, |this| {
7444 this.child(
7445 IconButton::new(button_id, IconName::ChevronUp)
7446 .full_width()
7447 .style(ButtonStyle::Outlined)
7448 .icon_color(Color::Muted)
7449 .on_click(cx.listener({
7450 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
7451 this.expanded_tool_calls.remove(&tool_call_id);
7452 cx.notify();
7453 }
7454 })),
7455 )
7456 })
7457 .into_any_element()
7458 }
7459
7460 fn render_image_output(
7461 &self,
7462 entry_ix: usize,
7463 image: Arc<gpui::Image>,
7464 location: Option<acp::ToolCallLocation>,
7465 card_layout: bool,
7466 show_dimensions: bool,
7467 cx: &Context<Self>,
7468 ) -> AnyElement {
7469 let dimensions_label = if show_dimensions {
7470 let format_name = match image.format() {
7471 gpui::ImageFormat::Png => "PNG",
7472 gpui::ImageFormat::Jpeg => "JPEG",
7473 gpui::ImageFormat::Webp => "WebP",
7474 gpui::ImageFormat::Gif => "GIF",
7475 gpui::ImageFormat::Svg => "SVG",
7476 gpui::ImageFormat::Bmp => "BMP",
7477 gpui::ImageFormat::Tiff => "TIFF",
7478 gpui::ImageFormat::Ico => "ICO",
7479 };
7480 let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
7481 .with_guessed_format()
7482 .ok()
7483 .and_then(|reader| reader.into_dimensions().ok());
7484 dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
7485 } else {
7486 None
7487 };
7488
7489 v_flex()
7490 .gap_2()
7491 .map(|this| {
7492 if card_layout {
7493 this
7494 } else {
7495 this.ml(rems(0.4))
7496 .px_3p5()
7497 .border_l_1()
7498 .border_color(self.tool_card_border_color(cx))
7499 }
7500 })
7501 .when(dimensions_label.is_some() || location.is_some(), |this| {
7502 this.child(
7503 h_flex()
7504 .w_full()
7505 .justify_between()
7506 .items_center()
7507 .children(dimensions_label.map(|label| {
7508 Label::new(label)
7509 .size(LabelSize::XSmall)
7510 .color(Color::Muted)
7511 .buffer_font(cx)
7512 }))
7513 .when_some(location, |this, _loc| {
7514 this.child(
7515 Button::new(("go-to-file", entry_ix), "Go to File")
7516 .label_size(LabelSize::Small)
7517 .on_click(cx.listener(move |this, _, window, cx| {
7518 this.open_tool_call_location(entry_ix, 0, window, cx);
7519 })),
7520 )
7521 }),
7522 )
7523 })
7524 .child(
7525 img(image)
7526 .max_w_96()
7527 .max_h_96()
7528 .object_fit(ObjectFit::ScaleDown),
7529 )
7530 .into_any_element()
7531 }
7532
7533 fn render_subagent_tool_call(
7534 &self,
7535 active_session_id: &acp::SessionId,
7536 entry_ix: usize,
7537 tool_call: &ToolCall,
7538 subagent_session_id: Option<acp::SessionId>,
7539 focus_handle: &FocusHandle,
7540 window: &Window,
7541 cx: &Context<Self>,
7542 ) -> Div {
7543 let subagent_thread_view = subagent_session_id.and_then(|id| {
7544 self.server_view
7545 .upgrade()
7546 .and_then(|server_view| server_view.read(cx).as_connected())
7547 .and_then(|connected| connected.threads.get(&id))
7548 });
7549
7550 let content = self.render_subagent_card(
7551 active_session_id,
7552 entry_ix,
7553 subagent_thread_view,
7554 tool_call,
7555 focus_handle,
7556 window,
7557 cx,
7558 );
7559
7560 v_flex().mx_5().my_1p5().gap_3().child(content)
7561 }
7562
7563 fn render_subagent_card(
7564 &self,
7565 active_session_id: &acp::SessionId,
7566 entry_ix: usize,
7567 thread_view: Option<&Entity<ThreadView>>,
7568 tool_call: &ToolCall,
7569 focus_handle: &FocusHandle,
7570 window: &Window,
7571 cx: &Context<Self>,
7572 ) -> AnyElement {
7573 let thread = thread_view
7574 .as_ref()
7575 .map(|view| view.read(cx).thread.clone());
7576 let subagent_session_id = thread
7577 .as_ref()
7578 .map(|thread| thread.read(cx).session_id().clone());
7579 let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log());
7580 let changed_buffers = action_log
7581 .map(|log| log.read(cx).changed_buffers(cx))
7582 .unwrap_or_default();
7583
7584 let is_pending_tool_call = thread
7585 .as_ref()
7586 .and_then(|thread| {
7587 self.conversation
7588 .read(cx)
7589 .pending_tool_call(thread.read(cx).session_id(), cx)
7590 })
7591 .is_some();
7592
7593 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
7594 let files_changed = changed_buffers.len();
7595 let diff_stats = DiffStats::all_files(&changed_buffers, cx);
7596
7597 let is_running = matches!(
7598 tool_call.status,
7599 ToolCallStatus::Pending
7600 | ToolCallStatus::InProgress
7601 | ToolCallStatus::WaitingForConfirmation { .. }
7602 );
7603
7604 let is_failed = matches!(
7605 tool_call.status,
7606 ToolCallStatus::Failed | ToolCallStatus::Rejected
7607 );
7608
7609 let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled)
7610 || tool_call.content.iter().any(|c| match c {
7611 ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
7612 markdown.read(cx).source() == "User canceled"
7613 }
7614 _ => false,
7615 });
7616
7617 let thread_title = thread
7618 .as_ref()
7619 .and_then(|t| t.read(cx).title())
7620 .filter(|t| !t.is_empty());
7621 let tool_call_label = tool_call.label.read(cx).source().to_string();
7622 let has_tool_call_label = !tool_call_label.is_empty();
7623
7624 let has_title = thread_title.is_some() || has_tool_call_label;
7625 let has_no_title_or_canceled = !has_title || is_failed || is_cancelled;
7626
7627 let title: SharedString = if let Some(thread_title) = thread_title {
7628 thread_title
7629 } else if !tool_call_label.is_empty() {
7630 tool_call_label.into()
7631 } else if is_cancelled {
7632 "Subagent Canceled".into()
7633 } else if is_failed {
7634 "Subagent Failed".into()
7635 } else {
7636 "Spawning Agent…".into()
7637 };
7638
7639 let card_header_id = format!("subagent-header-{}", entry_ix);
7640 let status_icon = format!("status-icon-{}", entry_ix);
7641 let diff_stat_id = format!("subagent-diff-{}", entry_ix);
7642
7643 let icon = h_flex().w_4().justify_center().child(if is_running {
7644 SpinnerLabel::new()
7645 .size(LabelSize::Small)
7646 .into_any_element()
7647 } else if is_cancelled {
7648 div()
7649 .id(status_icon)
7650 .child(
7651 Icon::new(IconName::Circle)
7652 .size(IconSize::Small)
7653 .color(Color::Custom(
7654 cx.theme().colors().icon_disabled.opacity(0.5),
7655 )),
7656 )
7657 .tooltip(Tooltip::text("Subagent Cancelled"))
7658 .into_any_element()
7659 } else if is_failed {
7660 div()
7661 .id(status_icon)
7662 .child(
7663 Icon::new(IconName::Close)
7664 .size(IconSize::Small)
7665 .color(Color::Error),
7666 )
7667 .tooltip(Tooltip::text("Subagent Failed"))
7668 .into_any_element()
7669 } else {
7670 Icon::new(IconName::Check)
7671 .size(IconSize::Small)
7672 .color(Color::Success)
7673 .into_any_element()
7674 });
7675
7676 let has_expandable_content = thread
7677 .as_ref()
7678 .map_or(false, |thread| !thread.read(cx).entries().is_empty());
7679
7680 let tooltip_meta_description = if is_expanded {
7681 "Click to Collapse"
7682 } else {
7683 "Click to Preview"
7684 };
7685
7686 let error_message = self.subagent_error_message(&tool_call.status, tool_call, cx);
7687
7688 v_flex()
7689 .w_full()
7690 .rounded_md()
7691 .border_1()
7692 .when(has_no_title_or_canceled, |this| this.border_dashed())
7693 .border_color(self.tool_card_border_color(cx))
7694 .overflow_hidden()
7695 .child(
7696 h_flex()
7697 .group(&card_header_id)
7698 .h_8()
7699 .p_1()
7700 .w_full()
7701 .justify_between()
7702 .when(!has_no_title_or_canceled, |this| {
7703 this.bg(self.tool_card_header_bg(cx))
7704 })
7705 .child(
7706 h_flex()
7707 .id(format!("subagent-title-{}", entry_ix))
7708 .px_1()
7709 .min_w_0()
7710 .size_full()
7711 .gap_2()
7712 .justify_between()
7713 .rounded_sm()
7714 .overflow_hidden()
7715 .child(
7716 h_flex()
7717 .min_w_0()
7718 .w_full()
7719 .gap_1p5()
7720 .child(icon)
7721 .child(
7722 Label::new(title.to_string())
7723 .size(LabelSize::Custom(self.tool_name_font_size()))
7724 .truncate(),
7725 )
7726 .when(files_changed > 0, |this| {
7727 this.child(
7728 Label::new(format!(
7729 "— {} {} changed",
7730 files_changed,
7731 if files_changed == 1 { "file" } else { "files" }
7732 ))
7733 .size(LabelSize::Custom(self.tool_name_font_size()))
7734 .color(Color::Muted),
7735 )
7736 .child(
7737 DiffStat::new(
7738 diff_stat_id.clone(),
7739 diff_stats.lines_added as usize,
7740 diff_stats.lines_removed as usize,
7741 )
7742 .label_size(LabelSize::Custom(
7743 self.tool_name_font_size(),
7744 )),
7745 )
7746 }),
7747 )
7748 .when(!has_no_title_or_canceled && !is_pending_tool_call, |this| {
7749 this.tooltip(move |_, cx| {
7750 Tooltip::with_meta(
7751 title.to_string(),
7752 None,
7753 tooltip_meta_description,
7754 cx,
7755 )
7756 })
7757 })
7758 .when(has_expandable_content && !is_pending_tool_call, |this| {
7759 this.cursor_pointer()
7760 .hover(|s| s.bg(cx.theme().colors().element_hover))
7761 .child(
7762 div().visible_on_hover(card_header_id).child(
7763 Icon::new(if is_expanded {
7764 IconName::ChevronUp
7765 } else {
7766 IconName::ChevronDown
7767 })
7768 .color(Color::Muted)
7769 .size(IconSize::Small),
7770 ),
7771 )
7772 .on_click(cx.listener({
7773 let tool_call_id = tool_call.id.clone();
7774 move |this, _, _, cx| {
7775 if this.expanded_tool_calls.contains(&tool_call_id) {
7776 this.expanded_tool_calls.remove(&tool_call_id);
7777 } else {
7778 this.expanded_tool_calls
7779 .insert(tool_call_id.clone());
7780 }
7781 let expanded =
7782 this.expanded_tool_calls.contains(&tool_call_id);
7783 telemetry::event!("Subagent Toggled", expanded);
7784 cx.notify();
7785 }
7786 }))
7787 }),
7788 )
7789 .when(is_running && subagent_session_id.is_some(), |buttons| {
7790 buttons.child(
7791 IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop)
7792 .icon_size(IconSize::Small)
7793 .icon_color(Color::Error)
7794 .tooltip(Tooltip::text("Stop Subagent"))
7795 .when_some(
7796 thread_view
7797 .as_ref()
7798 .map(|view| view.read(cx).thread.clone()),
7799 |this, thread| {
7800 this.on_click(cx.listener(
7801 move |_this, _event, _window, cx| {
7802 telemetry::event!("Subagent Stopped");
7803 thread.update(cx, |thread, cx| {
7804 thread.cancel(cx).detach();
7805 });
7806 },
7807 ))
7808 },
7809 ),
7810 )
7811 }),
7812 )
7813 .when_some(thread_view, |this, thread_view| {
7814 let thread = &thread_view.read(cx).thread;
7815 let pending_tool_call = self
7816 .conversation
7817 .read(cx)
7818 .pending_tool_call(thread.read(cx).session_id(), cx);
7819
7820 let session_id = thread.read(cx).session_id().clone();
7821
7822 let fullscreen_toggle = h_flex()
7823 .id(entry_ix)
7824 .py_1()
7825 .w_full()
7826 .justify_center()
7827 .border_t_1()
7828 .when(is_failed, |this| this.border_dashed())
7829 .border_color(self.tool_card_border_color(cx))
7830 .cursor_pointer()
7831 .hover(|s| s.bg(cx.theme().colors().element_hover))
7832 .child(
7833 Icon::new(IconName::Maximize)
7834 .color(Color::Muted)
7835 .size(IconSize::Small),
7836 )
7837 .tooltip(Tooltip::text("Make Subagent Full Screen"))
7838 .on_click(cx.listener(move |this, _event, window, cx| {
7839 telemetry::event!("Subagent Maximized");
7840 this.server_view
7841 .update(cx, |this, cx| {
7842 this.navigate_to_session(session_id.clone(), window, cx);
7843 })
7844 .ok();
7845 }));
7846
7847 if is_running && let Some((_, subagent_tool_call_id, _)) = pending_tool_call {
7848 if let Some((entry_ix, tool_call)) =
7849 thread.read(cx).tool_call(&subagent_tool_call_id)
7850 {
7851 this.child(Divider::horizontal().color(DividerColor::Border))
7852 .child(thread_view.read(cx).render_any_tool_call(
7853 active_session_id,
7854 entry_ix,
7855 tool_call,
7856 focus_handle,
7857 true,
7858 window,
7859 cx,
7860 ))
7861 .child(fullscreen_toggle)
7862 } else {
7863 this
7864 }
7865 } else {
7866 this.when(is_expanded, |this| {
7867 this.child(self.render_subagent_expanded_content(
7868 thread_view,
7869 tool_call,
7870 window,
7871 cx,
7872 ))
7873 .when_some(error_message, |this, message| {
7874 this.child(
7875 Callout::new()
7876 .severity(Severity::Error)
7877 .icon(IconName::XCircle)
7878 .title(message),
7879 )
7880 })
7881 .child(fullscreen_toggle)
7882 })
7883 }
7884 })
7885 .into_any_element()
7886 }
7887
7888 fn render_subagent_expanded_content(
7889 &self,
7890 thread_view: &Entity<ThreadView>,
7891 tool_call: &ToolCall,
7892 window: &Window,
7893 cx: &Context<Self>,
7894 ) -> impl IntoElement {
7895 const MAX_PREVIEW_ENTRIES: usize = 8;
7896
7897 let subagent_view = thread_view.read(cx);
7898 let session_id = subagent_view.thread.read(cx).session_id().clone();
7899
7900 let is_canceled_or_failed = matches!(
7901 tool_call.status,
7902 ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
7903 );
7904
7905 let editor_bg = cx.theme().colors().editor_background;
7906 let overlay = {
7907 div()
7908 .absolute()
7909 .inset_0()
7910 .size_full()
7911 .bg(linear_gradient(
7912 180.,
7913 linear_color_stop(editor_bg.opacity(0.5), 0.),
7914 linear_color_stop(editor_bg.opacity(0.), 0.1),
7915 ))
7916 .block_mouse_except_scroll()
7917 };
7918
7919 let entries = subagent_view.thread.read(cx).entries();
7920 let total_entries = entries.len();
7921 let mut entry_range = if let Some(info) = tool_call.subagent_session_info.as_ref() {
7922 info.message_start_index
7923 ..info
7924 .message_end_index
7925 .map(|i| (i + 1).min(total_entries))
7926 .unwrap_or(total_entries)
7927 } else {
7928 0..total_entries
7929 };
7930 entry_range.start = entry_range
7931 .end
7932 .saturating_sub(MAX_PREVIEW_ENTRIES)
7933 .max(entry_range.start);
7934 let start_ix = entry_range.start;
7935
7936 let scroll_handle = self
7937 .subagent_scroll_handles
7938 .borrow_mut()
7939 .entry(session_id.clone())
7940 .or_default()
7941 .clone();
7942
7943 scroll_handle.scroll_to_bottom();
7944
7945 let rendered_entries: Vec<AnyElement> = entries
7946 .get(entry_range)
7947 .unwrap_or_default()
7948 .iter()
7949 .enumerate()
7950 .map(|(i, entry)| {
7951 let actual_ix = start_ix + i;
7952 subagent_view.render_entry(actual_ix, total_entries, entry, window, cx)
7953 })
7954 .collect();
7955
7956 v_flex()
7957 .w_full()
7958 .border_t_1()
7959 .when(is_canceled_or_failed, |this| this.border_dashed())
7960 .border_color(self.tool_card_border_color(cx))
7961 .overflow_hidden()
7962 .child(
7963 div()
7964 .pb_1()
7965 .min_h_0()
7966 .id(format!("subagent-entries-{}", session_id))
7967 .track_scroll(&scroll_handle)
7968 .children(rendered_entries),
7969 )
7970 .h_56()
7971 .child(overlay)
7972 .into_any_element()
7973 }
7974
7975 fn subagent_error_message(
7976 &self,
7977 status: &ToolCallStatus,
7978 tool_call: &ToolCall,
7979 cx: &App,
7980 ) -> Option<SharedString> {
7981 if matches!(status, ToolCallStatus::Failed) {
7982 tool_call.content.iter().find_map(|content| {
7983 if let ToolCallContent::ContentBlock(block) = content {
7984 if let acp_thread::ContentBlock::Markdown { markdown } = block {
7985 let source = markdown.read(cx).source().to_string();
7986 if !source.is_empty() {
7987 if source == "User canceled" {
7988 return None;
7989 } else {
7990 return Some(SharedString::from(source));
7991 }
7992 }
7993 }
7994 }
7995 None
7996 })
7997 } else {
7998 None
7999 }
8000 }
8001
8002 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
8003 cx.theme()
8004 .colors()
8005 .element_background
8006 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
8007 }
8008
8009 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
8010 cx.theme().colors().border.opacity(0.8)
8011 }
8012
8013 fn tool_name_font_size(&self) -> Rems {
8014 rems_from_px(13.)
8015 }
8016
8017 pub(crate) fn render_thread_error(
8018 &mut self,
8019 window: &mut Window,
8020 cx: &mut Context<Self>,
8021 ) -> Option<Div> {
8022 let content = match self.thread_error.as_ref()? {
8023 ThreadError::Other { message, .. } => {
8024 self.render_any_thread_error(message.clone(), window, cx)
8025 }
8026 ThreadError::Refusal => self.render_refusal_error(cx),
8027 ThreadError::AuthenticationRequired(error) => {
8028 self.render_authentication_required_error(error.clone(), cx)
8029 }
8030 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
8031 };
8032
8033 Some(div().child(content))
8034 }
8035
8036 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
8037 let model_or_agent_name = self.current_model_name(cx);
8038 let refusal_message = format!(
8039 "{} refused to respond to this prompt. \
8040 This can happen when a model believes the prompt violates its content policy \
8041 or safety guidelines, so rephrasing it can sometimes address the issue.",
8042 model_or_agent_name
8043 );
8044
8045 Callout::new()
8046 .severity(Severity::Error)
8047 .title("Request Refused")
8048 .icon(IconName::XCircle)
8049 .description(refusal_message.clone())
8050 .actions_slot(self.create_copy_button(&refusal_message))
8051 .dismiss_action(self.dismiss_error_button(cx))
8052 }
8053
8054 fn render_authentication_required_error(
8055 &self,
8056 error: SharedString,
8057 cx: &mut Context<Self>,
8058 ) -> Callout {
8059 Callout::new()
8060 .severity(Severity::Error)
8061 .title("Authentication Required")
8062 .icon(IconName::XCircle)
8063 .description(error.clone())
8064 .actions_slot(
8065 h_flex()
8066 .gap_0p5()
8067 .child(self.authenticate_button(cx))
8068 .child(self.create_copy_button(error)),
8069 )
8070 .dismiss_action(self.dismiss_error_button(cx))
8071 }
8072
8073 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
8074 const ERROR_MESSAGE: &str =
8075 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
8076
8077 Callout::new()
8078 .severity(Severity::Error)
8079 .icon(IconName::XCircle)
8080 .title("Free Usage Exceeded")
8081 .description(ERROR_MESSAGE)
8082 .actions_slot(
8083 h_flex()
8084 .gap_0p5()
8085 .child(self.upgrade_button(cx))
8086 .child(self.create_copy_button(ERROR_MESSAGE)),
8087 )
8088 .dismiss_action(self.dismiss_error_button(cx))
8089 }
8090
8091 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8092 Button::new("upgrade", "Upgrade")
8093 .label_size(LabelSize::Small)
8094 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
8095 .on_click(cx.listener({
8096 move |this, _, _, cx| {
8097 this.clear_thread_error(cx);
8098 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
8099 }
8100 }))
8101 }
8102
8103 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8104 Button::new("authenticate", "Authenticate")
8105 .label_size(LabelSize::Small)
8106 .style(ButtonStyle::Filled)
8107 .on_click(cx.listener({
8108 move |this, _, window, cx| {
8109 let server_view = this.server_view.clone();
8110 let agent_name = this.agent_id.clone();
8111
8112 this.clear_thread_error(cx);
8113 if let Some(message) = this.in_flight_prompt.take() {
8114 this.message_editor.update(cx, |editor, cx| {
8115 editor.set_message(message, window, cx);
8116 });
8117 }
8118 let connection = this.thread.read(cx).connection().clone();
8119 window.defer(cx, |window, cx| {
8120 ConversationView::handle_auth_required(
8121 server_view,
8122 AuthRequired::new(),
8123 agent_name,
8124 connection,
8125 window,
8126 cx,
8127 );
8128 })
8129 }
8130 }))
8131 }
8132
8133 fn current_model_name(&self, cx: &App) -> SharedString {
8134 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
8135 // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
8136 // This provides better clarity about what refused the request
8137 if self.as_native_connection(cx).is_some() {
8138 self.model_selector
8139 .clone()
8140 .and_then(|selector| selector.read(cx).active_model(cx))
8141 .map(|model| model.name.clone())
8142 .unwrap_or_else(|| SharedString::from("The model"))
8143 } else {
8144 // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
8145 self.agent_id.0.clone()
8146 }
8147 }
8148
8149 fn render_any_thread_error(
8150 &mut self,
8151 error: SharedString,
8152 window: &mut Window,
8153 cx: &mut Context<'_, Self>,
8154 ) -> Callout {
8155 let can_resume = self.thread.read(cx).can_retry(cx);
8156
8157 let markdown = if let Some(markdown) = &self.thread_error_markdown {
8158 markdown.clone()
8159 } else {
8160 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
8161 self.thread_error_markdown = Some(markdown.clone());
8162 markdown
8163 };
8164
8165 let markdown_style =
8166 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx);
8167 let description = self
8168 .render_markdown(markdown, markdown_style)
8169 .into_any_element();
8170
8171 Callout::new()
8172 .severity(Severity::Error)
8173 .icon(IconName::XCircle)
8174 .title("An Error Happened")
8175 .description_slot(description)
8176 .actions_slot(
8177 h_flex()
8178 .gap_0p5()
8179 .when(can_resume, |this| {
8180 this.child(
8181 IconButton::new("retry", IconName::RotateCw)
8182 .icon_size(IconSize::Small)
8183 .tooltip(Tooltip::text("Retry Generation"))
8184 .on_click(cx.listener(|this, _, _window, cx| {
8185 this.retry_generation(cx);
8186 })),
8187 )
8188 })
8189 .child(self.create_copy_button(error.to_string())),
8190 )
8191 .dismiss_action(self.dismiss_error_button(cx))
8192 }
8193
8194 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
8195 let workspace = self.workspace.clone();
8196 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
8197 open_link(text, &workspace, window, cx);
8198 })
8199 }
8200
8201 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
8202 let message = message.into();
8203
8204 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
8205 }
8206
8207 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
8208 IconButton::new("dismiss", IconName::Close)
8209 .icon_size(IconSize::Small)
8210 .tooltip(Tooltip::text("Dismiss"))
8211 .on_click(cx.listener({
8212 move |this, _, _, cx| {
8213 this.clear_thread_error(cx);
8214 cx.notify();
8215 }
8216 }))
8217 }
8218
8219 fn render_resume_notice(_cx: &Context<Self>) -> AnyElement {
8220 let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off.";
8221
8222 div()
8223 .px_2()
8224 .pt_2()
8225 .pb_3()
8226 .w_full()
8227 .child(
8228 Callout::new()
8229 .severity(Severity::Info)
8230 .icon(IconName::Info)
8231 .title("Resumed Session")
8232 .description(description),
8233 )
8234 .into_any_element()
8235 }
8236
8237 fn update_recent_history_from_cache(
8238 &mut self,
8239 history: &Entity<ThreadHistory>,
8240 cx: &mut Context<Self>,
8241 ) {
8242 self.recent_history_entries = history.read(cx).get_recent_sessions(3);
8243 self.hovered_recent_history_item = None;
8244 cx.notify();
8245 }
8246
8247 fn render_empty_state_section_header(
8248 &self,
8249 label: impl Into<SharedString>,
8250 action_slot: Option<AnyElement>,
8251 cx: &mut Context<Self>,
8252 ) -> impl IntoElement {
8253 div().pl_1().pr_1p5().child(
8254 h_flex()
8255 .mt_2()
8256 .pl_1p5()
8257 .pb_1()
8258 .w_full()
8259 .justify_between()
8260 .border_b_1()
8261 .border_color(cx.theme().colors().border_variant)
8262 .child(
8263 Label::new(label.into())
8264 .size(LabelSize::Small)
8265 .color(Color::Muted),
8266 )
8267 .children(action_slot),
8268 )
8269 }
8270
8271 fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
8272 let render_history = !self.recent_history_entries.is_empty();
8273
8274 v_flex()
8275 .size_full()
8276 .when(render_history, |this| {
8277 let recent_history = self.recent_history_entries.clone();
8278 this.justify_end().child(
8279 v_flex()
8280 .child(
8281 self.render_empty_state_section_header(
8282 "Recent",
8283 Some(
8284 Button::new("view-history", "View All")
8285 .style(ButtonStyle::Subtle)
8286 .label_size(LabelSize::Small)
8287 .key_binding(
8288 KeyBinding::for_action_in(
8289 &OpenHistory,
8290 &self.focus_handle(cx),
8291 cx,
8292 )
8293 .map(|kb| kb.size(rems_from_px(12.))),
8294 )
8295 .on_click(move |_event, window, cx| {
8296 window.dispatch_action(OpenHistory.boxed_clone(), cx);
8297 })
8298 .into_any_element(),
8299 ),
8300 cx,
8301 ),
8302 )
8303 .child(v_flex().p_1().pr_1p5().gap_1().children({
8304 let supports_delete = self
8305 .history
8306 .as_ref()
8307 .map_or(false, |h| h.read(cx).supports_delete());
8308 recent_history
8309 .into_iter()
8310 .enumerate()
8311 .map(move |(index, entry)| {
8312 // TODO: Add keyboard navigation.
8313 let is_hovered =
8314 self.hovered_recent_history_item == Some(index);
8315 crate::thread_history_view::HistoryEntryElement::new(
8316 entry,
8317 self.server_view.clone(),
8318 )
8319 .hovered(is_hovered)
8320 .supports_delete(supports_delete)
8321 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
8322 if *is_hovered {
8323 this.hovered_recent_history_item = Some(index);
8324 } else if this.hovered_recent_history_item == Some(index) {
8325 this.hovered_recent_history_item = None;
8326 }
8327 cx.notify();
8328 }))
8329 .into_any_element()
8330 })
8331 })),
8332 )
8333 })
8334 .into_any()
8335 }
8336
8337 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
8338 Callout::new()
8339 .icon(IconName::Warning)
8340 .severity(Severity::Warning)
8341 .title("Codex on Windows")
8342 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
8343 .actions_slot(
8344 Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({
8345 move |_, _, _window, cx| {
8346 #[cfg(windows)]
8347 _window.dispatch_action(
8348 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
8349 cx,
8350 );
8351 cx.notify();
8352 }
8353 })),
8354 )
8355 .dismiss_action(
8356 IconButton::new("dismiss", IconName::Close)
8357 .icon_size(IconSize::Small)
8358 .icon_color(Color::Muted)
8359 .tooltip(Tooltip::text("Dismiss Warning"))
8360 .on_click(cx.listener({
8361 move |this, _, _, cx| {
8362 this.show_codex_windows_warning = false;
8363 cx.notify();
8364 }
8365 })),
8366 )
8367 }
8368
8369 fn render_external_source_prompt_warning(&self, cx: &mut Context<Self>) -> Callout {
8370 Callout::new()
8371 .icon(IconName::Warning)
8372 .severity(Severity::Warning)
8373 .title("Review before sending")
8374 .description("This prompt was pre-filled by an external link. Read it carefully before you send it.")
8375 .dismiss_action(
8376 IconButton::new("dismiss-external-source-prompt-warning", IconName::Close)
8377 .icon_size(IconSize::Small)
8378 .icon_color(Color::Muted)
8379 .tooltip(Tooltip::text("Dismiss Warning"))
8380 .on_click(cx.listener({
8381 move |this, _, _, cx| {
8382 this.show_external_source_prompt_warning = false;
8383 cx.notify();
8384 }
8385 })),
8386 )
8387 }
8388
8389 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
8390 let server_view = self.server_view.clone();
8391 let has_version = !version.is_empty();
8392 let title = if has_version {
8393 "New version available"
8394 } else {
8395 "Agent update available"
8396 };
8397 let button_label = if has_version {
8398 format!("Update to v{}", version)
8399 } else {
8400 "Reconnect".to_string()
8401 };
8402
8403 v_flex().w_full().justify_end().child(
8404 h_flex()
8405 .p_2()
8406 .pr_3()
8407 .w_full()
8408 .gap_1p5()
8409 .border_t_1()
8410 .border_color(cx.theme().colors().border)
8411 .bg(cx.theme().colors().element_background)
8412 .child(
8413 h_flex()
8414 .flex_1()
8415 .gap_1p5()
8416 .child(
8417 Icon::new(IconName::Download)
8418 .color(Color::Accent)
8419 .size(IconSize::Small),
8420 )
8421 .child(Label::new(title).size(LabelSize::Small)),
8422 )
8423 .child(
8424 Button::new("update-button", button_label)
8425 .label_size(LabelSize::Small)
8426 .style(ButtonStyle::Tinted(TintColor::Accent))
8427 .on_click(move |_, window, cx| {
8428 server_view
8429 .update(cx, |view, cx| view.reset(window, cx))
8430 .ok();
8431 }),
8432 ),
8433 )
8434 }
8435
8436 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
8437 if self.token_limit_callout_dismissed {
8438 return None;
8439 }
8440
8441 let token_usage = self.thread.read(cx).token_usage()?;
8442 let ratio = token_usage.ratio();
8443
8444 let (severity, icon, title) = match ratio {
8445 acp_thread::TokenUsageRatio::Normal => return None,
8446 acp_thread::TokenUsageRatio::Warning => (
8447 Severity::Warning,
8448 IconName::Warning,
8449 "Thread reaching the token limit soon",
8450 ),
8451 acp_thread::TokenUsageRatio::Exceeded => (
8452 Severity::Error,
8453 IconName::XCircle,
8454 "Thread reached the token limit",
8455 ),
8456 };
8457
8458 let description = "To continue, start a new thread from a summary.";
8459
8460 Some(
8461 Callout::new()
8462 .severity(severity)
8463 .icon(icon)
8464 .title(title)
8465 .description(description)
8466 .actions_slot(
8467 h_flex().gap_0p5().child(
8468 Button::new("start-new-thread", "Start New Thread")
8469 .label_size(LabelSize::Small)
8470 .on_click(cx.listener(|this, _, window, cx| {
8471 let session_id = this.thread.read(cx).session_id().clone();
8472 window.dispatch_action(
8473 crate::NewNativeAgentThreadFromSummary {
8474 from_session_id: session_id,
8475 }
8476 .boxed_clone(),
8477 cx,
8478 );
8479 })),
8480 ),
8481 )
8482 .dismiss_action(self.dismiss_error_button(cx)),
8483 )
8484 }
8485
8486 fn open_permission_dropdown(
8487 &mut self,
8488 _: &crate::OpenPermissionDropdown,
8489 window: &mut Window,
8490 cx: &mut Context<Self>,
8491 ) {
8492 let menu_handle = self.permission_dropdown_handle.clone();
8493 window.defer(cx, move |window, cx| {
8494 menu_handle.toggle(window, cx);
8495 });
8496 }
8497
8498 fn open_add_context_menu(
8499 &mut self,
8500 _action: &OpenAddContextMenu,
8501 window: &mut Window,
8502 cx: &mut Context<Self>,
8503 ) {
8504 let menu_handle = self.add_context_menu_handle.clone();
8505 window.defer(cx, move |window, cx| {
8506 menu_handle.toggle(window, cx);
8507 });
8508 }
8509
8510 fn toggle_fast_mode(&mut self, cx: &mut Context<Self>) {
8511 if !self.fast_mode_available(cx) {
8512 return;
8513 }
8514 let Some(thread) = self.as_native_thread(cx) else {
8515 return;
8516 };
8517 thread.update(cx, |thread, cx| {
8518 thread.set_speed(
8519 thread
8520 .speed()
8521 .map(|speed| speed.toggle())
8522 .unwrap_or(Speed::Fast),
8523 cx,
8524 );
8525 });
8526 }
8527
8528 fn cycle_thinking_effort(&mut self, cx: &mut Context<Self>) {
8529 let Some(thread) = self.as_native_thread(cx) else {
8530 return;
8531 };
8532
8533 let (effort_levels, current_effort) = {
8534 let thread_ref = thread.read(cx);
8535 let Some(model) = thread_ref.model() else {
8536 return;
8537 };
8538 if !model.supports_thinking() || !thread_ref.thinking_enabled() {
8539 return;
8540 }
8541 let effort_levels = model.supported_effort_levels();
8542 if effort_levels.is_empty() {
8543 return;
8544 }
8545 let current_effort = thread_ref.thinking_effort().cloned();
8546 (effort_levels, current_effort)
8547 };
8548
8549 let current_index = current_effort.and_then(|current| {
8550 effort_levels
8551 .iter()
8552 .position(|level| level.value == current)
8553 });
8554 let next_index = match current_index {
8555 Some(index) => (index + 1) % effort_levels.len(),
8556 None => 0,
8557 };
8558 let next_effort = effort_levels[next_index].value.to_string();
8559
8560 thread.update(cx, |thread, cx| {
8561 thread.set_thinking_effort(Some(next_effort.clone()), cx);
8562
8563 let fs = thread.project().read(cx).fs().clone();
8564 update_settings_file(fs, cx, move |settings, _| {
8565 if let Some(agent) = settings.agent.as_mut()
8566 && let Some(default_model) = agent.default_model.as_mut()
8567 {
8568 default_model.effort = Some(next_effort);
8569 }
8570 });
8571 });
8572 }
8573
8574 fn toggle_thinking_effort_menu(
8575 &mut self,
8576 _action: &ToggleThinkingEffortMenu,
8577 window: &mut Window,
8578 cx: &mut Context<Self>,
8579 ) {
8580 let menu_handle = self.thinking_effort_menu_handle.clone();
8581 window.defer(cx, move |window, cx| {
8582 menu_handle.toggle(window, cx);
8583 });
8584 }
8585}
8586
8587impl Render for ThreadView {
8588 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
8589 let has_messages = self.list_state.item_count() > 0;
8590 let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
8591
8592 let conversation = v_flex()
8593 .when(!v2_empty_state, |this| this.flex_1())
8594 .map(|this| {
8595 let this = this.when(self.resumed_without_history, |this| {
8596 this.child(Self::render_resume_notice(cx))
8597 });
8598 if has_messages {
8599 let list_state = self.list_state.clone();
8600 this.child(self.render_entries(cx))
8601 .vertical_scrollbar_for(&list_state, window, cx)
8602 .into_any()
8603 } else if v2_empty_state {
8604 this.into_any()
8605 } else {
8606 this.child(self.render_recent_history(cx)).into_any()
8607 }
8608 });
8609
8610 v_flex()
8611 .key_context("AcpThread")
8612 .track_focus(&self.focus_handle)
8613 .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
8614 if this.parent_id.is_none() {
8615 this.cancel_generation(cx);
8616 }
8617 }))
8618 .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
8619 if let Some(parent_session_id) = this.parent_id.clone() {
8620 this.server_view
8621 .update(cx, |view, cx| {
8622 view.navigate_to_session(parent_session_id, window, cx);
8623 })
8624 .ok();
8625 }
8626 }))
8627 .on_action(cx.listener(Self::keep_all))
8628 .on_action(cx.listener(Self::reject_all))
8629 .on_action(cx.listener(Self::undo_last_reject))
8630 .on_action(cx.listener(Self::allow_always))
8631 .on_action(cx.listener(Self::allow_once))
8632 .on_action(cx.listener(Self::reject_once))
8633 .on_action(cx.listener(Self::handle_authorize_tool_call))
8634 .on_action(cx.listener(Self::handle_select_permission_granularity))
8635 .on_action(cx.listener(Self::handle_toggle_command_pattern))
8636 .on_action(cx.listener(Self::open_permission_dropdown))
8637 .on_action(cx.listener(Self::open_add_context_menu))
8638 .on_action(cx.listener(Self::scroll_output_page_up))
8639 .on_action(cx.listener(Self::scroll_output_page_down))
8640 .on_action(cx.listener(Self::scroll_output_line_up))
8641 .on_action(cx.listener(Self::scroll_output_line_down))
8642 .on_action(cx.listener(Self::scroll_output_to_top))
8643 .on_action(cx.listener(Self::scroll_output_to_bottom))
8644 .on_action(cx.listener(Self::scroll_output_to_previous_message))
8645 .on_action(cx.listener(Self::scroll_output_to_next_message))
8646 .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {
8647 this.toggle_fast_mode(cx);
8648 }))
8649 .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
8650 if this.thread.read(cx).status() != ThreadStatus::Idle {
8651 return;
8652 }
8653 if let Some(thread) = this.as_native_thread(cx) {
8654 thread.update(cx, |thread, cx| {
8655 thread.set_thinking_enabled(!thread.thinking_enabled(), cx);
8656 });
8657 }
8658 }))
8659 .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
8660 if this.thread.read(cx).status() != ThreadStatus::Idle {
8661 return;
8662 }
8663 this.cycle_thinking_effort(cx);
8664 }))
8665 .on_action(
8666 cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| {
8667 if this.thread.read(cx).status() != ThreadStatus::Idle {
8668 return;
8669 }
8670 this.toggle_thinking_effort_menu(action, window, cx);
8671 }),
8672 )
8673 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
8674 this.send_queued_message_at_index(0, true, window, cx);
8675 }))
8676 .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
8677 this.remove_from_queue(0, cx);
8678 cx.notify();
8679 }))
8680 .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
8681 this.move_queued_message_to_main_editor(0, None, None, window, cx);
8682 }))
8683 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
8684 this.local_queued_messages.clear();
8685 this.sync_queue_flag_to_native_thread(cx);
8686 this.can_fast_track_queue = false;
8687 cx.notify();
8688 }))
8689 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
8690 if this.thread.read(cx).status() != ThreadStatus::Idle {
8691 return;
8692 }
8693 if let Some(config_options_view) = this.config_options_view.clone() {
8694 let handled = config_options_view.update(cx, |view, cx| {
8695 view.toggle_category_picker(
8696 acp::SessionConfigOptionCategory::Mode,
8697 window,
8698 cx,
8699 )
8700 });
8701 if handled {
8702 return;
8703 }
8704 }
8705
8706 if let Some(profile_selector) = this.profile_selector.clone() {
8707 profile_selector.read(cx).menu_handle().toggle(window, cx);
8708 } else if let Some(mode_selector) = this.mode_selector.clone() {
8709 mode_selector.read(cx).menu_handle().toggle(window, cx);
8710 }
8711 }))
8712 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
8713 if this.thread.read(cx).status() != ThreadStatus::Idle {
8714 return;
8715 }
8716 if let Some(config_options_view) = this.config_options_view.clone() {
8717 let handled = config_options_view.update(cx, |view, cx| {
8718 view.cycle_category_option(
8719 acp::SessionConfigOptionCategory::Mode,
8720 false,
8721 cx,
8722 )
8723 });
8724 if handled {
8725 return;
8726 }
8727 }
8728
8729 if let Some(profile_selector) = this.profile_selector.clone() {
8730 profile_selector.update(cx, |profile_selector, cx| {
8731 profile_selector.cycle_profile(cx);
8732 });
8733 } else if let Some(mode_selector) = this.mode_selector.clone() {
8734 mode_selector.update(cx, |mode_selector, cx| {
8735 mode_selector.cycle_mode(window, cx);
8736 });
8737 }
8738 }))
8739 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
8740 if this.thread.read(cx).status() != ThreadStatus::Idle {
8741 return;
8742 }
8743 if let Some(config_options_view) = this.config_options_view.clone() {
8744 let handled = config_options_view.update(cx, |view, cx| {
8745 view.toggle_category_picker(
8746 acp::SessionConfigOptionCategory::Model,
8747 window,
8748 cx,
8749 )
8750 });
8751 if handled {
8752 return;
8753 }
8754 }
8755
8756 if let Some(model_selector) = this.model_selector.clone() {
8757 model_selector
8758 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
8759 }
8760 }))
8761 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
8762 if this.thread.read(cx).status() != ThreadStatus::Idle {
8763 return;
8764 }
8765 if let Some(config_options_view) = this.config_options_view.clone() {
8766 let handled = config_options_view.update(cx, |view, cx| {
8767 view.cycle_category_option(
8768 acp::SessionConfigOptionCategory::Model,
8769 true,
8770 cx,
8771 )
8772 });
8773 if handled {
8774 return;
8775 }
8776 }
8777
8778 if let Some(model_selector) = this.model_selector.clone() {
8779 model_selector.update(cx, |model_selector, cx| {
8780 model_selector.cycle_favorite_models(window, cx);
8781 });
8782 }
8783 }))
8784 .size_full()
8785 .children(self.render_subagent_titlebar(cx))
8786 .child(conversation)
8787 .children(self.render_activity_bar(window, cx))
8788 .when(self.show_external_source_prompt_warning, |this| {
8789 this.child(self.render_external_source_prompt_warning(cx))
8790 })
8791 .when(self.show_codex_windows_warning, |this| {
8792 this.child(self.render_codex_windows_warning(cx))
8793 })
8794 .children(self.render_thread_retry_status_callout())
8795 .children(self.render_thread_error(window, cx))
8796 .when_some(
8797 match has_messages {
8798 true => None,
8799 false => self.new_server_version_available.clone(),
8800 },
8801 |this, version| this.child(self.render_new_version_callout(&version, cx)),
8802 )
8803 .children(self.render_token_limit_callout(cx))
8804 .child(self.render_message_editor(window, cx))
8805 }
8806}
8807
8808pub(crate) fn open_link(
8809 url: SharedString,
8810 workspace: &WeakEntity<Workspace>,
8811 window: &mut Window,
8812 cx: &mut App,
8813) {
8814 let Some(workspace) = workspace.upgrade() else {
8815 cx.open_url(&url);
8816 return;
8817 };
8818
8819 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() {
8820 workspace.update(cx, |workspace, cx| match mention {
8821 MentionUri::File { abs_path } => {
8822 let project = workspace.project();
8823 let Some(path) =
8824 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
8825 else {
8826 return;
8827 };
8828
8829 workspace
8830 .open_path(path, None, true, window, cx)
8831 .detach_and_log_err(cx);
8832 }
8833 MentionUri::PastedImage => {}
8834 MentionUri::Directory { abs_path } => {
8835 let project = workspace.project();
8836 let Some(entry_id) = project.update(cx, |project, cx| {
8837 let path = project.find_project_path(abs_path, cx)?;
8838 project.entry_for_path(&path, cx).map(|entry| entry.id)
8839 }) else {
8840 return;
8841 };
8842
8843 project.update(cx, |_, cx| {
8844 cx.emit(project::Event::RevealInProjectPanel(entry_id));
8845 });
8846 }
8847 MentionUri::Symbol {
8848 abs_path: path,
8849 line_range,
8850 ..
8851 }
8852 | MentionUri::Selection {
8853 abs_path: Some(path),
8854 line_range,
8855 } => {
8856 let project = workspace.project();
8857 let Some(path) =
8858 project.update(cx, |project, cx| project.find_project_path(path, cx))
8859 else {
8860 return;
8861 };
8862
8863 let item = workspace.open_path(path, None, true, window, cx);
8864 window
8865 .spawn(cx, async move |cx| {
8866 let Some(editor) = item.await?.downcast::<Editor>() else {
8867 return Ok(());
8868 };
8869 let range =
8870 Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
8871 editor
8872 .update_in(cx, |editor, window, cx| {
8873 editor.change_selections(
8874 SelectionEffects::scroll(Autoscroll::center()),
8875 window,
8876 cx,
8877 |s| s.select_ranges(vec![range]),
8878 );
8879 })
8880 .ok();
8881 anyhow::Ok(())
8882 })
8883 .detach_and_log_err(cx);
8884 }
8885 MentionUri::Selection { abs_path: None, .. } => {}
8886 MentionUri::Thread { id, name } => {
8887 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
8888 panel.update(cx, |panel, cx| {
8889 panel.open_thread(id, None, Some(name.into()), window, cx)
8890 });
8891 }
8892 }
8893 MentionUri::Rule { id, .. } => {
8894 let PromptId::User { uuid } = id else {
8895 return;
8896 };
8897 window.dispatch_action(
8898 Box::new(OpenRulesLibrary {
8899 prompt_to_select: Some(uuid.0),
8900 }),
8901 cx,
8902 )
8903 }
8904 MentionUri::Fetch { url } => {
8905 cx.open_url(url.as_str());
8906 }
8907 MentionUri::Diagnostics { .. } => {}
8908 MentionUri::TerminalSelection { .. } => {}
8909 MentionUri::GitDiff { .. } => {}
8910 MentionUri::MergeConflict { .. } => {}
8911 })
8912 } else {
8913 cx.open_url(&url);
8914 }
8915}