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