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