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