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