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