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