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