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