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