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