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