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