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