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