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 let thread = self.as_native_thread(cx)?.read(cx);
2865 let model = thread.model()?;
2866
2867 let supports_thinking = model.supports_thinking();
2868 if !supports_thinking {
2869 return None;
2870 }
2871
2872 let thinking = thread.thinking_enabled();
2873
2874 let (tooltip_label, icon, color) = if thinking {
2875 (
2876 "Disable Thinking Mode",
2877 IconName::ThinkingMode,
2878 Color::Muted,
2879 )
2880 } else {
2881 (
2882 "Enable Thinking Mode",
2883 IconName::ThinkingModeOff,
2884 Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
2885 )
2886 };
2887
2888 let focus_handle = self.message_editor.focus_handle(cx);
2889
2890 let thinking_toggle = IconButton::new("thinking-mode", icon)
2891 .icon_size(IconSize::Small)
2892 .icon_color(color)
2893 .tooltip(move |_, cx| {
2894 Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
2895 })
2896 .on_click(cx.listener(move |this, _, _window, cx| {
2897 if let Some(thread) = this.as_native_thread(cx) {
2898 thread.update(cx, |thread, cx| {
2899 let enable_thinking = !thread.thinking_enabled();
2900 thread.set_thinking_enabled(enable_thinking, cx);
2901
2902 let fs = thread.project().read(cx).fs().clone();
2903 update_settings_file(fs, cx, move |settings, _| {
2904 if let Some(agent) = settings.agent.as_mut()
2905 && let Some(default_model) = agent.default_model.as_mut()
2906 {
2907 default_model.enable_thinking = enable_thinking;
2908 }
2909 });
2910 });
2911 }
2912 }));
2913
2914 if model.supported_effort_levels().is_empty() {
2915 return Some(thinking_toggle.into_any_element());
2916 }
2917
2918 if !model.supported_effort_levels().is_empty() && !thinking {
2919 return Some(thinking_toggle.into_any_element());
2920 }
2921
2922 let left_btn = thinking_toggle;
2923 let right_btn = self.render_effort_selector(
2924 model.supported_effort_levels(),
2925 thread.thinking_effort().cloned(),
2926 cx,
2927 );
2928
2929 Some(
2930 SplitButton::new(left_btn, right_btn.into_any_element())
2931 .style(SplitButtonStyle::Transparent)
2932 .into_any_element(),
2933 )
2934 }
2935
2936 fn render_effort_selector(
2937 &self,
2938 supported_effort_levels: Vec<LanguageModelEffortLevel>,
2939 selected_effort: Option<String>,
2940 cx: &Context<Self>,
2941 ) -> impl IntoElement {
2942 let weak_self = cx.weak_entity();
2943
2944 let default_effort_level = supported_effort_levels
2945 .iter()
2946 .find(|effort_level| effort_level.is_default)
2947 .cloned();
2948
2949 let selected = selected_effort.and_then(|effort| {
2950 supported_effort_levels
2951 .iter()
2952 .find(|level| level.value == effort)
2953 .cloned()
2954 });
2955
2956 let label = selected
2957 .clone()
2958 .or(default_effort_level)
2959 .map_or("Select Effort".into(), |effort| effort.name);
2960
2961 let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() {
2962 (Color::Accent, IconName::ChevronUp)
2963 } else {
2964 (Color::Muted, IconName::ChevronDown)
2965 };
2966
2967 let focus_handle = self.message_editor.focus_handle(cx);
2968 let show_cycle_row = supported_effort_levels.len() > 1;
2969
2970 let tooltip = Tooltip::element({
2971 move |_, cx| {
2972 let mut content = v_flex().gap_1().child(
2973 h_flex()
2974 .gap_2()
2975 .justify_between()
2976 .child(Label::new("Change Thinking Effort"))
2977 .child(KeyBinding::for_action_in(
2978 &ToggleThinkingEffortMenu,
2979 &focus_handle,
2980 cx,
2981 )),
2982 );
2983
2984 if show_cycle_row {
2985 content = content.child(
2986 h_flex()
2987 .pt_1()
2988 .gap_2()
2989 .justify_between()
2990 .border_t_1()
2991 .border_color(cx.theme().colors().border_variant)
2992 .child(Label::new("Cycle Thinking Effort"))
2993 .child(KeyBinding::for_action_in(
2994 &CycleThinkingEffort,
2995 &focus_handle,
2996 cx,
2997 )),
2998 );
2999 }
3000
3001 content.into_any_element()
3002 }
3003 });
3004
3005 PopoverMenu::new("effort-selector")
3006 .trigger_with_tooltip(
3007 ButtonLike::new_rounded_right("effort-selector-trigger")
3008 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
3009 .child(Label::new(label).size(LabelSize::Small).color(label_color))
3010 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
3011 tooltip,
3012 )
3013 .menu(move |window, cx| {
3014 Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
3015 menu = menu.header("Change Thinking Effort");
3016
3017 for effort_level in supported_effort_levels.clone() {
3018 let is_selected = selected
3019 .as_ref()
3020 .is_some_and(|selected| selected.value == effort_level.value);
3021 let entry = ContextMenuEntry::new(effort_level.name)
3022 .toggleable(IconPosition::End, is_selected);
3023
3024 menu.push_item(entry.handler({
3025 let effort = effort_level.value.clone();
3026 let weak_self = weak_self.clone();
3027 move |_window, cx| {
3028 let effort = effort.clone();
3029 weak_self
3030 .update(cx, |this, cx| {
3031 if let Some(thread) = this.as_native_thread(cx) {
3032 thread.update(cx, |thread, cx| {
3033 thread.set_thinking_effort(
3034 Some(effort.to_string()),
3035 cx,
3036 );
3037
3038 let fs = thread.project().read(cx).fs().clone();
3039 update_settings_file(fs, cx, move |settings, _| {
3040 if let Some(agent) = settings.agent.as_mut()
3041 && let Some(default_model) =
3042 agent.default_model.as_mut()
3043 {
3044 default_model.effort =
3045 Some(effort.to_string());
3046 }
3047 });
3048 });
3049 }
3050 })
3051 .ok();
3052 }
3053 }));
3054 }
3055
3056 menu
3057 }))
3058 })
3059 .with_handle(self.thinking_effort_menu_handle.clone())
3060 .offset(gpui::Point {
3061 x: px(0.0),
3062 y: px(-2.0),
3063 })
3064 .anchor(Corner::BottomLeft)
3065 }
3066
3067 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3068 let message_editor = self.message_editor.read(cx);
3069 let is_editor_empty = message_editor.is_empty(cx);
3070 let focus_handle = message_editor.focus_handle(cx);
3071
3072 let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle;
3073
3074 if self.is_loading_contents {
3075 div()
3076 .id("loading-message-content")
3077 .px_1()
3078 .tooltip(Tooltip::text("Loading Added Context…"))
3079 .child(loading_contents_spinner(IconSize::default()))
3080 .into_any_element()
3081 } else if is_generating && is_editor_empty {
3082 IconButton::new("stop-generation", IconName::Stop)
3083 .icon_color(Color::Error)
3084 .style(ButtonStyle::Tinted(TintColor::Error))
3085 .tooltip(move |_window, cx| {
3086 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx)
3087 })
3088 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3089 .into_any_element()
3090 } else {
3091 IconButton::new("send-message", IconName::Send)
3092 .style(ButtonStyle::Filled)
3093 .map(|this| {
3094 if is_editor_empty && !is_generating {
3095 this.disabled(true).icon_color(Color::Muted)
3096 } else {
3097 this.icon_color(Color::Accent)
3098 }
3099 })
3100 .tooltip(move |_window, cx| {
3101 if is_editor_empty && !is_generating {
3102 Tooltip::for_action("Type to Send", &Chat, cx)
3103 } else if is_generating {
3104 let focus_handle = focus_handle.clone();
3105
3106 Tooltip::element(move |_window, cx| {
3107 v_flex()
3108 .gap_1()
3109 .child(
3110 h_flex()
3111 .gap_2()
3112 .justify_between()
3113 .child(Label::new("Queue and Send"))
3114 .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)),
3115 )
3116 .child(
3117 h_flex()
3118 .pt_1()
3119 .gap_2()
3120 .justify_between()
3121 .border_t_1()
3122 .border_color(cx.theme().colors().border_variant)
3123 .child(Label::new("Send Immediately"))
3124 .child(KeyBinding::for_action_in(
3125 &SendImmediately,
3126 &focus_handle,
3127 cx,
3128 )),
3129 )
3130 .into_any_element()
3131 })(_window, cx)
3132 } else {
3133 Tooltip::for_action("Send Message", &Chat, cx)
3134 }
3135 })
3136 .on_click(cx.listener(|this, _, window, cx| {
3137 this.send(window, cx);
3138 }))
3139 .into_any_element()
3140 }
3141 }
3142
3143 fn render_add_context_button(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3144 let focus_handle = self.message_editor.focus_handle(cx);
3145 let weak_self = cx.weak_entity();
3146
3147 PopoverMenu::new("add-context-menu")
3148 .trigger_with_tooltip(
3149 IconButton::new("add-context", IconName::Plus)
3150 .icon_size(IconSize::Small)
3151 .icon_color(Color::Muted),
3152 {
3153 move |_window, cx| {
3154 Tooltip::for_action_in(
3155 "Add Context",
3156 &OpenAddContextMenu,
3157 &focus_handle,
3158 cx,
3159 )
3160 }
3161 },
3162 )
3163 .anchor(Corner::BottomLeft)
3164 .with_handle(self.add_context_menu_handle.clone())
3165 .offset(gpui::Point {
3166 x: px(0.0),
3167 y: px(-2.0),
3168 })
3169 .menu(move |window, cx| {
3170 weak_self
3171 .update(cx, |this, cx| this.build_add_context_menu(window, cx))
3172 .ok()
3173 })
3174 }
3175
3176 fn build_add_context_menu(
3177 &self,
3178 window: &mut Window,
3179 cx: &mut Context<Self>,
3180 ) -> Entity<ContextMenu> {
3181 let message_editor = self.message_editor.clone();
3182 let workspace = self.workspace.clone();
3183 let supports_images = self.prompt_capabilities.borrow().image;
3184
3185 let has_editor_selection = workspace
3186 .upgrade()
3187 .and_then(|ws| {
3188 ws.read(cx)
3189 .active_item(cx)
3190 .and_then(|item| item.downcast::<Editor>())
3191 })
3192 .is_some_and(|editor| {
3193 editor.update(cx, |editor, cx| {
3194 editor.has_non_empty_selection(&editor.display_snapshot(cx))
3195 })
3196 });
3197
3198 let has_terminal_selection = workspace
3199 .upgrade()
3200 .and_then(|ws| ws.read(cx).panel::<TerminalPanel>(cx))
3201 .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty());
3202
3203 let has_selection = has_editor_selection || has_terminal_selection;
3204
3205 ContextMenu::build(window, cx, move |menu, _window, _cx| {
3206 menu.key_context("AddContextMenu")
3207 .header("Context")
3208 .item(
3209 ContextMenuEntry::new("Files & Directories")
3210 .icon(IconName::File)
3211 .icon_color(Color::Muted)
3212 .icon_size(IconSize::XSmall)
3213 .handler({
3214 let message_editor = message_editor.clone();
3215 move |window, cx| {
3216 message_editor.focus_handle(cx).focus(window, cx);
3217 message_editor.update(cx, |editor, cx| {
3218 editor.insert_context_type("file", window, cx);
3219 });
3220 }
3221 }),
3222 )
3223 .item(
3224 ContextMenuEntry::new("Symbols")
3225 .icon(IconName::Code)
3226 .icon_color(Color::Muted)
3227 .icon_size(IconSize::XSmall)
3228 .handler({
3229 let message_editor = message_editor.clone();
3230 move |window, cx| {
3231 message_editor.focus_handle(cx).focus(window, cx);
3232 message_editor.update(cx, |editor, cx| {
3233 editor.insert_context_type("symbol", window, cx);
3234 });
3235 }
3236 }),
3237 )
3238 .item(
3239 ContextMenuEntry::new("Threads")
3240 .icon(IconName::Thread)
3241 .icon_color(Color::Muted)
3242 .icon_size(IconSize::XSmall)
3243 .handler({
3244 let message_editor = message_editor.clone();
3245 move |window, cx| {
3246 message_editor.focus_handle(cx).focus(window, cx);
3247 message_editor.update(cx, |editor, cx| {
3248 editor.insert_context_type("thread", window, cx);
3249 });
3250 }
3251 }),
3252 )
3253 .item(
3254 ContextMenuEntry::new("Rules")
3255 .icon(IconName::Reader)
3256 .icon_color(Color::Muted)
3257 .icon_size(IconSize::XSmall)
3258 .handler({
3259 let message_editor = message_editor.clone();
3260 move |window, cx| {
3261 message_editor.focus_handle(cx).focus(window, cx);
3262 message_editor.update(cx, |editor, cx| {
3263 editor.insert_context_type("rule", window, cx);
3264 });
3265 }
3266 }),
3267 )
3268 .item(
3269 ContextMenuEntry::new("Image")
3270 .icon(IconName::Image)
3271 .icon_color(Color::Muted)
3272 .icon_size(IconSize::XSmall)
3273 .disabled(!supports_images)
3274 .handler({
3275 let message_editor = message_editor.clone();
3276 move |window, cx| {
3277 message_editor.focus_handle(cx).focus(window, cx);
3278 message_editor.update(cx, |editor, cx| {
3279 editor.add_images_from_picker(window, cx);
3280 });
3281 }
3282 }),
3283 )
3284 .item(
3285 ContextMenuEntry::new("Selection")
3286 .icon(IconName::CursorIBeam)
3287 .icon_color(Color::Muted)
3288 .icon_size(IconSize::XSmall)
3289 .disabled(!has_selection)
3290 .handler({
3291 move |window, cx| {
3292 window.dispatch_action(
3293 zed_actions::agent::AddSelectionToThread.boxed_clone(),
3294 cx,
3295 );
3296 }
3297 }),
3298 )
3299 })
3300 }
3301
3302 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
3303 let following = self.is_following(cx);
3304
3305 let tooltip_label = if following {
3306 if self.agent_name == "Zed Agent" {
3307 format!("Stop Following the {}", self.agent_name)
3308 } else {
3309 format!("Stop Following {}", self.agent_name)
3310 }
3311 } else {
3312 if self.agent_name == "Zed Agent" {
3313 format!("Follow the {}", self.agent_name)
3314 } else {
3315 format!("Follow {}", self.agent_name)
3316 }
3317 };
3318
3319 IconButton::new("follow-agent", IconName::Crosshair)
3320 .icon_size(IconSize::Small)
3321 .icon_color(Color::Muted)
3322 .toggle_state(following)
3323 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
3324 .tooltip(move |_window, cx| {
3325 if following {
3326 Tooltip::for_action(tooltip_label.clone(), &Follow, cx)
3327 } else {
3328 Tooltip::with_meta(
3329 tooltip_label.clone(),
3330 Some(&Follow),
3331 "Track the agent's location as it reads and edits files.",
3332 cx,
3333 )
3334 }
3335 })
3336 .on_click(cx.listener(move |this, _, window, cx| {
3337 this.toggle_following(window, cx);
3338 }))
3339 }
3340}
3341
3342impl AcpThreadView {
3343 pub(crate) fn render_entries(&mut self, cx: &mut Context<Self>) -> List {
3344 list(
3345 self.list_state.clone(),
3346 cx.processor(|this, index: usize, window, cx| {
3347 let entries = this.thread.read(cx).entries();
3348 let Some(entry) = entries.get(index) else {
3349 return Empty.into_any();
3350 };
3351 this.render_entry(index, entries.len(), entry, window, cx)
3352 }),
3353 )
3354 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
3355 .flex_grow()
3356 }
3357
3358 fn render_entry(
3359 &self,
3360 entry_ix: usize,
3361 total_entries: usize,
3362 entry: &AgentThreadEntry,
3363 window: &mut Window,
3364 cx: &Context<Self>,
3365 ) -> AnyElement {
3366 let is_indented = entry.is_indented();
3367 let is_first_indented = is_indented
3368 && self
3369 .thread
3370 .read(cx)
3371 .entries()
3372 .get(entry_ix.saturating_sub(1))
3373 .is_none_or(|entry| !entry.is_indented());
3374
3375 let primary = match &entry {
3376 AgentThreadEntry::UserMessage(message) => {
3377 let Some(editor) = self
3378 .entry_view_state
3379 .read(cx)
3380 .entry(entry_ix)
3381 .and_then(|entry| entry.message_editor())
3382 .cloned()
3383 else {
3384 return Empty.into_any_element();
3385 };
3386
3387 let editing = self.editing_message == Some(entry_ix);
3388 let editor_focus = editor.focus_handle(cx).is_focused(window);
3389 let focus_border = cx.theme().colors().border_focused;
3390
3391 let rules_item = if entry_ix == 0 {
3392 self.render_rules_item(cx)
3393 } else {
3394 None
3395 };
3396
3397 let has_checkpoint_button = message
3398 .checkpoint
3399 .as_ref()
3400 .is_some_and(|checkpoint| checkpoint.show);
3401
3402 let agent_name = self.agent_name.clone();
3403 let is_subagent = self.is_subagent();
3404
3405 let non_editable_icon = || {
3406 IconButton::new("non_editable", IconName::PencilUnavailable)
3407 .icon_size(IconSize::Small)
3408 .icon_color(Color::Muted)
3409 .style(ButtonStyle::Transparent)
3410 };
3411
3412 v_flex()
3413 .id(("user_message", entry_ix))
3414 .map(|this| {
3415 if is_first_indented {
3416 this.pt_0p5()
3417 } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
3418 this.pt(rems_from_px(18.))
3419 } else if rules_item.is_some() {
3420 this.pt_3()
3421 } else {
3422 this.pt_2()
3423 }
3424 })
3425 .pb_3()
3426 .px_2()
3427 .gap_1p5()
3428 .w_full()
3429 .children(rules_item)
3430 .children(message.id.clone().and_then(|message_id| {
3431 message.checkpoint.as_ref()?.show.then(|| {
3432 h_flex()
3433 .px_3()
3434 .gap_2()
3435 .child(Divider::horizontal())
3436 .child(
3437 Button::new("restore-checkpoint", "Restore Checkpoint")
3438 .icon(IconName::Undo)
3439 .icon_size(IconSize::XSmall)
3440 .icon_position(IconPosition::Start)
3441 .label_size(LabelSize::XSmall)
3442 .icon_color(Color::Muted)
3443 .color(Color::Muted)
3444 .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
3445 .on_click(cx.listener(move |this, _, _window, cx| {
3446 this.restore_checkpoint(&message_id, cx);
3447 }))
3448 )
3449 .child(Divider::horizontal())
3450 })
3451 }))
3452 .child(
3453 div()
3454 .relative()
3455 .child(
3456 div()
3457 .py_3()
3458 .px_2()
3459 .rounded_md()
3460 .bg(cx.theme().colors().editor_background)
3461 .border_1()
3462 .when(is_indented, |this| {
3463 this.py_2().px_2().shadow_sm()
3464 })
3465 .border_color(cx.theme().colors().border)
3466 .map(|this| {
3467 if is_subagent {
3468 return this.border_dashed();
3469 }
3470 if editing && editor_focus {
3471 return this.border_color(focus_border);
3472 }
3473 if editing && !editor_focus {
3474 return this.border_dashed()
3475 }
3476 if message.id.is_some() {
3477 return this.shadow_md().hover(|s| {
3478 s.border_color(focus_border.opacity(0.8))
3479 });
3480 }
3481 this
3482 })
3483 .text_xs()
3484 .child(editor.clone().into_any_element())
3485 )
3486 .when(editor_focus, |this| {
3487 let base_container = h_flex()
3488 .absolute()
3489 .top_neg_3p5()
3490 .right_3()
3491 .gap_1()
3492 .rounded_sm()
3493 .border_1()
3494 .border_color(cx.theme().colors().border)
3495 .bg(cx.theme().colors().editor_background)
3496 .overflow_hidden();
3497
3498 let is_loading_contents = self.is_loading_contents;
3499 if is_subagent {
3500 this.child(
3501 base_container.border_dashed().child(
3502 non_editable_icon().tooltip(move |_, cx| {
3503 Tooltip::with_meta(
3504 "Unavailable Editing",
3505 None,
3506 "Editing subagent messages is currently not supported.",
3507 cx,
3508 )
3509 }),
3510 ),
3511 )
3512 } else if message.id.is_some() {
3513 this.child(
3514 base_container
3515 .child(
3516 IconButton::new("cancel", IconName::Close)
3517 .disabled(is_loading_contents)
3518 .icon_color(Color::Error)
3519 .icon_size(IconSize::XSmall)
3520 .on_click(cx.listener(Self::cancel_editing))
3521 )
3522 .child(
3523 if is_loading_contents {
3524 div()
3525 .id("loading-edited-message-content")
3526 .tooltip(Tooltip::text("Loading Added Context…"))
3527 .child(loading_contents_spinner(IconSize::XSmall))
3528 .into_any_element()
3529 } else {
3530 IconButton::new("regenerate", IconName::Return)
3531 .icon_color(Color::Muted)
3532 .icon_size(IconSize::XSmall)
3533 .tooltip(Tooltip::text(
3534 "Editing will restart the thread from this point."
3535 ))
3536 .on_click(cx.listener({
3537 let editor = editor.clone();
3538 move |this, _, window, cx| {
3539 this.regenerate(
3540 entry_ix, editor.clone(), window, cx,
3541 );
3542 }
3543 })).into_any_element()
3544 }
3545 )
3546 )
3547 } else {
3548 this.child(
3549 base_container
3550 .border_dashed()
3551 .child(
3552 non_editable_icon()
3553 .tooltip(Tooltip::element({
3554 move |_, _| {
3555 v_flex()
3556 .gap_1()
3557 .child(Label::new("Unavailable Editing")).child(
3558 div().max_w_64().child(
3559 Label::new(format!(
3560 "Editing previous messages is not available for {} yet.",
3561 agent_name.clone()
3562 ))
3563 .size(LabelSize::Small)
3564 .color(Color::Muted),
3565 ),
3566 )
3567 .into_any_element()
3568 }
3569 }))
3570 )
3571 )
3572 }
3573 }),
3574 )
3575 .into_any()
3576 }
3577 AgentThreadEntry::AssistantMessage(AssistantMessage {
3578 chunks,
3579 indented: _,
3580 }) => {
3581 let mut is_blank = true;
3582 let is_last = entry_ix + 1 == total_entries;
3583
3584 let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
3585 let message_body = v_flex()
3586 .w_full()
3587 .gap_3()
3588 .children(chunks.iter().enumerate().filter_map(
3589 |(chunk_ix, chunk)| match chunk {
3590 AssistantMessageChunk::Message { block } => {
3591 block.markdown().and_then(|md| {
3592 let this_is_blank = md.read(cx).source().trim().is_empty();
3593 is_blank = is_blank && this_is_blank;
3594 if this_is_blank {
3595 return None;
3596 }
3597
3598 Some(
3599 self.render_markdown(md.clone(), style.clone())
3600 .into_any_element(),
3601 )
3602 })
3603 }
3604 AssistantMessageChunk::Thought { block } => {
3605 block.markdown().and_then(|md| {
3606 let this_is_blank = md.read(cx).source().trim().is_empty();
3607 is_blank = is_blank && this_is_blank;
3608 if this_is_blank {
3609 return None;
3610 }
3611 Some(
3612 self.render_thinking_block(
3613 entry_ix,
3614 chunk_ix,
3615 md.clone(),
3616 window,
3617 cx,
3618 )
3619 .into_any_element(),
3620 )
3621 })
3622 }
3623 },
3624 ))
3625 .into_any();
3626
3627 if is_blank {
3628 Empty.into_any()
3629 } else {
3630 v_flex()
3631 .px_5()
3632 .py_1p5()
3633 .when(is_last, |this| this.pb_4())
3634 .w_full()
3635 .text_ui(cx)
3636 .child(self.render_message_context_menu(entry_ix, message_body, cx))
3637 .into_any()
3638 }
3639 }
3640 AgentThreadEntry::ToolCall(tool_call) => {
3641 let has_terminals = tool_call.terminals().next().is_some();
3642
3643 div()
3644 .w_full()
3645 .map(|this| {
3646 if has_terminals {
3647 this.children(tool_call.terminals().map(|terminal| {
3648 self.render_terminal_tool_call(
3649 entry_ix, terminal, tool_call, window, cx,
3650 )
3651 }))
3652 } else {
3653 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
3654 }
3655 })
3656 .into_any()
3657 }
3658 };
3659
3660 let primary = if is_indented {
3661 let line_top = if is_first_indented {
3662 rems_from_px(-12.0)
3663 } else {
3664 rems_from_px(0.0)
3665 };
3666
3667 div()
3668 .relative()
3669 .w_full()
3670 .pl_5()
3671 .bg(cx.theme().colors().panel_background.opacity(0.2))
3672 .child(
3673 div()
3674 .absolute()
3675 .left(rems_from_px(18.0))
3676 .top(line_top)
3677 .bottom_0()
3678 .w_px()
3679 .bg(cx.theme().colors().border.opacity(0.6)),
3680 )
3681 .child(primary)
3682 .into_any_element()
3683 } else {
3684 primary
3685 };
3686
3687 let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
3688 matches!(
3689 tool_call.status,
3690 ToolCallStatus::WaitingForConfirmation { .. }
3691 )
3692 } else {
3693 false
3694 };
3695
3696 let thread = self.thread.clone();
3697 let comments_editor = self.thread_feedback.comments_editor.clone();
3698
3699 let primary = if entry_ix == total_entries - 1 {
3700 v_flex()
3701 .w_full()
3702 .child(primary)
3703 .map(|this| {
3704 if needs_confirmation {
3705 this.child(self.render_generating(true, cx))
3706 } else {
3707 this.child(self.render_thread_controls(&thread, cx))
3708 }
3709 })
3710 .when_some(comments_editor, |this, editor| {
3711 this.child(Self::render_feedback_feedback_editor(editor, cx))
3712 })
3713 .into_any_element()
3714 } else {
3715 primary
3716 };
3717
3718 if let Some(editing_index) = self.editing_message
3719 && editing_index < entry_ix
3720 {
3721 let is_subagent = self.is_subagent();
3722
3723 let backdrop = div()
3724 .id(("backdrop", entry_ix))
3725 .size_full()
3726 .absolute()
3727 .inset_0()
3728 .bg(cx.theme().colors().panel_background)
3729 .opacity(0.8)
3730 .block_mouse_except_scroll()
3731 .on_click(cx.listener(Self::cancel_editing));
3732
3733 div()
3734 .relative()
3735 .child(primary)
3736 .when(!is_subagent, |this| this.child(backdrop))
3737 .into_any_element()
3738 } else {
3739 primary
3740 }
3741 }
3742
3743 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
3744 h_flex()
3745 .key_context("AgentFeedbackMessageEditor")
3746 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
3747 this.thread_feedback.dismiss_comments();
3748 cx.notify();
3749 }))
3750 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
3751 this.submit_feedback_message(cx);
3752 }))
3753 .p_2()
3754 .mb_2()
3755 .mx_5()
3756 .gap_1()
3757 .rounded_md()
3758 .border_1()
3759 .border_color(cx.theme().colors().border)
3760 .bg(cx.theme().colors().editor_background)
3761 .child(div().w_full().child(editor))
3762 .child(
3763 h_flex()
3764 .child(
3765 IconButton::new("dismiss-feedback-message", IconName::Close)
3766 .icon_color(Color::Error)
3767 .icon_size(IconSize::XSmall)
3768 .shape(ui::IconButtonShape::Square)
3769 .on_click(cx.listener(move |this, _, _window, cx| {
3770 this.thread_feedback.dismiss_comments();
3771 cx.notify();
3772 })),
3773 )
3774 .child(
3775 IconButton::new("submit-feedback-message", IconName::Return)
3776 .icon_size(IconSize::XSmall)
3777 .shape(ui::IconButtonShape::Square)
3778 .on_click(cx.listener(move |this, _, _window, cx| {
3779 this.submit_feedback_message(cx);
3780 })),
3781 ),
3782 )
3783 }
3784
3785 fn render_thread_controls(
3786 &self,
3787 thread: &Entity<AcpThread>,
3788 cx: &Context<Self>,
3789 ) -> impl IntoElement {
3790 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
3791 if is_generating {
3792 return self.render_generating(false, cx).into_any_element();
3793 }
3794
3795 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
3796 .shape(ui::IconButtonShape::Square)
3797 .icon_size(IconSize::Small)
3798 .icon_color(Color::Ignored)
3799 .tooltip(Tooltip::text("Open Thread as Markdown"))
3800 .on_click(cx.listener(move |this, _, window, cx| {
3801 if let Some(workspace) = this.workspace.upgrade() {
3802 this.open_thread_as_markdown(workspace, window, cx)
3803 .detach_and_log_err(cx);
3804 }
3805 }));
3806
3807 let scroll_to_recent_user_prompt =
3808 IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
3809 .shape(ui::IconButtonShape::Square)
3810 .icon_size(IconSize::Small)
3811 .icon_color(Color::Ignored)
3812 .tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
3813 .on_click(cx.listener(move |this, _, _, cx| {
3814 this.scroll_to_most_recent_user_prompt(cx);
3815 }));
3816
3817 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
3818 .shape(ui::IconButtonShape::Square)
3819 .icon_size(IconSize::Small)
3820 .icon_color(Color::Ignored)
3821 .tooltip(Tooltip::text("Scroll To Top"))
3822 .on_click(cx.listener(move |this, _, _, cx| {
3823 this.scroll_to_top(cx);
3824 }));
3825
3826 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
3827 let last_turn_clock = show_stats
3828 .then(|| {
3829 self.turn_fields
3830 .last_turn_duration
3831 .filter(|&duration| duration > STOPWATCH_THRESHOLD)
3832 .map(|duration| {
3833 Label::new(duration_alt_display(duration))
3834 .size(LabelSize::Small)
3835 .color(Color::Muted)
3836 })
3837 })
3838 .flatten();
3839
3840 let last_turn_tokens_label = last_turn_clock
3841 .is_some()
3842 .then(|| {
3843 self.turn_fields
3844 .last_turn_tokens
3845 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
3846 .map(|tokens| {
3847 Label::new(format!(
3848 "{} tokens",
3849 crate::text_thread_editor::humanize_token_count(tokens)
3850 ))
3851 .size(LabelSize::Small)
3852 .color(Color::Muted)
3853 })
3854 })
3855 .flatten();
3856
3857 let mut container = h_flex()
3858 .w_full()
3859 .py_2()
3860 .px_5()
3861 .gap_px()
3862 .opacity(0.6)
3863 .hover(|s| s.opacity(1.))
3864 .justify_end()
3865 .when(
3866 last_turn_tokens_label.is_some() || last_turn_clock.is_some(),
3867 |this| {
3868 this.child(
3869 h_flex()
3870 .gap_1()
3871 .px_1()
3872 .when_some(last_turn_tokens_label, |this, label| this.child(label))
3873 .when_some(last_turn_clock, |this, label| this.child(label)),
3874 )
3875 },
3876 );
3877
3878 if AgentSettings::get_global(cx).enable_feedback
3879 && self.thread.read(cx).connection().telemetry().is_some()
3880 {
3881 let feedback = self.thread_feedback.feedback;
3882
3883 let tooltip_meta = || {
3884 SharedString::new(
3885 "Rating the thread sends all of your current conversation to the Zed team.",
3886 )
3887 };
3888
3889 container = container
3890 .child(
3891 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
3892 .shape(ui::IconButtonShape::Square)
3893 .icon_size(IconSize::Small)
3894 .icon_color(match feedback {
3895 Some(ThreadFeedback::Positive) => Color::Accent,
3896 _ => Color::Ignored,
3897 })
3898 .tooltip(move |window, cx| match feedback {
3899 Some(ThreadFeedback::Positive) => {
3900 Tooltip::text("Thanks for your feedback!")(window, cx)
3901 }
3902 _ => {
3903 Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx)
3904 }
3905 })
3906 .on_click(cx.listener(move |this, _, window, cx| {
3907 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
3908 })),
3909 )
3910 .child(
3911 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
3912 .shape(ui::IconButtonShape::Square)
3913 .icon_size(IconSize::Small)
3914 .icon_color(match feedback {
3915 Some(ThreadFeedback::Negative) => Color::Accent,
3916 _ => Color::Ignored,
3917 })
3918 .tooltip(move |window, cx| match feedback {
3919 Some(ThreadFeedback::Negative) => {
3920 Tooltip::text(
3921 "We appreciate your feedback and will use it to improve in the future.",
3922 )(window, cx)
3923 }
3924 _ => {
3925 Tooltip::with_meta(
3926 "Not Helpful Response",
3927 None,
3928 tooltip_meta(),
3929 cx,
3930 )
3931 }
3932 })
3933 .on_click(cx.listener(move |this, _, window, cx| {
3934 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
3935 })),
3936 );
3937 }
3938
3939 if let Some(project) = self.project.upgrade()
3940 && let Some(server_view) = self.server_view.upgrade()
3941 && cx.has_flag::<AgentSharingFeatureFlag>()
3942 && project.read(cx).client().status().borrow().is_connected()
3943 {
3944 let button = if self.is_imported_thread(cx) {
3945 IconButton::new("sync-thread", IconName::ArrowCircle)
3946 .shape(ui::IconButtonShape::Square)
3947 .icon_size(IconSize::Small)
3948 .icon_color(Color::Ignored)
3949 .tooltip(Tooltip::text("Sync with source thread"))
3950 .on_click(cx.listener(move |this, _, window, cx| {
3951 this.sync_thread(project.clone(), server_view.clone(), window, cx);
3952 }))
3953 } else {
3954 IconButton::new("share-thread", IconName::ArrowUpRight)
3955 .shape(ui::IconButtonShape::Square)
3956 .icon_size(IconSize::Small)
3957 .icon_color(Color::Ignored)
3958 .tooltip(Tooltip::text("Share Thread"))
3959 .on_click(cx.listener(move |this, _, window, cx| {
3960 this.share_thread(window, cx);
3961 }))
3962 };
3963
3964 container = container.child(button);
3965 }
3966
3967 container
3968 .child(open_as_markdown)
3969 .child(scroll_to_recent_user_prompt)
3970 .child(scroll_to_top)
3971 .into_any_element()
3972 }
3973
3974 pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
3975 let entries = self.thread.read(cx).entries();
3976 if entries.is_empty() {
3977 return;
3978 }
3979
3980 // Find the most recent user message and scroll it to the top of the viewport.
3981 // (Fallback: if no user message exists, scroll to the bottom.)
3982 if let Some(ix) = entries
3983 .iter()
3984 .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
3985 {
3986 self.list_state.scroll_to(ListOffset {
3987 item_ix: ix,
3988 offset_in_item: px(0.0),
3989 });
3990 cx.notify();
3991 } else {
3992 self.scroll_to_bottom(cx);
3993 }
3994 }
3995
3996 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
3997 let entry_count = self.thread.read(cx).entries().len();
3998 self.list_state.reset(entry_count);
3999 cx.notify();
4000 }
4001
4002 fn handle_feedback_click(
4003 &mut self,
4004 feedback: ThreadFeedback,
4005 window: &mut Window,
4006 cx: &mut Context<Self>,
4007 ) {
4008 self.thread_feedback
4009 .submit(self.thread.clone(), feedback, window, cx);
4010 cx.notify();
4011 }
4012
4013 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4014 let thread = self.thread.clone();
4015 self.thread_feedback.submit_comments(thread, cx);
4016 cx.notify();
4017 }
4018
4019 pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
4020 self.list_state.scroll_to(ListOffset::default());
4021 cx.notify();
4022 }
4023
4024 pub fn open_thread_as_markdown(
4025 &self,
4026 workspace: Entity<Workspace>,
4027 window: &mut Window,
4028 cx: &mut App,
4029 ) -> Task<Result<()>> {
4030 let markdown_language_task = workspace
4031 .read(cx)
4032 .app_state()
4033 .languages
4034 .language_for_name("Markdown");
4035
4036 let thread = self.thread.read(cx);
4037 let thread_title = thread.title().to_string();
4038 let markdown = thread.to_markdown(cx);
4039
4040 let project = workspace.read(cx).project().clone();
4041 window.spawn(cx, async move |cx| {
4042 let markdown_language = markdown_language_task.await?;
4043
4044 let buffer = project
4045 .update(cx, |project, cx| {
4046 project.create_buffer(Some(markdown_language), false, cx)
4047 })
4048 .await?;
4049
4050 buffer.update(cx, |buffer, cx| {
4051 buffer.set_text(markdown, cx);
4052 buffer.set_capability(language::Capability::ReadWrite, cx);
4053 });
4054
4055 workspace.update_in(cx, |workspace, window, cx| {
4056 let buffer = cx
4057 .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone()));
4058
4059 workspace.add_item_to_active_pane(
4060 Box::new(cx.new(|cx| {
4061 let mut editor =
4062 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4063 editor.set_breadcrumb_header(thread_title);
4064 editor
4065 })),
4066 None,
4067 true,
4068 window,
4069 cx,
4070 );
4071 })?;
4072 anyhow::Ok(())
4073 })
4074 }
4075
4076 fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
4077 let show_stats = AgentSettings::get_global(cx).show_turn_stats;
4078 let elapsed_label = show_stats
4079 .then(|| {
4080 self.turn_fields.turn_started_at.and_then(|started_at| {
4081 let elapsed = started_at.elapsed();
4082 (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
4083 })
4084 })
4085 .flatten();
4086
4087 let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls();
4088
4089 let turn_tokens_label = elapsed_label
4090 .is_some()
4091 .then(|| {
4092 self.turn_fields
4093 .turn_tokens
4094 .filter(|&tokens| tokens > TOKEN_THRESHOLD)
4095 .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens))
4096 })
4097 .flatten();
4098
4099 let arrow_icon = if is_waiting {
4100 IconName::ArrowUp
4101 } else {
4102 IconName::ArrowDown
4103 };
4104
4105 h_flex()
4106 .id("generating-spinner")
4107 .py_2()
4108 .px(rems_from_px(22.))
4109 .gap_2()
4110 .map(|this| {
4111 if confirmation {
4112 this.child(
4113 h_flex()
4114 .w_2()
4115 .child(SpinnerLabel::sand().size(LabelSize::Small)),
4116 )
4117 .child(
4118 div().min_w(rems(8.)).child(
4119 LoadingLabel::new("Awaiting Confirmation")
4120 .size(LabelSize::Small)
4121 .color(Color::Muted),
4122 ),
4123 )
4124 } else {
4125 this.child(SpinnerLabel::new().size(LabelSize::Small))
4126 }
4127 })
4128 .when_some(elapsed_label, |this, elapsed| {
4129 this.child(
4130 Label::new(elapsed)
4131 .size(LabelSize::Small)
4132 .color(Color::Muted),
4133 )
4134 })
4135 .when_some(turn_tokens_label, |this, tokens| {
4136 this.child(
4137 h_flex()
4138 .gap_0p5()
4139 .child(
4140 Icon::new(arrow_icon)
4141 .size(IconSize::XSmall)
4142 .color(Color::Muted),
4143 )
4144 .child(
4145 Label::new(format!("{} tokens", tokens))
4146 .size(LabelSize::Small)
4147 .color(Color::Muted),
4148 ),
4149 )
4150 })
4151 .into_any_element()
4152 }
4153
4154 fn render_thinking_block(
4155 &self,
4156 entry_ix: usize,
4157 chunk_ix: usize,
4158 chunk: Entity<Markdown>,
4159 window: &Window,
4160 cx: &Context<Self>,
4161 ) -> AnyElement {
4162 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
4163 let card_header_id = SharedString::from("inner-card-header");
4164
4165 let key = (entry_ix, chunk_ix);
4166
4167 let is_open = self.expanded_thinking_blocks.contains(&key);
4168
4169 let scroll_handle = self
4170 .entry_view_state
4171 .read(cx)
4172 .entry(entry_ix)
4173 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
4174
4175 let thinking_content = {
4176 div()
4177 .id(("thinking-content", chunk_ix))
4178 .when_some(scroll_handle, |this, scroll_handle| {
4179 this.track_scroll(&scroll_handle)
4180 })
4181 .text_ui_sm(cx)
4182 .overflow_hidden()
4183 .child(self.render_markdown(
4184 chunk,
4185 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
4186 ))
4187 };
4188
4189 v_flex()
4190 .gap_1()
4191 .child(
4192 h_flex()
4193 .id(header_id)
4194 .group(&card_header_id)
4195 .relative()
4196 .w_full()
4197 .pr_1()
4198 .justify_between()
4199 .child(
4200 h_flex()
4201 .h(window.line_height() - px(2.))
4202 .gap_1p5()
4203 .overflow_hidden()
4204 .child(
4205 Icon::new(IconName::ToolThink)
4206 .size(IconSize::Small)
4207 .color(Color::Muted),
4208 )
4209 .child(
4210 div()
4211 .text_size(self.tool_name_font_size())
4212 .text_color(cx.theme().colors().text_muted)
4213 .child("Thinking"),
4214 ),
4215 )
4216 .child(
4217 Disclosure::new(("expand", entry_ix), is_open)
4218 .opened_icon(IconName::ChevronUp)
4219 .closed_icon(IconName::ChevronDown)
4220 .visible_on_hover(&card_header_id)
4221 .on_click(cx.listener({
4222 move |this, _event, _window, cx| {
4223 if is_open {
4224 this.expanded_thinking_blocks.remove(&key);
4225 } else {
4226 this.expanded_thinking_blocks.insert(key);
4227 }
4228 cx.notify();
4229 }
4230 })),
4231 )
4232 .on_click(cx.listener(move |this, _event, _window, cx| {
4233 if is_open {
4234 this.expanded_thinking_blocks.remove(&key);
4235 } else {
4236 this.expanded_thinking_blocks.insert(key);
4237 }
4238 cx.notify();
4239 })),
4240 )
4241 .when(is_open, |this| {
4242 this.child(
4243 div()
4244 .ml_1p5()
4245 .pl_3p5()
4246 .border_l_1()
4247 .border_color(self.tool_card_border_color(cx))
4248 .child(thinking_content),
4249 )
4250 })
4251 .into_any_element()
4252 }
4253
4254 fn render_message_context_menu(
4255 &self,
4256 entry_ix: usize,
4257 message_body: AnyElement,
4258 cx: &Context<Self>,
4259 ) -> AnyElement {
4260 let entity = cx.entity();
4261 let workspace = self.workspace.clone();
4262
4263 right_click_menu(format!("agent_context_menu-{}", entry_ix))
4264 .trigger(move |_, _, _| message_body)
4265 .menu(move |window, cx| {
4266 let focus = window.focused(cx);
4267 let entity = entity.clone();
4268 let workspace = workspace.clone();
4269
4270 ContextMenu::build(window, cx, move |menu, _, cx| {
4271 let this = entity.read(cx);
4272 let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
4273
4274 let has_selection = this
4275 .thread
4276 .read(cx)
4277 .entries()
4278 .get(entry_ix)
4279 .and_then(|entry| match &entry {
4280 AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
4281 _ => None,
4282 })
4283 .map(|chunks| {
4284 chunks.iter().any(|chunk| {
4285 let md = match chunk {
4286 AssistantMessageChunk::Message { block } => block.markdown(),
4287 AssistantMessageChunk::Thought { block } => block.markdown(),
4288 };
4289 md.map_or(false, |m| m.read(cx).selected_text().is_some())
4290 })
4291 })
4292 .unwrap_or(false);
4293
4294 let copy_this_agent_response =
4295 ContextMenuEntry::new("Copy This Agent Response").handler({
4296 let entity = entity.clone();
4297 move |_, cx| {
4298 entity.update(cx, |this, cx| {
4299 let entries = this.thread.read(cx).entries();
4300 if let Some(text) =
4301 Self::get_agent_message_content(entries, entry_ix, cx)
4302 {
4303 cx.write_to_clipboard(ClipboardItem::new_string(text));
4304 }
4305 });
4306 }
4307 });
4308
4309 let scroll_item = if is_at_top {
4310 ContextMenuEntry::new("Scroll to Bottom").handler({
4311 let entity = entity.clone();
4312 move |_, cx| {
4313 entity.update(cx, |this, cx| {
4314 this.scroll_to_bottom(cx);
4315 });
4316 }
4317 })
4318 } else {
4319 ContextMenuEntry::new("Scroll to Top").handler({
4320 let entity = entity.clone();
4321 move |_, cx| {
4322 entity.update(cx, |this, cx| {
4323 this.scroll_to_top(cx);
4324 });
4325 }
4326 })
4327 };
4328
4329 let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
4330 .handler({
4331 let entity = entity.clone();
4332 let workspace = workspace.clone();
4333 move |window, cx| {
4334 if let Some(workspace) = workspace.upgrade() {
4335 entity
4336 .update(cx, |this, cx| {
4337 this.open_thread_as_markdown(workspace, window, cx)
4338 })
4339 .detach_and_log_err(cx);
4340 }
4341 }
4342 });
4343
4344 menu.when_some(focus, |menu, focus| menu.context(focus))
4345 .action_disabled_when(
4346 !has_selection,
4347 "Copy Selection",
4348 Box::new(markdown::CopyAsMarkdown),
4349 )
4350 .item(copy_this_agent_response)
4351 .separator()
4352 .item(scroll_item)
4353 .item(open_thread_as_markdown)
4354 })
4355 })
4356 .into_any_element()
4357 }
4358
4359 fn get_agent_message_content(
4360 entries: &[AgentThreadEntry],
4361 entry_index: usize,
4362 cx: &App,
4363 ) -> Option<String> {
4364 let entry = entries.get(entry_index)?;
4365 if matches!(entry, AgentThreadEntry::UserMessage(_)) {
4366 return None;
4367 }
4368
4369 let start_index = (0..entry_index)
4370 .rev()
4371 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
4372 .map(|i| i + 1)
4373 .unwrap_or(0);
4374
4375 let end_index = (entry_index + 1..entries.len())
4376 .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
4377 .map(|i| i - 1)
4378 .unwrap_or(entries.len() - 1);
4379
4380 let parts: Vec<String> = (start_index..=end_index)
4381 .filter_map(|i| entries.get(i))
4382 .filter_map(|entry| {
4383 if let AgentThreadEntry::AssistantMessage(message) = entry {
4384 let text: String = message
4385 .chunks
4386 .iter()
4387 .filter_map(|chunk| match chunk {
4388 AssistantMessageChunk::Message { block } => {
4389 let markdown = block.to_markdown(cx);
4390 if markdown.trim().is_empty() {
4391 None
4392 } else {
4393 Some(markdown.to_string())
4394 }
4395 }
4396 AssistantMessageChunk::Thought { .. } => None,
4397 })
4398 .collect::<Vec<_>>()
4399 .join("\n\n");
4400
4401 if text.is_empty() { None } else { Some(text) }
4402 } else {
4403 None
4404 }
4405 })
4406 .collect();
4407
4408 let text = parts.join("\n\n");
4409 if text.is_empty() { None } else { Some(text) }
4410 }
4411
4412 fn render_collapsible_command(
4413 &self,
4414 is_preview: bool,
4415 command_source: &str,
4416 tool_call_id: &acp::ToolCallId,
4417 cx: &Context<Self>,
4418 ) -> Div {
4419 let command_group =
4420 SharedString::from(format!("collapsible-command-group-{}", tool_call_id));
4421
4422 v_flex()
4423 .group(command_group.clone())
4424 .bg(self.tool_card_header_bg(cx))
4425 .child(
4426 v_flex()
4427 .p_1p5()
4428 .when(is_preview, |this| {
4429 this.pt_1().child(
4430 // Wrapping this label on a container with 24px height to avoid
4431 // layout shift when it changes from being a preview label
4432 // to the actual path where the command will run in
4433 h_flex().h_6().child(
4434 Label::new("Run Command")
4435 .buffer_font(cx)
4436 .size(LabelSize::XSmall)
4437 .color(Color::Muted),
4438 ),
4439 )
4440 })
4441 .children(command_source.lines().map(|line| {
4442 let text: SharedString = if line.is_empty() {
4443 " ".into()
4444 } else {
4445 line.to_string().into()
4446 };
4447
4448 Label::new(text).buffer_font(cx).size(LabelSize::Small)
4449 }))
4450 .child(
4451 div().absolute().top_1().right_1().child(
4452 CopyButton::new("copy-command", command_source.to_string())
4453 .tooltip_label("Copy Command")
4454 .visible_on_hover(command_group),
4455 ),
4456 ),
4457 )
4458 }
4459
4460 fn render_terminal_tool_call(
4461 &self,
4462 entry_ix: usize,
4463 terminal: &Entity<acp_thread::Terminal>,
4464 tool_call: &ToolCall,
4465 window: &Window,
4466 cx: &Context<Self>,
4467 ) -> AnyElement {
4468 let terminal_data = terminal.read(cx);
4469 let working_dir = terminal_data.working_dir();
4470 let command = terminal_data.command();
4471 let started_at = terminal_data.started_at();
4472
4473 let tool_failed = matches!(
4474 &tool_call.status,
4475 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
4476 );
4477
4478 let confirmation_options = match &tool_call.status {
4479 ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options),
4480 _ => None,
4481 };
4482 let needs_confirmation = confirmation_options.is_some();
4483
4484 let output = terminal_data.output();
4485 let command_finished = output.is_some();
4486 let truncated_output =
4487 output.is_some_and(|output| output.original_content_len > output.content.len());
4488 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
4489
4490 let command_failed = command_finished
4491 && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
4492
4493 let time_elapsed = if let Some(output) = output {
4494 output.ended_at.duration_since(started_at)
4495 } else {
4496 started_at.elapsed()
4497 };
4498
4499 let header_id =
4500 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
4501 let header_group = SharedString::from(format!(
4502 "terminal-tool-header-group-{}",
4503 terminal.entity_id()
4504 ));
4505 let header_bg = cx
4506 .theme()
4507 .colors()
4508 .element_background
4509 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
4510 let border_color = cx.theme().colors().border.opacity(0.6);
4511
4512 let working_dir = working_dir
4513 .as_ref()
4514 .map(|path| path.display().to_string())
4515 .unwrap_or_else(|| "current directory".to_string());
4516
4517 // Since the command's source is wrapped in a markdown code block
4518 // (```\n...\n```), we need to strip that so we're left with only the
4519 // command's content.
4520 let command_source = command.read(cx).source();
4521 let command_content = command_source
4522 .strip_prefix("```\n")
4523 .and_then(|s| s.strip_suffix("\n```"))
4524 .unwrap_or(&command_source);
4525
4526 let command_element =
4527 self.render_collapsible_command(false, command_content, &tool_call.id, cx);
4528
4529 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
4530
4531 let header = h_flex()
4532 .id(header_id)
4533 .px_1p5()
4534 .pt_1()
4535 .flex_none()
4536 .gap_1()
4537 .justify_between()
4538 .rounded_t_md()
4539 .child(
4540 div()
4541 .id(("command-target-path", terminal.entity_id()))
4542 .w_full()
4543 .max_w_full()
4544 .overflow_x_scroll()
4545 .child(
4546 Label::new(working_dir)
4547 .buffer_font(cx)
4548 .size(LabelSize::XSmall)
4549 .color(Color::Muted),
4550 ),
4551 )
4552 .when(!command_finished && !needs_confirmation, |header| {
4553 header
4554 .gap_1p5()
4555 .child(
4556 Button::new(
4557 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
4558 "Stop",
4559 )
4560 .icon(IconName::Stop)
4561 .icon_position(IconPosition::Start)
4562 .icon_size(IconSize::Small)
4563 .icon_color(Color::Error)
4564 .label_size(LabelSize::Small)
4565 .tooltip(move |_window, cx| {
4566 Tooltip::with_meta(
4567 "Stop This Command",
4568 None,
4569 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
4570 cx,
4571 )
4572 })
4573 .on_click({
4574 let terminal = terminal.clone();
4575 cx.listener(move |this, _event, _window, cx| {
4576 terminal.update(cx, |terminal, cx| {
4577 terminal.stop_by_user(cx);
4578 });
4579 if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
4580 this.cancel_generation(cx);
4581 }
4582 })
4583 }),
4584 )
4585 .child(Divider::vertical())
4586 .child(
4587 Icon::new(IconName::ArrowCircle)
4588 .size(IconSize::XSmall)
4589 .color(Color::Info)
4590 .with_rotate_animation(2)
4591 )
4592 })
4593 .when(truncated_output, |header| {
4594 let tooltip = if let Some(output) = output {
4595 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
4596 format!("Output exceeded terminal max lines and was \
4597 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
4598 } else {
4599 format!(
4600 "Output is {} long, and to avoid unexpected token usage, \
4601 only {} was sent back to the agent.",
4602 format_file_size(output.original_content_len as u64, true),
4603 format_file_size(output.content.len() as u64, true)
4604 )
4605 }
4606 } else {
4607 "Output was truncated".to_string()
4608 };
4609
4610 header.child(
4611 h_flex()
4612 .id(("terminal-tool-truncated-label", terminal.entity_id()))
4613 .gap_1()
4614 .child(
4615 Icon::new(IconName::Info)
4616 .size(IconSize::XSmall)
4617 .color(Color::Ignored),
4618 )
4619 .child(
4620 Label::new("Truncated")
4621 .color(Color::Muted)
4622 .size(LabelSize::XSmall),
4623 )
4624 .tooltip(Tooltip::text(tooltip)),
4625 )
4626 })
4627 .when(time_elapsed > Duration::from_secs(10), |header| {
4628 header.child(
4629 Label::new(format!("({})", duration_alt_display(time_elapsed)))
4630 .buffer_font(cx)
4631 .color(Color::Muted)
4632 .size(LabelSize::XSmall),
4633 )
4634 })
4635 .when(tool_failed || command_failed, |header| {
4636 header.child(
4637 div()
4638 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
4639 .child(
4640 Icon::new(IconName::Close)
4641 .size(IconSize::Small)
4642 .color(Color::Error),
4643 )
4644 .when_some(output.and_then(|o| o.exit_status), |this, status| {
4645 this.tooltip(Tooltip::text(format!(
4646 "Exited with code {}",
4647 status.code().unwrap_or(-1),
4648 )))
4649 }),
4650 )
4651 })
4652 .child(
4653 Disclosure::new(
4654 SharedString::from(format!(
4655 "terminal-tool-disclosure-{}",
4656 terminal.entity_id()
4657 )),
4658 is_expanded,
4659 )
4660 .opened_icon(IconName::ChevronUp)
4661 .closed_icon(IconName::ChevronDown)
4662 .visible_on_hover(&header_group)
4663 .on_click(cx.listener({
4664 let id = tool_call.id.clone();
4665 move |this, _event, _window, cx| {
4666 if is_expanded {
4667 this.expanded_tool_calls.remove(&id);
4668 } else {
4669 this.expanded_tool_calls.insert(id.clone());
4670 }
4671 cx.notify();
4672 }
4673 })),
4674 );
4675
4676 let terminal_view = self
4677 .entry_view_state
4678 .read(cx)
4679 .entry(entry_ix)
4680 .and_then(|entry| entry.terminal(terminal));
4681
4682 v_flex()
4683 .my_1p5()
4684 .mx_5()
4685 .border_1()
4686 .when(tool_failed || command_failed, |card| card.border_dashed())
4687 .border_color(border_color)
4688 .rounded_md()
4689 .overflow_hidden()
4690 .child(
4691 v_flex()
4692 .group(&header_group)
4693 .bg(header_bg)
4694 .text_xs()
4695 .child(header)
4696 .child(command_element),
4697 )
4698 .when(is_expanded && terminal_view.is_some(), |this| {
4699 this.child(
4700 div()
4701 .pt_2()
4702 .border_t_1()
4703 .when(tool_failed || command_failed, |card| card.border_dashed())
4704 .border_color(border_color)
4705 .bg(cx.theme().colors().editor_background)
4706 .rounded_b_md()
4707 .text_ui_sm(cx)
4708 .h_full()
4709 .children(terminal_view.map(|terminal_view| {
4710 let element = if terminal_view
4711 .read(cx)
4712 .content_mode(window, cx)
4713 .is_scrollable()
4714 {
4715 div().h_72().child(terminal_view).into_any_element()
4716 } else {
4717 terminal_view.into_any_element()
4718 };
4719
4720 div()
4721 .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| {
4722 window.dispatch_action(NewThread.boxed_clone(), cx);
4723 cx.stop_propagation();
4724 }))
4725 .child(element)
4726 .into_any_element()
4727 })),
4728 )
4729 })
4730 .when_some(confirmation_options, |this, options| {
4731 this.child(self.render_permission_buttons(
4732 options,
4733 entry_ix,
4734 tool_call.id.clone(),
4735 cx,
4736 ))
4737 })
4738 .into_any()
4739 }
4740
4741 fn render_tool_call(
4742 &self,
4743 entry_ix: usize,
4744 tool_call: &ToolCall,
4745 window: &Window,
4746 cx: &Context<Self>,
4747 ) -> Div {
4748 let has_location = tool_call.locations.len() == 1;
4749 let card_header_id = SharedString::from("inner-tool-call-header");
4750
4751 let failed_or_canceled = match &tool_call.status {
4752 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
4753 _ => false,
4754 };
4755
4756 let needs_confirmation = matches!(
4757 tool_call.status,
4758 ToolCallStatus::WaitingForConfirmation { .. }
4759 );
4760 let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
4761
4762 let is_edit =
4763 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
4764
4765 // For subagent tool calls, render the subagent cards directly without wrapper
4766 if tool_call.is_subagent() {
4767 return self.render_subagent_tool_call(
4768 entry_ix,
4769 tool_call,
4770 tool_call.subagent_session_id.clone(),
4771 window,
4772 cx,
4773 );
4774 }
4775
4776 let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
4777 let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| {
4778 self.entry_view_state
4779 .read(cx)
4780 .entry(entry_ix)
4781 .and_then(|entry| entry.editor_for_diff(diff))
4782 .is_some()
4783 && diff.read(cx).has_revealed_range(cx)
4784 });
4785
4786 let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
4787
4788 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
4789 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
4790 let mut is_open = self.expanded_tool_calls.contains(&tool_call.id);
4791
4792 is_open |= needs_confirmation;
4793
4794 let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
4795
4796 let input_output_header = |label: SharedString| {
4797 Label::new(label)
4798 .size(LabelSize::XSmall)
4799 .color(Color::Muted)
4800 .buffer_font(cx)
4801 };
4802
4803 let tool_output_display = if is_open {
4804 match &tool_call.status {
4805 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
4806 .w_full()
4807 .children(
4808 tool_call
4809 .content
4810 .iter()
4811 .enumerate()
4812 .map(|(content_ix, content)| {
4813 div()
4814 .child(self.render_tool_call_content(
4815 entry_ix,
4816 content,
4817 content_ix,
4818 tool_call,
4819 use_card_layout,
4820 has_image_content,
4821 failed_or_canceled,
4822 window,
4823 cx,
4824 ))
4825 .into_any_element()
4826 }),
4827 )
4828 .when(should_show_raw_input, |this| {
4829 let is_raw_input_expanded =
4830 self.expanded_tool_call_raw_inputs.contains(&tool_call.id);
4831
4832 let input_header = if is_raw_input_expanded {
4833 "Raw Input:"
4834 } else {
4835 "View Raw Input"
4836 };
4837
4838 this.child(
4839 v_flex()
4840 .p_2()
4841 .gap_1()
4842 .border_t_1()
4843 .border_color(self.tool_card_border_color(cx))
4844 .child(
4845 h_flex()
4846 .id("disclosure_container")
4847 .pl_0p5()
4848 .gap_1()
4849 .justify_between()
4850 .rounded_xs()
4851 .hover(|s| s.bg(cx.theme().colors().element_hover))
4852 .child(input_output_header(input_header.into()))
4853 .child(
4854 Disclosure::new(
4855 ("raw-input-disclosure", entry_ix),
4856 is_raw_input_expanded,
4857 )
4858 .opened_icon(IconName::ChevronUp)
4859 .closed_icon(IconName::ChevronDown),
4860 )
4861 .on_click(cx.listener({
4862 let id = tool_call.id.clone();
4863
4864 move |this: &mut Self, _, _, cx| {
4865 if this.expanded_tool_call_raw_inputs.contains(&id)
4866 {
4867 this.expanded_tool_call_raw_inputs.remove(&id);
4868 } else {
4869 this.expanded_tool_call_raw_inputs
4870 .insert(id.clone());
4871 }
4872 cx.notify();
4873 }
4874 })),
4875 )
4876 .when(is_raw_input_expanded, |this| {
4877 this.children(tool_call.raw_input_markdown.clone().map(
4878 |input| {
4879 self.render_markdown(
4880 input,
4881 MarkdownStyle::themed(
4882 MarkdownFont::Agent,
4883 window,
4884 cx,
4885 ),
4886 )
4887 },
4888 ))
4889 }),
4890 )
4891 })
4892 .child(self.render_permission_buttons(
4893 options,
4894 entry_ix,
4895 tool_call.id.clone(),
4896 cx,
4897 ))
4898 .into_any(),
4899 ToolCallStatus::Pending | ToolCallStatus::InProgress
4900 if is_edit
4901 && tool_call.content.is_empty()
4902 && self.as_native_connection(cx).is_some() =>
4903 {
4904 self.render_diff_loading(cx)
4905 }
4906 ToolCallStatus::Pending
4907 | ToolCallStatus::InProgress
4908 | ToolCallStatus::Completed
4909 | ToolCallStatus::Failed
4910 | ToolCallStatus::Canceled => v_flex()
4911 .when(should_show_raw_input, |this| {
4912 this.mt_1p5().w_full().child(
4913 v_flex()
4914 .ml(rems(0.4))
4915 .px_3p5()
4916 .pb_1()
4917 .gap_1()
4918 .border_l_1()
4919 .border_color(self.tool_card_border_color(cx))
4920 .child(input_output_header("Raw Input:".into()))
4921 .children(tool_call.raw_input_markdown.clone().map(|input| {
4922 div().id(("tool-call-raw-input-markdown", entry_ix)).child(
4923 self.render_markdown(
4924 input,
4925 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
4926 ),
4927 )
4928 }))
4929 .child(input_output_header("Output:".into())),
4930 )
4931 })
4932 .children(
4933 tool_call
4934 .content
4935 .iter()
4936 .enumerate()
4937 .map(|(content_ix, content)| {
4938 div().id(("tool-call-output", entry_ix)).child(
4939 self.render_tool_call_content(
4940 entry_ix,
4941 content,
4942 content_ix,
4943 tool_call,
4944 use_card_layout,
4945 has_image_content,
4946 failed_or_canceled,
4947 window,
4948 cx,
4949 ),
4950 )
4951 }),
4952 )
4953 .into_any(),
4954 ToolCallStatus::Rejected => Empty.into_any(),
4955 }
4956 .into()
4957 } else {
4958 None
4959 };
4960
4961 v_flex()
4962 .map(|this| {
4963 if use_card_layout {
4964 this.my_1p5()
4965 .rounded_md()
4966 .border_1()
4967 .when(failed_or_canceled, |this| this.border_dashed())
4968 .border_color(self.tool_card_border_color(cx))
4969 .bg(cx.theme().colors().editor_background)
4970 .overflow_hidden()
4971 } else {
4972 this.my_1()
4973 }
4974 })
4975 .map(|this| {
4976 if has_location && !use_card_layout {
4977 this.ml_4()
4978 } else {
4979 this.ml_5()
4980 }
4981 })
4982 .mr_5()
4983 .map(|this| {
4984 if is_terminal_tool {
4985 let label_source = tool_call.label.read(cx).source();
4986 this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx))
4987 } else {
4988 this.child(
4989 h_flex()
4990 .group(&card_header_id)
4991 .relative()
4992 .w_full()
4993 .gap_1()
4994 .justify_between()
4995 .when(use_card_layout, |this| {
4996 this.p_0p5()
4997 .rounded_t(rems_from_px(5.))
4998 .bg(self.tool_card_header_bg(cx))
4999 })
5000 .child(self.render_tool_call_label(
5001 entry_ix,
5002 tool_call,
5003 is_edit,
5004 is_cancelled_edit,
5005 has_revealed_diff,
5006 use_card_layout,
5007 window,
5008 cx,
5009 ))
5010 .when(is_collapsible || failed_or_canceled, |this| {
5011 let diff_for_discard =
5012 if has_revealed_diff && is_cancelled_edit && cx.has_flag::<AgentV2FeatureFlag>() {
5013 tool_call.diffs().next().cloned()
5014 } else {
5015 None
5016 };
5017 this.child(
5018 h_flex()
5019 .px_1()
5020 .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5())
5021 .gap_1()
5022 .when(is_collapsible, |this| {
5023 this.child(
5024 Disclosure::new(("expand-output", entry_ix), is_open)
5025 .opened_icon(IconName::ChevronUp)
5026 .closed_icon(IconName::ChevronDown)
5027 .visible_on_hover(&card_header_id)
5028 .on_click(cx.listener({
5029 let id = tool_call.id.clone();
5030 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
5031 if is_open {
5032 this
5033 .expanded_tool_calls.remove(&id);
5034 } else {
5035 this.expanded_tool_calls.insert(id.clone());
5036 }
5037 cx.notify();
5038 }
5039 })),
5040 )
5041 })
5042 .when(failed_or_canceled, |this| {
5043 if is_cancelled_edit && !has_revealed_diff {
5044 this.child(
5045 div()
5046 .id(entry_ix)
5047 .tooltip(Tooltip::text(
5048 "Interrupted Edit",
5049 ))
5050 .child(
5051 Icon::new(IconName::XCircle)
5052 .color(Color::Muted)
5053 .size(IconSize::Small),
5054 ),
5055 )
5056 } else if is_cancelled_edit {
5057 this
5058 } else {
5059 this.child(
5060 Icon::new(IconName::Close)
5061 .color(Color::Error)
5062 .size(IconSize::Small),
5063 )
5064 }
5065 })
5066 .when_some(diff_for_discard, |this, diff| {
5067 let tool_call_id = tool_call.id.clone();
5068 let is_discarded = self.discarded_partial_edits.contains(&tool_call_id);
5069 this.when(!is_discarded, |this| {
5070 this.child(
5071 IconButton::new(
5072 ("discard-partial-edit", entry_ix),
5073 IconName::Undo,
5074 )
5075 .icon_size(IconSize::Small)
5076 .tooltip(move |_, cx| Tooltip::with_meta(
5077 "Discard Interrupted Edit",
5078 None,
5079 "You can discard this interrupted partial edit and restore the original file content.",
5080 cx
5081 ))
5082 .on_click(cx.listener({
5083 let tool_call_id = tool_call_id.clone();
5084 move |this, _, _window, cx| {
5085 let diff_data = diff.read(cx);
5086 let base_text = diff_data.base_text().clone();
5087 let buffer = diff_data.buffer().clone();
5088 buffer.update(cx, |buffer, cx| {
5089 buffer.set_text(base_text.as_ref(), cx);
5090 });
5091 this.discarded_partial_edits.insert(tool_call_id.clone());
5092 cx.notify();
5093 }
5094 })),
5095 )
5096 })
5097 })
5098
5099 )
5100 }),
5101 )
5102 }
5103 })
5104 .children(tool_output_display)
5105 }
5106
5107 fn render_permission_buttons(
5108 &self,
5109 options: &PermissionOptions,
5110 entry_ix: usize,
5111 tool_call_id: acp::ToolCallId,
5112 cx: &Context<Self>,
5113 ) -> Div {
5114 match options {
5115 PermissionOptions::Flat(options) => {
5116 self.render_permission_buttons_flat(options, entry_ix, tool_call_id, cx)
5117 }
5118 PermissionOptions::Dropdown(options) => {
5119 self.render_permission_buttons_dropdown(options, entry_ix, tool_call_id, cx)
5120 }
5121 }
5122 }
5123
5124 fn render_permission_buttons_dropdown(
5125 &self,
5126 choices: &[PermissionOptionChoice],
5127 entry_ix: usize,
5128 tool_call_id: acp::ToolCallId,
5129 cx: &Context<Self>,
5130 ) -> Div {
5131 let is_first = self
5132 .thread
5133 .read(cx)
5134 .first_tool_awaiting_confirmation()
5135 .is_some_and(|call| call.id == tool_call_id);
5136
5137 // Get the selected granularity index, defaulting to the last option ("Only this time")
5138 let selected_index = self
5139 .selected_permission_granularity
5140 .get(&tool_call_id)
5141 .copied()
5142 .unwrap_or_else(|| choices.len().saturating_sub(1));
5143
5144 let selected_choice = choices.get(selected_index).or(choices.last());
5145
5146 let dropdown_label: SharedString = selected_choice
5147 .map(|choice| choice.label())
5148 .unwrap_or_else(|| "Only this time".into());
5149
5150 let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) =
5151 if let Some(choice) = selected_choice {
5152 (
5153 choice.allow.option_id.clone(),
5154 choice.allow.kind,
5155 choice.deny.option_id.clone(),
5156 choice.deny.kind,
5157 )
5158 } else {
5159 (
5160 acp::PermissionOptionId::new("allow"),
5161 acp::PermissionOptionKind::AllowOnce,
5162 acp::PermissionOptionId::new("deny"),
5163 acp::PermissionOptionKind::RejectOnce,
5164 )
5165 };
5166
5167 h_flex()
5168 .w_full()
5169 .p_1()
5170 .gap_2()
5171 .justify_between()
5172 .border_t_1()
5173 .border_color(self.tool_card_border_color(cx))
5174 .child(
5175 h_flex()
5176 .gap_0p5()
5177 .child(
5178 Button::new(("allow-btn", entry_ix), "Allow")
5179 .icon(IconName::Check)
5180 .icon_color(Color::Success)
5181 .icon_position(IconPosition::Start)
5182 .icon_size(IconSize::XSmall)
5183 .label_size(LabelSize::Small)
5184 .when(is_first, |this| {
5185 this.key_binding(
5186 KeyBinding::for_action_in(
5187 &AllowOnce as &dyn Action,
5188 &self.focus_handle(cx),
5189 cx,
5190 )
5191 .map(|kb| kb.size(rems_from_px(10.))),
5192 )
5193 })
5194 .on_click(cx.listener({
5195 let tool_call_id = tool_call_id.clone();
5196 let option_id = allow_option_id;
5197 let option_kind = allow_option_kind;
5198 move |this, _, window, cx| {
5199 this.authorize_tool_call(
5200 tool_call_id.clone(),
5201 option_id.clone(),
5202 option_kind,
5203 window,
5204 cx,
5205 );
5206 }
5207 })),
5208 )
5209 .child(
5210 Button::new(("deny-btn", entry_ix), "Deny")
5211 .icon(IconName::Close)
5212 .icon_color(Color::Error)
5213 .icon_position(IconPosition::Start)
5214 .icon_size(IconSize::XSmall)
5215 .label_size(LabelSize::Small)
5216 .when(is_first, |this| {
5217 this.key_binding(
5218 KeyBinding::for_action_in(
5219 &RejectOnce as &dyn Action,
5220 &self.focus_handle(cx),
5221 cx,
5222 )
5223 .map(|kb| kb.size(rems_from_px(10.))),
5224 )
5225 })
5226 .on_click(cx.listener({
5227 let tool_call_id = tool_call_id.clone();
5228 let option_id = deny_option_id;
5229 let option_kind = deny_option_kind;
5230 move |this, _, window, cx| {
5231 this.authorize_tool_call(
5232 tool_call_id.clone(),
5233 option_id.clone(),
5234 option_kind,
5235 window,
5236 cx,
5237 );
5238 }
5239 })),
5240 ),
5241 )
5242 .child(self.render_permission_granularity_dropdown(
5243 choices,
5244 dropdown_label,
5245 entry_ix,
5246 tool_call_id,
5247 selected_index,
5248 is_first,
5249 cx,
5250 ))
5251 }
5252
5253 fn render_permission_granularity_dropdown(
5254 &self,
5255 choices: &[PermissionOptionChoice],
5256 current_label: SharedString,
5257 entry_ix: usize,
5258 tool_call_id: acp::ToolCallId,
5259 selected_index: usize,
5260 is_first: bool,
5261 cx: &Context<Self>,
5262 ) -> AnyElement {
5263 let menu_options: Vec<(usize, SharedString)> = choices
5264 .iter()
5265 .enumerate()
5266 .map(|(i, choice)| (i, choice.label()))
5267 .collect();
5268
5269 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
5270
5271 PopoverMenu::new(("permission-granularity", entry_ix))
5272 .with_handle(permission_dropdown_handle)
5273 .trigger(
5274 Button::new(("granularity-trigger", entry_ix), current_label)
5275 .icon(IconName::ChevronDown)
5276 .icon_size(IconSize::XSmall)
5277 .icon_color(Color::Muted)
5278 .label_size(LabelSize::Small)
5279 .when(is_first, |this| {
5280 this.key_binding(
5281 KeyBinding::for_action_in(
5282 &crate::OpenPermissionDropdown as &dyn Action,
5283 &self.focus_handle(cx),
5284 cx,
5285 )
5286 .map(|kb| kb.size(rems_from_px(10.))),
5287 )
5288 }),
5289 )
5290 .menu(move |window, cx| {
5291 let tool_call_id = tool_call_id.clone();
5292 let options = menu_options.clone();
5293
5294 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
5295 for (index, display_name) in options.iter() {
5296 let display_name = display_name.clone();
5297 let index = *index;
5298 let tool_call_id_for_entry = tool_call_id.clone();
5299 let is_selected = index == selected_index;
5300
5301 menu = menu.toggleable_entry(
5302 display_name,
5303 is_selected,
5304 IconPosition::End,
5305 None,
5306 move |window, cx| {
5307 window.dispatch_action(
5308 SelectPermissionGranularity {
5309 tool_call_id: tool_call_id_for_entry.0.to_string(),
5310 index,
5311 }
5312 .boxed_clone(),
5313 cx,
5314 );
5315 },
5316 );
5317 }
5318
5319 menu
5320 }))
5321 })
5322 .into_any_element()
5323 }
5324
5325 fn render_permission_buttons_flat(
5326 &self,
5327 options: &[acp::PermissionOption],
5328 entry_ix: usize,
5329 tool_call_id: acp::ToolCallId,
5330 cx: &Context<Self>,
5331 ) -> Div {
5332 let is_first = self
5333 .thread
5334 .read(cx)
5335 .first_tool_awaiting_confirmation()
5336 .is_some_and(|call| call.id == tool_call_id);
5337 let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3> = ArrayVec::new();
5338
5339 div()
5340 .p_1()
5341 .border_t_1()
5342 .border_color(self.tool_card_border_color(cx))
5343 .w_full()
5344 .v_flex()
5345 .gap_0p5()
5346 .children(options.iter().map(move |option| {
5347 let option_id = SharedString::from(option.option_id.0.clone());
5348 Button::new((option_id, entry_ix), option.name.clone())
5349 .map(|this| {
5350 let (this, action) = match option.kind {
5351 acp::PermissionOptionKind::AllowOnce => (
5352 this.icon(IconName::Check).icon_color(Color::Success),
5353 Some(&AllowOnce as &dyn Action),
5354 ),
5355 acp::PermissionOptionKind::AllowAlways => (
5356 this.icon(IconName::CheckDouble).icon_color(Color::Success),
5357 Some(&AllowAlways as &dyn Action),
5358 ),
5359 acp::PermissionOptionKind::RejectOnce => (
5360 this.icon(IconName::Close).icon_color(Color::Error),
5361 Some(&RejectOnce as &dyn Action),
5362 ),
5363 acp::PermissionOptionKind::RejectAlways | _ => {
5364 (this.icon(IconName::Close).icon_color(Color::Error), None)
5365 }
5366 };
5367
5368 let Some(action) = action else {
5369 return this;
5370 };
5371
5372 if !is_first || seen_kinds.contains(&option.kind) {
5373 return this;
5374 }
5375
5376 seen_kinds.push(option.kind);
5377
5378 this.key_binding(
5379 KeyBinding::for_action_in(action, &self.focus_handle(cx), cx)
5380 .map(|kb| kb.size(rems_from_px(10.))),
5381 )
5382 })
5383 .icon_position(IconPosition::Start)
5384 .icon_size(IconSize::XSmall)
5385 .label_size(LabelSize::Small)
5386 .on_click(cx.listener({
5387 let tool_call_id = tool_call_id.clone();
5388 let option_id = option.option_id.clone();
5389 let option_kind = option.kind;
5390 move |this, _, window, cx| {
5391 this.authorize_tool_call(
5392 tool_call_id.clone(),
5393 option_id.clone(),
5394 option_kind,
5395 window,
5396 cx,
5397 );
5398 }
5399 }))
5400 }))
5401 }
5402
5403 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
5404 let bar = |n: u64, width_class: &str| {
5405 let bg_color = cx.theme().colors().element_active;
5406 let base = h_flex().h_1().rounded_full();
5407
5408 let modified = match width_class {
5409 "w_4_5" => base.w_3_4(),
5410 "w_1_4" => base.w_1_4(),
5411 "w_2_4" => base.w_2_4(),
5412 "w_3_5" => base.w_3_5(),
5413 "w_2_5" => base.w_2_5(),
5414 _ => base.w_1_2(),
5415 };
5416
5417 modified.with_animation(
5418 ElementId::Integer(n),
5419 Animation::new(Duration::from_secs(2)).repeat(),
5420 move |tab, delta| {
5421 let delta = (delta - 0.15 * n as f32) / 0.7;
5422 let delta = 1.0 - (0.5 - delta).abs() * 2.;
5423 let delta = ease_in_out(delta.clamp(0., 1.));
5424 let delta = 0.1 + 0.9 * delta;
5425
5426 tab.bg(bg_color.opacity(delta))
5427 },
5428 )
5429 };
5430
5431 v_flex()
5432 .p_3()
5433 .gap_1()
5434 .rounded_b_md()
5435 .bg(cx.theme().colors().editor_background)
5436 .child(bar(0, "w_4_5"))
5437 .child(bar(1, "w_1_4"))
5438 .child(bar(2, "w_2_4"))
5439 .child(bar(3, "w_3_5"))
5440 .child(bar(4, "w_2_5"))
5441 .into_any_element()
5442 }
5443
5444 fn render_tool_call_label(
5445 &self,
5446 entry_ix: usize,
5447 tool_call: &ToolCall,
5448 is_edit: bool,
5449 has_failed: bool,
5450 has_revealed_diff: bool,
5451 use_card_layout: bool,
5452 window: &Window,
5453 cx: &Context<Self>,
5454 ) -> Div {
5455 let has_location = tool_call.locations.len() == 1;
5456 let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
5457 let is_subagent_tool_call = tool_call.is_subagent();
5458
5459 let file_icon = if has_location {
5460 FileIcons::get_icon(&tool_call.locations[0].path, cx)
5461 .map(Icon::from_path)
5462 .unwrap_or(Icon::new(IconName::ToolPencil))
5463 } else {
5464 Icon::new(IconName::ToolPencil)
5465 };
5466
5467 let tool_icon = if is_file && has_failed && has_revealed_diff {
5468 div()
5469 .id(entry_ix)
5470 .tooltip(Tooltip::text("Interrupted Edit"))
5471 .child(DecoratedIcon::new(
5472 file_icon,
5473 Some(
5474 IconDecoration::new(
5475 IconDecorationKind::Triangle,
5476 self.tool_card_header_bg(cx),
5477 cx,
5478 )
5479 .color(cx.theme().status().warning)
5480 .position(gpui::Point {
5481 x: px(-2.),
5482 y: px(-2.),
5483 }),
5484 ),
5485 ))
5486 .into_any_element()
5487 } else if is_file {
5488 div().child(file_icon).into_any_element()
5489 } else if is_subagent_tool_call {
5490 Icon::new(self.agent_icon)
5491 .size(IconSize::Small)
5492 .color(Color::Muted)
5493 .into_any_element()
5494 } else {
5495 Icon::new(match tool_call.kind {
5496 acp::ToolKind::Read => IconName::ToolSearch,
5497 acp::ToolKind::Edit => IconName::ToolPencil,
5498 acp::ToolKind::Delete => IconName::ToolDeleteFile,
5499 acp::ToolKind::Move => IconName::ArrowRightLeft,
5500 acp::ToolKind::Search => IconName::ToolSearch,
5501 acp::ToolKind::Execute => IconName::ToolTerminal,
5502 acp::ToolKind::Think => IconName::ToolThink,
5503 acp::ToolKind::Fetch => IconName::ToolWeb,
5504 acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
5505 acp::ToolKind::Other | _ => IconName::ToolHammer,
5506 })
5507 .size(IconSize::Small)
5508 .color(Color::Muted)
5509 .into_any_element()
5510 };
5511
5512 let gradient_overlay = {
5513 div()
5514 .absolute()
5515 .top_0()
5516 .right_0()
5517 .w_12()
5518 .h_full()
5519 .map(|this| {
5520 if use_card_layout {
5521 this.bg(linear_gradient(
5522 90.,
5523 linear_color_stop(self.tool_card_header_bg(cx), 1.),
5524 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
5525 ))
5526 } else {
5527 this.bg(linear_gradient(
5528 90.,
5529 linear_color_stop(cx.theme().colors().panel_background, 1.),
5530 linear_color_stop(
5531 cx.theme().colors().panel_background.opacity(0.2),
5532 0.,
5533 ),
5534 ))
5535 }
5536 })
5537 };
5538
5539 h_flex()
5540 .relative()
5541 .w_full()
5542 .h(window.line_height() - px(2.))
5543 .text_size(self.tool_name_font_size())
5544 .gap_1p5()
5545 .when(has_location || use_card_layout, |this| this.px_1())
5546 .when(has_location, |this| {
5547 this.cursor(CursorStyle::PointingHand)
5548 .rounded(rems_from_px(3.)) // Concentric border radius
5549 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
5550 })
5551 .overflow_hidden()
5552 .child(tool_icon)
5553 .child(if has_location {
5554 h_flex()
5555 .id(("open-tool-call-location", entry_ix))
5556 .w_full()
5557 .map(|this| {
5558 if use_card_layout {
5559 this.text_color(cx.theme().colors().text)
5560 } else {
5561 this.text_color(cx.theme().colors().text_muted)
5562 }
5563 })
5564 .child(
5565 self.render_markdown(
5566 tool_call.label.clone(),
5567 MarkdownStyle {
5568 prevent_mouse_interaction: true,
5569 ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx)
5570 .with_muted_text(cx)
5571 },
5572 ),
5573 )
5574 .tooltip(Tooltip::text("Go to File"))
5575 .on_click(cx.listener(move |this, _, window, cx| {
5576 this.open_tool_call_location(entry_ix, 0, window, cx);
5577 }))
5578 .into_any_element()
5579 } else {
5580 h_flex()
5581 .w_full()
5582 .child(self.render_markdown(
5583 tool_call.label.clone(),
5584 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx),
5585 ))
5586 .into_any()
5587 })
5588 .when(!is_edit, |this| this.child(gradient_overlay))
5589 }
5590
5591 fn open_tool_call_location(
5592 &self,
5593 entry_ix: usize,
5594 location_ix: usize,
5595 window: &mut Window,
5596 cx: &mut Context<Self>,
5597 ) -> Option<()> {
5598 let (tool_call_location, agent_location) = self
5599 .thread
5600 .read(cx)
5601 .entries()
5602 .get(entry_ix)?
5603 .location(location_ix)?;
5604
5605 let project_path = self
5606 .project
5607 .upgrade()?
5608 .read(cx)
5609 .find_project_path(&tool_call_location.path, cx)?;
5610
5611 let open_task = self
5612 .workspace
5613 .update(cx, |workspace, cx| {
5614 workspace.open_path(project_path, None, true, window, cx)
5615 })
5616 .log_err()?;
5617 window
5618 .spawn(cx, async move |cx| {
5619 let item = open_task.await?;
5620
5621 let Some(active_editor) = item.downcast::<Editor>() else {
5622 return anyhow::Ok(());
5623 };
5624
5625 active_editor.update_in(cx, |editor, window, cx| {
5626 let multibuffer = editor.buffer().read(cx);
5627 let buffer = multibuffer.as_singleton();
5628 if agent_location.buffer.upgrade() == buffer {
5629 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
5630 let anchor =
5631 editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position);
5632 editor.change_selections(Default::default(), window, cx, |selections| {
5633 selections.select_anchor_ranges([anchor..anchor]);
5634 })
5635 } else {
5636 let row = tool_call_location.line.unwrap_or_default();
5637 editor.change_selections(Default::default(), window, cx, |selections| {
5638 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
5639 })
5640 }
5641 })?;
5642
5643 anyhow::Ok(())
5644 })
5645 .detach_and_log_err(cx);
5646
5647 None
5648 }
5649
5650 fn render_tool_call_content(
5651 &self,
5652 entry_ix: usize,
5653 content: &ToolCallContent,
5654 context_ix: usize,
5655 tool_call: &ToolCall,
5656 card_layout: bool,
5657 is_image_tool_call: bool,
5658 has_failed: bool,
5659 window: &Window,
5660 cx: &Context<Self>,
5661 ) -> AnyElement {
5662 match content {
5663 ToolCallContent::ContentBlock(content) => {
5664 if let Some(resource_link) = content.resource_link() {
5665 self.render_resource_link(resource_link, cx)
5666 } else if let Some(markdown) = content.markdown() {
5667 self.render_markdown_output(
5668 markdown.clone(),
5669 tool_call.id.clone(),
5670 context_ix,
5671 card_layout,
5672 window,
5673 cx,
5674 )
5675 } else if let Some(image) = content.image() {
5676 let location = tool_call.locations.first().cloned();
5677 self.render_image_output(
5678 entry_ix,
5679 image.clone(),
5680 location,
5681 card_layout,
5682 is_image_tool_call,
5683 cx,
5684 )
5685 } else {
5686 Empty.into_any_element()
5687 }
5688 }
5689 ToolCallContent::Diff(diff) => {
5690 self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
5691 }
5692 ToolCallContent::Terminal(terminal) => {
5693 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
5694 }
5695 }
5696 }
5697
5698 fn render_resource_link(
5699 &self,
5700 resource_link: &acp::ResourceLink,
5701 cx: &Context<Self>,
5702 ) -> AnyElement {
5703 let uri: SharedString = resource_link.uri.clone().into();
5704 let is_file = resource_link.uri.strip_prefix("file://");
5705
5706 let Some(project) = self.project.upgrade() else {
5707 return Empty.into_any_element();
5708 };
5709
5710 let label: SharedString = if let Some(abs_path) = is_file {
5711 if let Some(project_path) = project
5712 .read(cx)
5713 .project_path_for_absolute_path(&Path::new(abs_path), cx)
5714 && let Some(worktree) = project
5715 .read(cx)
5716 .worktree_for_id(project_path.worktree_id, cx)
5717 {
5718 worktree
5719 .read(cx)
5720 .full_path(&project_path.path)
5721 .to_string_lossy()
5722 .to_string()
5723 .into()
5724 } else {
5725 abs_path.to_string().into()
5726 }
5727 } else {
5728 uri.clone()
5729 };
5730
5731 let button_id = SharedString::from(format!("item-{}", uri));
5732
5733 div()
5734 .ml(rems(0.4))
5735 .pl_2p5()
5736 .border_l_1()
5737 .border_color(self.tool_card_border_color(cx))
5738 .overflow_hidden()
5739 .child(
5740 Button::new(button_id, label)
5741 .label_size(LabelSize::Small)
5742 .color(Color::Muted)
5743 .truncate(true)
5744 .when(is_file.is_none(), |this| {
5745 this.icon(IconName::ArrowUpRight)
5746 .icon_size(IconSize::XSmall)
5747 .icon_color(Color::Muted)
5748 })
5749 .on_click(cx.listener({
5750 let workspace = self.workspace.clone();
5751 move |_, _, window, cx: &mut Context<Self>| {
5752 open_link(uri.clone(), &workspace, window, cx);
5753 }
5754 })),
5755 )
5756 .into_any_element()
5757 }
5758
5759 fn render_diff_editor(
5760 &self,
5761 entry_ix: usize,
5762 diff: &Entity<acp_thread::Diff>,
5763 tool_call: &ToolCall,
5764 has_failed: bool,
5765 cx: &Context<Self>,
5766 ) -> AnyElement {
5767 let tool_progress = matches!(
5768 &tool_call.status,
5769 ToolCallStatus::InProgress | ToolCallStatus::Pending
5770 );
5771
5772 let revealed_diff_editor = if let Some(entry) =
5773 self.entry_view_state.read(cx).entry(entry_ix)
5774 && let Some(editor) = entry.editor_for_diff(diff)
5775 && diff.read(cx).has_revealed_range(cx)
5776 {
5777 Some(editor)
5778 } else {
5779 None
5780 };
5781
5782 let show_top_border = !has_failed || revealed_diff_editor.is_some();
5783
5784 v_flex()
5785 .h_full()
5786 .when(show_top_border, |this| {
5787 this.border_t_1()
5788 .when(has_failed, |this| this.border_dashed())
5789 .border_color(self.tool_card_border_color(cx))
5790 })
5791 .child(if let Some(editor) = revealed_diff_editor {
5792 editor.into_any_element()
5793 } else if tool_progress && self.as_native_connection(cx).is_some() {
5794 self.render_diff_loading(cx)
5795 } else {
5796 Empty.into_any()
5797 })
5798 .into_any()
5799 }
5800
5801 fn render_markdown_output(
5802 &self,
5803 markdown: Entity<Markdown>,
5804 tool_call_id: acp::ToolCallId,
5805 context_ix: usize,
5806 card_layout: bool,
5807 window: &Window,
5808 cx: &Context<Self>,
5809 ) -> AnyElement {
5810 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
5811
5812 v_flex()
5813 .gap_2()
5814 .map(|this| {
5815 if card_layout {
5816 this.when(context_ix > 0, |this| {
5817 this.pt_2()
5818 .border_t_1()
5819 .border_color(self.tool_card_border_color(cx))
5820 })
5821 } else {
5822 this.ml(rems(0.4))
5823 .px_3p5()
5824 .border_l_1()
5825 .border_color(self.tool_card_border_color(cx))
5826 }
5827 })
5828 .text_xs()
5829 .text_color(cx.theme().colors().text_muted)
5830 .child(self.render_markdown(
5831 markdown,
5832 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
5833 ))
5834 .when(!card_layout, |this| {
5835 this.child(
5836 IconButton::new(button_id, IconName::ChevronUp)
5837 .full_width()
5838 .style(ButtonStyle::Outlined)
5839 .icon_color(Color::Muted)
5840 .on_click(cx.listener({
5841 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
5842 this.expanded_tool_calls.remove(&tool_call_id);
5843 cx.notify();
5844 }
5845 })),
5846 )
5847 })
5848 .into_any_element()
5849 }
5850
5851 fn render_image_output(
5852 &self,
5853 entry_ix: usize,
5854 image: Arc<gpui::Image>,
5855 location: Option<acp::ToolCallLocation>,
5856 card_layout: bool,
5857 show_dimensions: bool,
5858 cx: &Context<Self>,
5859 ) -> AnyElement {
5860 let dimensions_label = if show_dimensions {
5861 let format_name = match image.format() {
5862 gpui::ImageFormat::Png => "PNG",
5863 gpui::ImageFormat::Jpeg => "JPEG",
5864 gpui::ImageFormat::Webp => "WebP",
5865 gpui::ImageFormat::Gif => "GIF",
5866 gpui::ImageFormat::Svg => "SVG",
5867 gpui::ImageFormat::Bmp => "BMP",
5868 gpui::ImageFormat::Tiff => "TIFF",
5869 gpui::ImageFormat::Ico => "ICO",
5870 };
5871 let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
5872 .with_guessed_format()
5873 .ok()
5874 .and_then(|reader| reader.into_dimensions().ok());
5875 dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name))
5876 } else {
5877 None
5878 };
5879
5880 v_flex()
5881 .gap_2()
5882 .map(|this| {
5883 if card_layout {
5884 this
5885 } else {
5886 this.ml(rems(0.4))
5887 .px_3p5()
5888 .border_l_1()
5889 .border_color(self.tool_card_border_color(cx))
5890 }
5891 })
5892 .when(dimensions_label.is_some() || location.is_some(), |this| {
5893 this.child(
5894 h_flex()
5895 .w_full()
5896 .justify_between()
5897 .items_center()
5898 .children(dimensions_label.map(|label| {
5899 Label::new(label)
5900 .size(LabelSize::XSmall)
5901 .color(Color::Muted)
5902 .buffer_font(cx)
5903 }))
5904 .when_some(location, |this, _loc| {
5905 this.child(
5906 Button::new(("go-to-file", entry_ix), "Go to File")
5907 .label_size(LabelSize::Small)
5908 .on_click(cx.listener(move |this, _, window, cx| {
5909 this.open_tool_call_location(entry_ix, 0, window, cx);
5910 })),
5911 )
5912 }),
5913 )
5914 })
5915 .child(
5916 img(image)
5917 .max_w_96()
5918 .max_h_96()
5919 .object_fit(ObjectFit::ScaleDown),
5920 )
5921 .into_any_element()
5922 }
5923
5924 fn render_subagent_tool_call(
5925 &self,
5926 entry_ix: usize,
5927 tool_call: &ToolCall,
5928 subagent_session_id: Option<acp::SessionId>,
5929 window: &Window,
5930 cx: &Context<Self>,
5931 ) -> Div {
5932 let tool_call_status = &tool_call.status;
5933
5934 let subagent_thread_view = subagent_session_id.and_then(|id| {
5935 self.server_view
5936 .upgrade()
5937 .and_then(|server_view| server_view.read(cx).as_connected())
5938 .and_then(|connected| connected.threads.get(&id))
5939 });
5940
5941 let content = self.render_subagent_card(
5942 entry_ix,
5943 0,
5944 subagent_thread_view,
5945 tool_call_status,
5946 window,
5947 cx,
5948 );
5949
5950 v_flex().mx_5().my_1p5().gap_3().child(content)
5951 }
5952
5953 fn render_subagent_card(
5954 &self,
5955 entry_ix: usize,
5956 context_ix: usize,
5957 thread_view: Option<&Entity<AcpThreadView>>,
5958 tool_call_status: &ToolCallStatus,
5959 window: &Window,
5960 cx: &Context<Self>,
5961 ) -> AnyElement {
5962 let thread = thread_view
5963 .as_ref()
5964 .map(|view| view.read(cx).thread.clone());
5965 let session_id = thread
5966 .as_ref()
5967 .map(|thread| thread.read(cx).session_id().clone());
5968 let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log());
5969 let changed_buffers = action_log
5970 .map(|log| log.read(cx).changed_buffers(cx))
5971 .unwrap_or_default();
5972
5973 let is_expanded = if let Some(session_id) = &session_id {
5974 self.expanded_subagents.contains(session_id)
5975 } else {
5976 false
5977 };
5978 let files_changed = changed_buffers.len();
5979 let diff_stats = DiffStats::all_files(&changed_buffers, cx);
5980
5981 let is_running = matches!(
5982 tool_call_status,
5983 ToolCallStatus::Pending | ToolCallStatus::InProgress
5984 );
5985 let is_canceled_or_failed = matches!(
5986 tool_call_status,
5987 ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
5988 );
5989
5990 let title = thread
5991 .as_ref()
5992 .map(|t| t.read(cx).title())
5993 .unwrap_or_else(|| {
5994 if is_canceled_or_failed {
5995 "Subagent Canceled"
5996 } else {
5997 "Creating Subagent…"
5998 }
5999 .into()
6000 });
6001
6002 let card_header_id = format!("subagent-header-{}-{}", entry_ix, context_ix);
6003 let diff_stat_id = format!("subagent-diff-{}-{}", entry_ix, context_ix);
6004
6005 let icon = h_flex().w_4().justify_center().child(if is_running {
6006 SpinnerLabel::new()
6007 .size(LabelSize::Small)
6008 .into_any_element()
6009 } else if is_canceled_or_failed {
6010 Icon::new(IconName::Close)
6011 .size(IconSize::Small)
6012 .color(Color::Error)
6013 .into_any_element()
6014 } else {
6015 Icon::new(IconName::Check)
6016 .size(IconSize::Small)
6017 .color(Color::Success)
6018 .into_any_element()
6019 });
6020
6021 let has_expandable_content = thread.as_ref().map_or(false, |thread| {
6022 thread.read(cx).entries().iter().rev().any(|entry| {
6023 if let AgentThreadEntry::AssistantMessage(msg) = entry {
6024 msg.chunks.iter().any(|chunk| match chunk {
6025 AssistantMessageChunk::Message { block } => block.markdown().is_some(),
6026 AssistantMessageChunk::Thought { block } => block.markdown().is_some(),
6027 })
6028 } else {
6029 false
6030 }
6031 })
6032 });
6033
6034 v_flex()
6035 .w_full()
6036 .rounded_md()
6037 .border_1()
6038 .border_color(self.tool_card_border_color(cx))
6039 .overflow_hidden()
6040 .child(
6041 h_flex()
6042 .group(&card_header_id)
6043 .p_1()
6044 .pl_1p5()
6045 .w_full()
6046 .gap_1()
6047 .justify_between()
6048 .bg(self.tool_card_header_bg(cx))
6049 .child(
6050 h_flex()
6051 .gap_1p5()
6052 .child(icon)
6053 .child(Label::new(title.to_string()).size(LabelSize::Small))
6054 .when(files_changed > 0, |this| {
6055 this.child(
6056 h_flex()
6057 .gap_1()
6058 .child(
6059 Label::new(format!(
6060 "— {} {} changed",
6061 files_changed,
6062 if files_changed == 1 { "file" } else { "files" }
6063 ))
6064 .size(LabelSize::Small)
6065 .color(Color::Muted),
6066 )
6067 .child(DiffStat::new(
6068 diff_stat_id.clone(),
6069 diff_stats.lines_added as usize,
6070 diff_stats.lines_removed as usize,
6071 )),
6072 )
6073 }),
6074 )
6075 .when_some(session_id, |this, session_id| {
6076 this.child(
6077 h_flex()
6078 .when(has_expandable_content, |this| {
6079 this.child(
6080 IconButton::new(
6081 format!(
6082 "subagent-disclosure-{}-{}",
6083 entry_ix, context_ix
6084 ),
6085 if is_expanded {
6086 IconName::ChevronUp
6087 } else {
6088 IconName::ChevronDown
6089 },
6090 )
6091 .icon_color(Color::Muted)
6092 .icon_size(IconSize::Small)
6093 .disabled(!has_expandable_content)
6094 .visible_on_hover(card_header_id.clone())
6095 .on_click(
6096 cx.listener({
6097 let session_id = session_id.clone();
6098 move |this, _, _, cx| {
6099 if this.expanded_subagents.contains(&session_id)
6100 {
6101 this.expanded_subagents.remove(&session_id);
6102 } else {
6103 this.expanded_subagents
6104 .insert(session_id.clone());
6105 }
6106 cx.notify();
6107 }
6108 }),
6109 ),
6110 )
6111 })
6112 .child(
6113 IconButton::new(
6114 format!("expand-subagent-{}-{}", entry_ix, context_ix),
6115 IconName::Maximize,
6116 )
6117 .icon_color(Color::Muted)
6118 .icon_size(IconSize::Small)
6119 .tooltip(Tooltip::text("Expand Subagent"))
6120 .visible_on_hover(card_header_id)
6121 .on_click(cx.listener(
6122 move |this, _event, window, cx| {
6123 this.server_view
6124 .update(cx, |this, cx| {
6125 this.navigate_to_session(
6126 session_id.clone(),
6127 window,
6128 cx,
6129 );
6130 })
6131 .ok();
6132 },
6133 )),
6134 )
6135 .when(is_running, |buttons| {
6136 buttons.child(
6137 IconButton::new(
6138 format!("stop-subagent-{}-{}", entry_ix, context_ix),
6139 IconName::Stop,
6140 )
6141 .icon_size(IconSize::Small)
6142 .icon_color(Color::Error)
6143 .tooltip(Tooltip::text("Stop Subagent"))
6144 .when_some(
6145 thread_view
6146 .as_ref()
6147 .map(|view| view.read(cx).thread.clone()),
6148 |this, thread| {
6149 this.on_click(cx.listener(
6150 move |_this, _event, _window, cx| {
6151 thread.update(cx, |thread, _cx| {
6152 thread.stop_by_user();
6153 });
6154 },
6155 ))
6156 },
6157 ),
6158 )
6159 }),
6160 )
6161 }),
6162 )
6163 .when_some(thread_view, |this, thread_view| {
6164 let thread = &thread_view.read(cx).thread;
6165 this.when(is_expanded, |this| {
6166 this.child(
6167 self.render_subagent_expanded_content(
6168 entry_ix, context_ix, thread, window, cx,
6169 ),
6170 )
6171 })
6172 .children(
6173 thread
6174 .read(cx)
6175 .first_tool_awaiting_confirmation()
6176 .and_then(|tc| {
6177 if let ToolCallStatus::WaitingForConfirmation { options, .. } =
6178 &tc.status
6179 {
6180 Some(self.render_subagent_pending_tool_call(
6181 entry_ix,
6182 context_ix,
6183 thread.clone(),
6184 tc,
6185 options,
6186 window,
6187 cx,
6188 ))
6189 } else {
6190 None
6191 }
6192 }),
6193 )
6194 })
6195 .into_any_element()
6196 }
6197
6198 fn render_subagent_expanded_content(
6199 &self,
6200 _entry_ix: usize,
6201 _context_ix: usize,
6202 thread: &Entity<AcpThread>,
6203 window: &Window,
6204 cx: &Context<Self>,
6205 ) -> impl IntoElement {
6206 let thread_read = thread.read(cx);
6207 let session_id = thread_read.session_id().clone();
6208 let entries = thread_read.entries();
6209
6210 // Find the most recent agent message with any content (message or thought)
6211 let last_assistant_markdown = entries.iter().rev().find_map(|entry| {
6212 if let AgentThreadEntry::AssistantMessage(msg) = entry {
6213 msg.chunks.iter().find_map(|chunk| match chunk {
6214 AssistantMessageChunk::Message { block } => block.markdown().cloned(),
6215 AssistantMessageChunk::Thought { block } => block.markdown().cloned(),
6216 })
6217 } else {
6218 None
6219 }
6220 });
6221
6222 let scroll_handle = self
6223 .subagent_scroll_handles
6224 .borrow_mut()
6225 .entry(session_id.clone())
6226 .or_default()
6227 .clone();
6228
6229 scroll_handle.scroll_to_bottom();
6230 let editor_bg = cx.theme().colors().editor_background;
6231
6232 let gradient_overlay = {
6233 div().absolute().inset_0().bg(linear_gradient(
6234 180.,
6235 linear_color_stop(editor_bg, 0.),
6236 linear_color_stop(editor_bg.opacity(0.), 0.15),
6237 ))
6238 };
6239
6240 div()
6241 .relative()
6242 .w_full()
6243 .max_h_56()
6244 .p_2p5()
6245 .text_ui(cx)
6246 .border_t_1()
6247 .border_color(self.tool_card_border_color(cx))
6248 .bg(editor_bg.opacity(0.4))
6249 .overflow_hidden()
6250 .child(
6251 div()
6252 .id(format!("subagent-content-{}", session_id))
6253 .size_full()
6254 .track_scroll(&scroll_handle)
6255 .when_some(last_assistant_markdown, |this, markdown| {
6256 this.child(self.render_markdown(
6257 markdown,
6258 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
6259 ))
6260 }),
6261 )
6262 .child(gradient_overlay)
6263 }
6264
6265 fn render_subagent_pending_tool_call(
6266 &self,
6267 entry_ix: usize,
6268 context_ix: usize,
6269 subagent_thread: Entity<AcpThread>,
6270 tool_call: &ToolCall,
6271 options: &PermissionOptions,
6272 window: &Window,
6273 cx: &Context<Self>,
6274 ) -> Div {
6275 let tool_call_id = tool_call.id.clone();
6276 let is_edit =
6277 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
6278 let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
6279
6280 v_flex()
6281 .w_full()
6282 .border_t_1()
6283 .border_color(self.tool_card_border_color(cx))
6284 .child(
6285 self.render_tool_call_label(
6286 entry_ix, tool_call, is_edit, false, // has_failed
6287 false, // has_revealed_diff
6288 true, // use_card_layout
6289 window, cx,
6290 )
6291 .py_1(),
6292 )
6293 .children(
6294 tool_call
6295 .content
6296 .iter()
6297 .enumerate()
6298 .map(|(content_ix, content)| {
6299 self.render_tool_call_content(
6300 entry_ix,
6301 content,
6302 content_ix,
6303 tool_call,
6304 true, // card_layout
6305 has_image_content,
6306 false, // has_failed
6307 window,
6308 cx,
6309 )
6310 }),
6311 )
6312 .child(self.render_subagent_permission_buttons(
6313 entry_ix,
6314 context_ix,
6315 subagent_thread,
6316 tool_call_id,
6317 options,
6318 cx,
6319 ))
6320 }
6321
6322 fn render_subagent_permission_buttons(
6323 &self,
6324 entry_ix: usize,
6325 context_ix: usize,
6326 subagent_thread: Entity<AcpThread>,
6327 tool_call_id: acp::ToolCallId,
6328 options: &PermissionOptions,
6329 cx: &Context<Self>,
6330 ) -> Div {
6331 match options {
6332 PermissionOptions::Flat(options) => self.render_subagent_permission_buttons_flat(
6333 entry_ix,
6334 context_ix,
6335 subagent_thread,
6336 tool_call_id,
6337 options,
6338 cx,
6339 ),
6340 PermissionOptions::Dropdown(options) => self
6341 .render_subagent_permission_buttons_dropdown(
6342 entry_ix,
6343 context_ix,
6344 subagent_thread,
6345 tool_call_id,
6346 options,
6347 cx,
6348 ),
6349 }
6350 }
6351
6352 fn render_subagent_permission_buttons_flat(
6353 &self,
6354 entry_ix: usize,
6355 context_ix: usize,
6356 subagent_thread: Entity<AcpThread>,
6357 tool_call_id: acp::ToolCallId,
6358 options: &[acp::PermissionOption],
6359 cx: &Context<Self>,
6360 ) -> Div {
6361 div()
6362 .p_1()
6363 .border_t_1()
6364 .border_color(self.tool_card_border_color(cx))
6365 .w_full()
6366 .v_flex()
6367 .gap_0p5()
6368 .children(options.iter().map(move |option| {
6369 let option_id = SharedString::from(format!(
6370 "subagent-{}-{}-{}",
6371 entry_ix, context_ix, option.option_id.0
6372 ));
6373 Button::new((option_id, entry_ix), option.name.clone())
6374 .map(|this| match option.kind {
6375 acp::PermissionOptionKind::AllowOnce => {
6376 this.icon(IconName::Check).icon_color(Color::Success)
6377 }
6378 acp::PermissionOptionKind::AllowAlways => {
6379 this.icon(IconName::CheckDouble).icon_color(Color::Success)
6380 }
6381 acp::PermissionOptionKind::RejectOnce
6382 | acp::PermissionOptionKind::RejectAlways
6383 | _ => this.icon(IconName::Close).icon_color(Color::Error),
6384 })
6385 .icon_position(IconPosition::Start)
6386 .icon_size(IconSize::XSmall)
6387 .label_size(LabelSize::Small)
6388 .on_click(cx.listener({
6389 let subagent_thread = subagent_thread.clone();
6390 let tool_call_id = tool_call_id.clone();
6391 let option_id = option.option_id.clone();
6392 let option_kind = option.kind;
6393 move |this, _, window, cx| {
6394 this.authorize_subagent_tool_call(
6395 subagent_thread.clone(),
6396 tool_call_id.clone(),
6397 option_id.clone(),
6398 option_kind,
6399 window,
6400 cx,
6401 );
6402 }
6403 }))
6404 }))
6405 }
6406
6407 fn authorize_subagent_tool_call(
6408 &mut self,
6409 subagent_thread: Entity<AcpThread>,
6410 tool_call_id: acp::ToolCallId,
6411 option_id: acp::PermissionOptionId,
6412 option_kind: acp::PermissionOptionKind,
6413 _window: &mut Window,
6414 cx: &mut Context<Self>,
6415 ) {
6416 subagent_thread.update(cx, |thread, cx| {
6417 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
6418 });
6419 }
6420
6421 fn render_subagent_permission_buttons_dropdown(
6422 &self,
6423 entry_ix: usize,
6424 context_ix: usize,
6425 subagent_thread: Entity<AcpThread>,
6426 tool_call_id: acp::ToolCallId,
6427 choices: &[PermissionOptionChoice],
6428 cx: &Context<Self>,
6429 ) -> Div {
6430 let selected_index = self
6431 .selected_permission_granularity
6432 .get(&tool_call_id)
6433 .copied()
6434 .unwrap_or_else(|| choices.len().saturating_sub(1));
6435
6436 let selected_choice = choices.get(selected_index).or(choices.last());
6437
6438 let dropdown_label: SharedString = selected_choice
6439 .map(|choice| choice.label())
6440 .unwrap_or_else(|| "Only this time".into());
6441
6442 let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) =
6443 if let Some(choice) = selected_choice {
6444 (
6445 choice.allow.option_id.clone(),
6446 choice.allow.kind,
6447 choice.deny.option_id.clone(),
6448 choice.deny.kind,
6449 )
6450 } else {
6451 (
6452 acp::PermissionOptionId::new("allow"),
6453 acp::PermissionOptionKind::AllowOnce,
6454 acp::PermissionOptionId::new("deny"),
6455 acp::PermissionOptionKind::RejectOnce,
6456 )
6457 };
6458
6459 h_flex()
6460 .w_full()
6461 .p_1()
6462 .gap_2()
6463 .justify_between()
6464 .border_t_1()
6465 .border_color(self.tool_card_border_color(cx))
6466 .child(
6467 h_flex()
6468 .gap_0p5()
6469 .child(
6470 Button::new(
6471 (
6472 SharedString::from(format!(
6473 "subagent-allow-btn-{}-{}",
6474 entry_ix, context_ix
6475 )),
6476 entry_ix,
6477 ),
6478 "Allow",
6479 )
6480 .icon(IconName::Check)
6481 .icon_color(Color::Success)
6482 .icon_position(IconPosition::Start)
6483 .icon_size(IconSize::XSmall)
6484 .label_size(LabelSize::Small)
6485 .on_click(cx.listener({
6486 let subagent_thread = subagent_thread.clone();
6487 let tool_call_id = tool_call_id.clone();
6488 let option_id = allow_option_id;
6489 let option_kind = allow_option_kind;
6490 move |this, _, window, cx| {
6491 this.authorize_subagent_tool_call(
6492 subagent_thread.clone(),
6493 tool_call_id.clone(),
6494 option_id.clone(),
6495 option_kind,
6496 window,
6497 cx,
6498 );
6499 }
6500 })),
6501 )
6502 .child(
6503 Button::new(
6504 (
6505 SharedString::from(format!(
6506 "subagent-deny-btn-{}-{}",
6507 entry_ix, context_ix
6508 )),
6509 entry_ix,
6510 ),
6511 "Deny",
6512 )
6513 .icon(IconName::Close)
6514 .icon_color(Color::Error)
6515 .icon_position(IconPosition::Start)
6516 .icon_size(IconSize::XSmall)
6517 .label_size(LabelSize::Small)
6518 .on_click(cx.listener({
6519 let tool_call_id = tool_call_id.clone();
6520 let option_id = deny_option_id;
6521 let option_kind = deny_option_kind;
6522 move |this, _, window, cx| {
6523 this.authorize_subagent_tool_call(
6524 subagent_thread.clone(),
6525 tool_call_id.clone(),
6526 option_id.clone(),
6527 option_kind,
6528 window,
6529 cx,
6530 );
6531 }
6532 })),
6533 ),
6534 )
6535 .child(self.render_subagent_permission_granularity_dropdown(
6536 choices,
6537 dropdown_label,
6538 entry_ix,
6539 context_ix,
6540 tool_call_id,
6541 selected_index,
6542 cx,
6543 ))
6544 }
6545
6546 fn render_subagent_permission_granularity_dropdown(
6547 &self,
6548 choices: &[PermissionOptionChoice],
6549 current_label: SharedString,
6550 entry_ix: usize,
6551 context_ix: usize,
6552 tool_call_id: acp::ToolCallId,
6553 selected_index: usize,
6554 _cx: &Context<Self>,
6555 ) -> AnyElement {
6556 let menu_options: Vec<(usize, SharedString)> = choices
6557 .iter()
6558 .enumerate()
6559 .map(|(i, choice)| (i, choice.label()))
6560 .collect();
6561
6562 let permission_dropdown_handle = self.permission_dropdown_handle.clone();
6563
6564 PopoverMenu::new((
6565 SharedString::from(format!(
6566 "subagent-permission-granularity-{}-{}",
6567 entry_ix, context_ix
6568 )),
6569 entry_ix,
6570 ))
6571 .with_handle(permission_dropdown_handle)
6572 .trigger(
6573 Button::new(
6574 (
6575 SharedString::from(format!(
6576 "subagent-granularity-trigger-{}-{}",
6577 entry_ix, context_ix
6578 )),
6579 entry_ix,
6580 ),
6581 current_label,
6582 )
6583 .icon(IconName::ChevronDown)
6584 .icon_size(IconSize::XSmall)
6585 .icon_color(Color::Muted)
6586 .label_size(LabelSize::Small),
6587 )
6588 .menu(move |window, cx| {
6589 let tool_call_id = tool_call_id.clone();
6590 let options = menu_options.clone();
6591
6592 Some(ContextMenu::build(window, cx, move |mut menu, _, _| {
6593 for (index, display_name) in options.iter() {
6594 let display_name = display_name.clone();
6595 let index = *index;
6596 let tool_call_id_for_entry = tool_call_id.clone();
6597 let is_selected = index == selected_index;
6598
6599 menu = menu.toggleable_entry(
6600 display_name,
6601 is_selected,
6602 IconPosition::End,
6603 None,
6604 move |window, cx| {
6605 window.dispatch_action(
6606 SelectPermissionGranularity {
6607 tool_call_id: tool_call_id_for_entry.0.to_string(),
6608 index,
6609 }
6610 .boxed_clone(),
6611 cx,
6612 );
6613 },
6614 );
6615 }
6616
6617 menu
6618 }))
6619 })
6620 .into_any_element()
6621 }
6622
6623 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
6624 let project_context = self
6625 .as_native_thread(cx)?
6626 .read(cx)
6627 .project_context()
6628 .read(cx);
6629
6630 let user_rules_text = if project_context.user_rules.is_empty() {
6631 None
6632 } else if project_context.user_rules.len() == 1 {
6633 let user_rules = &project_context.user_rules[0];
6634
6635 match user_rules.title.as_ref() {
6636 Some(title) => Some(format!("Using \"{title}\" user rule")),
6637 None => Some("Using user rule".into()),
6638 }
6639 } else {
6640 Some(format!(
6641 "Using {} user rules",
6642 project_context.user_rules.len()
6643 ))
6644 };
6645
6646 let first_user_rules_id = project_context
6647 .user_rules
6648 .first()
6649 .map(|user_rules| user_rules.uuid.0);
6650
6651 let rules_files = project_context
6652 .worktrees
6653 .iter()
6654 .filter_map(|worktree| worktree.rules_file.as_ref())
6655 .collect::<Vec<_>>();
6656
6657 let rules_file_text = match rules_files.as_slice() {
6658 &[] => None,
6659 &[rules_file] => Some(format!(
6660 "Using project {:?} file",
6661 rules_file.path_in_worktree
6662 )),
6663 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
6664 };
6665
6666 if user_rules_text.is_none() && rules_file_text.is_none() {
6667 return None;
6668 }
6669
6670 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
6671
6672 Some(
6673 h_flex()
6674 .px_2p5()
6675 .child(
6676 Icon::new(IconName::Attach)
6677 .size(IconSize::XSmall)
6678 .color(Color::Disabled),
6679 )
6680 .when_some(user_rules_text, |parent, user_rules_text| {
6681 parent.child(
6682 h_flex()
6683 .id("user-rules")
6684 .ml_1()
6685 .mr_1p5()
6686 .child(
6687 Label::new(user_rules_text)
6688 .size(LabelSize::XSmall)
6689 .color(Color::Muted)
6690 .truncate(),
6691 )
6692 .hover(|s| s.bg(cx.theme().colors().element_hover))
6693 .tooltip(Tooltip::text("View User Rules"))
6694 .on_click(move |_event, window, cx| {
6695 window.dispatch_action(
6696 Box::new(OpenRulesLibrary {
6697 prompt_to_select: first_user_rules_id,
6698 }),
6699 cx,
6700 )
6701 }),
6702 )
6703 })
6704 .when(has_both, |this| {
6705 this.child(
6706 Label::new("•")
6707 .size(LabelSize::XSmall)
6708 .color(Color::Disabled),
6709 )
6710 })
6711 .when_some(rules_file_text, |parent, rules_file_text| {
6712 parent.child(
6713 h_flex()
6714 .id("project-rules")
6715 .ml_1p5()
6716 .child(
6717 Label::new(rules_file_text)
6718 .size(LabelSize::XSmall)
6719 .color(Color::Muted),
6720 )
6721 .hover(|s| s.bg(cx.theme().colors().element_hover))
6722 .tooltip(Tooltip::text("View Project Rules"))
6723 .on_click(cx.listener(Self::handle_open_rules)),
6724 )
6725 })
6726 .into_any(),
6727 )
6728 }
6729
6730 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
6731 cx.theme()
6732 .colors()
6733 .element_background
6734 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
6735 }
6736
6737 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
6738 cx.theme().colors().border.opacity(0.8)
6739 }
6740
6741 fn tool_name_font_size(&self) -> Rems {
6742 rems_from_px(13.)
6743 }
6744
6745 pub(crate) fn render_thread_error(
6746 &mut self,
6747 window: &mut Window,
6748 cx: &mut Context<Self>,
6749 ) -> Option<Div> {
6750 let content = match self.thread_error.as_ref()? {
6751 ThreadError::Other { message, .. } => {
6752 self.render_any_thread_error(message.clone(), window, cx)
6753 }
6754 ThreadError::Refusal => self.render_refusal_error(cx),
6755 ThreadError::AuthenticationRequired(error) => {
6756 self.render_authentication_required_error(error.clone(), cx)
6757 }
6758 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
6759 };
6760
6761 Some(div().child(content))
6762 }
6763
6764 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
6765 let model_or_agent_name = self.current_model_name(cx);
6766 let refusal_message = format!(
6767 "{} refused to respond to this prompt. \
6768 This can happen when a model believes the prompt violates its content policy \
6769 or safety guidelines, so rephrasing it can sometimes address the issue.",
6770 model_or_agent_name
6771 );
6772
6773 Callout::new()
6774 .severity(Severity::Error)
6775 .title("Request Refused")
6776 .icon(IconName::XCircle)
6777 .description(refusal_message.clone())
6778 .actions_slot(self.create_copy_button(&refusal_message))
6779 .dismiss_action(self.dismiss_error_button(cx))
6780 }
6781
6782 fn render_authentication_required_error(
6783 &self,
6784 error: SharedString,
6785 cx: &mut Context<Self>,
6786 ) -> Callout {
6787 Callout::new()
6788 .severity(Severity::Error)
6789 .title("Authentication Required")
6790 .icon(IconName::XCircle)
6791 .description(error.clone())
6792 .actions_slot(
6793 h_flex()
6794 .gap_0p5()
6795 .child(self.authenticate_button(cx))
6796 .child(self.create_copy_button(error)),
6797 )
6798 .dismiss_action(self.dismiss_error_button(cx))
6799 }
6800
6801 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
6802 const ERROR_MESSAGE: &str =
6803 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
6804
6805 Callout::new()
6806 .severity(Severity::Error)
6807 .icon(IconName::XCircle)
6808 .title("Free Usage Exceeded")
6809 .description(ERROR_MESSAGE)
6810 .actions_slot(
6811 h_flex()
6812 .gap_0p5()
6813 .child(self.upgrade_button(cx))
6814 .child(self.create_copy_button(ERROR_MESSAGE)),
6815 )
6816 .dismiss_action(self.dismiss_error_button(cx))
6817 }
6818
6819 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6820 Button::new("upgrade", "Upgrade")
6821 .label_size(LabelSize::Small)
6822 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
6823 .on_click(cx.listener({
6824 move |this, _, _, cx| {
6825 this.clear_thread_error(cx);
6826 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
6827 }
6828 }))
6829 }
6830
6831 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6832 Button::new("authenticate", "Authenticate")
6833 .label_size(LabelSize::Small)
6834 .style(ButtonStyle::Filled)
6835 .on_click(cx.listener({
6836 move |this, _, window, cx| {
6837 let server_view = this.server_view.clone();
6838 let agent_name = this.agent_name.clone();
6839
6840 this.clear_thread_error(cx);
6841 if let Some(message) = this.in_flight_prompt.take() {
6842 this.message_editor.update(cx, |editor, cx| {
6843 editor.set_message(message, window, cx);
6844 });
6845 }
6846 let connection = this.thread.read(cx).connection().clone();
6847 window.defer(cx, |window, cx| {
6848 AcpServerView::handle_auth_required(
6849 server_view,
6850 AuthRequired::new(),
6851 agent_name,
6852 connection,
6853 window,
6854 cx,
6855 );
6856 })
6857 }
6858 }))
6859 }
6860
6861 fn current_model_name(&self, cx: &App) -> SharedString {
6862 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
6863 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
6864 // This provides better clarity about what refused the request
6865 if self.as_native_connection(cx).is_some() {
6866 self.model_selector
6867 .clone()
6868 .and_then(|selector| selector.read(cx).active_model(cx))
6869 .map(|model| model.name.clone())
6870 .unwrap_or_else(|| SharedString::from("The model"))
6871 } else {
6872 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
6873 self.agent_name.clone()
6874 }
6875 }
6876
6877 fn render_any_thread_error(
6878 &mut self,
6879 error: SharedString,
6880 window: &mut Window,
6881 cx: &mut Context<'_, Self>,
6882 ) -> Callout {
6883 let can_resume = self.thread.read(cx).can_retry(cx);
6884
6885 let markdown = if let Some(markdown) = &self.thread_error_markdown {
6886 markdown.clone()
6887 } else {
6888 let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
6889 self.thread_error_markdown = Some(markdown.clone());
6890 markdown
6891 };
6892
6893 let markdown_style =
6894 MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx);
6895 let description = self
6896 .render_markdown(markdown, markdown_style)
6897 .into_any_element();
6898
6899 Callout::new()
6900 .severity(Severity::Error)
6901 .icon(IconName::XCircle)
6902 .title("An Error Happened")
6903 .description_slot(description)
6904 .actions_slot(
6905 h_flex()
6906 .gap_0p5()
6907 .when(can_resume, |this| {
6908 this.child(
6909 IconButton::new("retry", IconName::RotateCw)
6910 .icon_size(IconSize::Small)
6911 .tooltip(Tooltip::text("Retry Generation"))
6912 .on_click(cx.listener(|this, _, _window, cx| {
6913 this.retry_generation(cx);
6914 })),
6915 )
6916 })
6917 .child(self.create_copy_button(error.to_string())),
6918 )
6919 .dismiss_action(self.dismiss_error_button(cx))
6920 }
6921
6922 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
6923 let workspace = self.workspace.clone();
6924 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
6925 open_link(text, &workspace, window, cx);
6926 })
6927 }
6928
6929 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
6930 let message = message.into();
6931
6932 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
6933 }
6934
6935 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6936 IconButton::new("dismiss", IconName::Close)
6937 .icon_size(IconSize::Small)
6938 .tooltip(Tooltip::text("Dismiss"))
6939 .on_click(cx.listener({
6940 move |this, _, _, cx| {
6941 this.clear_thread_error(cx);
6942 cx.notify();
6943 }
6944 }))
6945 }
6946
6947 fn render_resume_notice(_cx: &Context<Self>) -> AnyElement {
6948 let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off.";
6949
6950 div()
6951 .px_2()
6952 .pt_2()
6953 .pb_3()
6954 .w_full()
6955 .child(
6956 Callout::new()
6957 .severity(Severity::Info)
6958 .icon(IconName::Info)
6959 .title("Resumed Session")
6960 .description(description),
6961 )
6962 .into_any_element()
6963 }
6964
6965 fn update_recent_history_from_cache(
6966 &mut self,
6967 history: &Entity<AcpThreadHistory>,
6968 cx: &mut Context<Self>,
6969 ) {
6970 self.recent_history_entries = history.read(cx).get_recent_sessions(3);
6971 self.hovered_recent_history_item = None;
6972 cx.notify();
6973 }
6974
6975 fn render_empty_state_section_header(
6976 &self,
6977 label: impl Into<SharedString>,
6978 action_slot: Option<AnyElement>,
6979 cx: &mut Context<Self>,
6980 ) -> impl IntoElement {
6981 div().pl_1().pr_1p5().child(
6982 h_flex()
6983 .mt_2()
6984 .pl_1p5()
6985 .pb_1()
6986 .w_full()
6987 .justify_between()
6988 .border_b_1()
6989 .border_color(cx.theme().colors().border_variant)
6990 .child(
6991 Label::new(label.into())
6992 .size(LabelSize::Small)
6993 .color(Color::Muted),
6994 )
6995 .children(action_slot),
6996 )
6997 }
6998
6999 fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
7000 let render_history = !self.recent_history_entries.is_empty();
7001
7002 v_flex()
7003 .size_full()
7004 .when(render_history, |this| {
7005 let recent_history = self.recent_history_entries.clone();
7006 this.justify_end().child(
7007 v_flex()
7008 .child(
7009 self.render_empty_state_section_header(
7010 "Recent",
7011 Some(
7012 Button::new("view-history", "View All")
7013 .style(ButtonStyle::Subtle)
7014 .label_size(LabelSize::Small)
7015 .key_binding(
7016 KeyBinding::for_action_in(
7017 &OpenHistory,
7018 &self.focus_handle(cx),
7019 cx,
7020 )
7021 .map(|kb| kb.size(rems_from_px(12.))),
7022 )
7023 .on_click(move |_event, window, cx| {
7024 window.dispatch_action(OpenHistory.boxed_clone(), cx);
7025 })
7026 .into_any_element(),
7027 ),
7028 cx,
7029 ),
7030 )
7031 .child(v_flex().p_1().pr_1p5().gap_1().children({
7032 let supports_delete = self.history.read(cx).supports_delete();
7033 recent_history
7034 .into_iter()
7035 .enumerate()
7036 .map(move |(index, entry)| {
7037 // TODO: Add keyboard navigation.
7038 let is_hovered =
7039 self.hovered_recent_history_item == Some(index);
7040 crate::acp::thread_history::AcpHistoryEntryElement::new(
7041 entry,
7042 self.server_view.clone(),
7043 )
7044 .hovered(is_hovered)
7045 .supports_delete(supports_delete)
7046 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
7047 if *is_hovered {
7048 this.hovered_recent_history_item = Some(index);
7049 } else if this.hovered_recent_history_item == Some(index) {
7050 this.hovered_recent_history_item = None;
7051 }
7052 cx.notify();
7053 }))
7054 .into_any_element()
7055 })
7056 })),
7057 )
7058 })
7059 .into_any()
7060 }
7061
7062 fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
7063 Callout::new()
7064 .icon(IconName::Warning)
7065 .severity(Severity::Warning)
7066 .title("Codex on Windows")
7067 .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
7068 .actions_slot(
7069 Button::new("open-wsl-modal", "Open in WSL")
7070 .icon_size(IconSize::Small)
7071 .icon_color(Color::Muted)
7072 .on_click(cx.listener({
7073 move |_, _, _window, cx| {
7074 #[cfg(windows)]
7075 _window.dispatch_action(
7076 zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
7077 cx,
7078 );
7079 cx.notify();
7080 }
7081 })),
7082 )
7083 .dismiss_action(
7084 IconButton::new("dismiss", IconName::Close)
7085 .icon_size(IconSize::Small)
7086 .icon_color(Color::Muted)
7087 .tooltip(Tooltip::text("Dismiss Warning"))
7088 .on_click(cx.listener({
7089 move |this, _, _, cx| {
7090 this.show_codex_windows_warning = false;
7091 cx.notify();
7092 }
7093 })),
7094 )
7095 }
7096
7097 fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
7098 let server_view = self.server_view.clone();
7099 v_flex().w_full().justify_end().child(
7100 h_flex()
7101 .p_2()
7102 .pr_3()
7103 .w_full()
7104 .gap_1p5()
7105 .border_t_1()
7106 .border_color(cx.theme().colors().border)
7107 .bg(cx.theme().colors().element_background)
7108 .child(
7109 h_flex()
7110 .flex_1()
7111 .gap_1p5()
7112 .child(
7113 Icon::new(IconName::Download)
7114 .color(Color::Accent)
7115 .size(IconSize::Small),
7116 )
7117 .child(Label::new("New version available").size(LabelSize::Small)),
7118 )
7119 .child(
7120 Button::new("update-button", format!("Update to v{}", version))
7121 .label_size(LabelSize::Small)
7122 .style(ButtonStyle::Tinted(TintColor::Accent))
7123 .on_click(move |_, window, cx| {
7124 server_view
7125 .update(cx, |view, cx| view.reset(window, cx))
7126 .ok();
7127 }),
7128 ),
7129 )
7130 }
7131
7132 fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
7133 if self.token_limit_callout_dismissed {
7134 return None;
7135 }
7136
7137 let token_usage = self.thread.read(cx).token_usage()?;
7138 let ratio = token_usage.ratio();
7139
7140 let (severity, icon, title) = match ratio {
7141 acp_thread::TokenUsageRatio::Normal => return None,
7142 acp_thread::TokenUsageRatio::Warning => (
7143 Severity::Warning,
7144 IconName::Warning,
7145 "Thread reaching the token limit soon",
7146 ),
7147 acp_thread::TokenUsageRatio::Exceeded => (
7148 Severity::Error,
7149 IconName::XCircle,
7150 "Thread reached the token limit",
7151 ),
7152 };
7153
7154 let description = "To continue, start a new thread from a summary.";
7155
7156 Some(
7157 Callout::new()
7158 .severity(severity)
7159 .icon(icon)
7160 .title(title)
7161 .description(description)
7162 .actions_slot(
7163 h_flex().gap_0p5().child(
7164 Button::new("start-new-thread", "Start New Thread")
7165 .label_size(LabelSize::Small)
7166 .on_click(cx.listener(|this, _, window, cx| {
7167 let session_id = this.thread.read(cx).session_id().clone();
7168 window.dispatch_action(
7169 crate::NewNativeAgentThreadFromSummary {
7170 from_session_id: session_id,
7171 }
7172 .boxed_clone(),
7173 cx,
7174 );
7175 })),
7176 ),
7177 )
7178 .dismiss_action(self.dismiss_error_button(cx)),
7179 )
7180 }
7181
7182 fn open_permission_dropdown(
7183 &mut self,
7184 _: &crate::OpenPermissionDropdown,
7185 window: &mut Window,
7186 cx: &mut Context<Self>,
7187 ) {
7188 self.permission_dropdown_handle.clone().toggle(window, cx);
7189 }
7190
7191 fn open_add_context_menu(
7192 &mut self,
7193 _action: &OpenAddContextMenu,
7194 window: &mut Window,
7195 cx: &mut Context<Self>,
7196 ) {
7197 let menu_handle = self.add_context_menu_handle.clone();
7198 window.defer(cx, move |window, cx| {
7199 menu_handle.toggle(window, cx);
7200 });
7201 }
7202
7203 fn cycle_thinking_effort(&mut self, cx: &mut Context<Self>) {
7204 let Some(thread) = self.as_native_thread(cx) else {
7205 return;
7206 };
7207
7208 let (effort_levels, current_effort) = {
7209 let thread_ref = thread.read(cx);
7210 let Some(model) = thread_ref.model() else {
7211 return;
7212 };
7213 if !model.supports_thinking() || !thread_ref.thinking_enabled() {
7214 return;
7215 }
7216 let effort_levels = model.supported_effort_levels();
7217 if effort_levels.is_empty() {
7218 return;
7219 }
7220 let current_effort = thread_ref.thinking_effort().cloned();
7221 (effort_levels, current_effort)
7222 };
7223
7224 let current_index = current_effort.and_then(|current| {
7225 effort_levels
7226 .iter()
7227 .position(|level| level.value == current)
7228 });
7229 let next_index = match current_index {
7230 Some(index) => (index + 1) % effort_levels.len(),
7231 None => 0,
7232 };
7233 let next_effort = effort_levels[next_index].value.to_string();
7234
7235 thread.update(cx, |thread, cx| {
7236 thread.set_thinking_effort(Some(next_effort.clone()), cx);
7237
7238 let fs = thread.project().read(cx).fs().clone();
7239 update_settings_file(fs, cx, move |settings, _| {
7240 if let Some(agent) = settings.agent.as_mut()
7241 && let Some(default_model) = agent.default_model.as_mut()
7242 {
7243 default_model.effort = Some(next_effort);
7244 }
7245 });
7246 });
7247 }
7248
7249 fn toggle_thinking_effort_menu(
7250 &mut self,
7251 _action: &ToggleThinkingEffortMenu,
7252 window: &mut Window,
7253 cx: &mut Context<Self>,
7254 ) {
7255 let menu_handle = self.thinking_effort_menu_handle.clone();
7256 window.defer(cx, move |window, cx| {
7257 menu_handle.toggle(window, cx);
7258 });
7259 }
7260}
7261
7262impl Render for AcpThreadView {
7263 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
7264 let has_messages = self.list_state.item_count() > 0;
7265
7266 let conversation = v_flex().flex_1().map(|this| {
7267 let this = this.when(self.resumed_without_history, |this| {
7268 this.child(Self::render_resume_notice(cx))
7269 });
7270 if has_messages {
7271 let list_state = self.list_state.clone();
7272 this.child(self.render_entries(cx))
7273 .vertical_scrollbar_for(&list_state, window, cx)
7274 .into_any()
7275 } else {
7276 this.child(self.render_recent_history(cx)).into_any()
7277 }
7278 });
7279
7280 v_flex()
7281 .key_context("AcpThread")
7282 .track_focus(&self.focus_handle)
7283 .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
7284 if this.parent_id.is_none() {
7285 this.cancel_generation(cx);
7286 }
7287 }))
7288 .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
7289 if let Some(parent_session_id) = this.parent_id.clone() {
7290 this.server_view
7291 .update(cx, |view, cx| {
7292 view.navigate_to_session(parent_session_id, window, cx);
7293 })
7294 .ok();
7295 }
7296 }))
7297 .on_action(cx.listener(Self::keep_all))
7298 .on_action(cx.listener(Self::reject_all))
7299 .on_action(cx.listener(Self::allow_always))
7300 .on_action(cx.listener(Self::allow_once))
7301 .on_action(cx.listener(Self::reject_once))
7302 .on_action(cx.listener(Self::handle_authorize_tool_call))
7303 .on_action(cx.listener(Self::handle_select_permission_granularity))
7304 .on_action(cx.listener(Self::open_permission_dropdown))
7305 .on_action(cx.listener(Self::open_add_context_menu))
7306 .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
7307 if let Some(thread) = this.as_native_thread(cx) {
7308 thread.update(cx, |thread, cx| {
7309 thread.set_thinking_enabled(!thread.thinking_enabled(), cx);
7310 });
7311 }
7312 }))
7313 .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
7314 this.cycle_thinking_effort(cx);
7315 }))
7316 .on_action(cx.listener(Self::toggle_thinking_effort_menu))
7317 .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
7318 this.send_queued_message_at_index(0, true, window, cx);
7319 }))
7320 .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
7321 this.remove_from_queue(0, cx);
7322 cx.notify();
7323 }))
7324 .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
7325 if let Some(editor) = this.queued_message_editors.first() {
7326 window.focus(&editor.focus_handle(cx), cx);
7327 }
7328 }))
7329 .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
7330 this.local_queued_messages.clear();
7331 this.sync_queue_flag_to_native_thread(cx);
7332 this.can_fast_track_queue = false;
7333 cx.notify();
7334 }))
7335 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
7336 if let Some(config_options_view) = this.config_options_view.clone() {
7337 let handled = config_options_view.update(cx, |view, cx| {
7338 view.toggle_category_picker(
7339 acp::SessionConfigOptionCategory::Mode,
7340 window,
7341 cx,
7342 )
7343 });
7344 if handled {
7345 return;
7346 }
7347 }
7348
7349 if let Some(profile_selector) = this.profile_selector.clone() {
7350 profile_selector.read(cx).menu_handle().toggle(window, cx);
7351 } else if let Some(mode_selector) = this.mode_selector.clone() {
7352 mode_selector.read(cx).menu_handle().toggle(window, cx);
7353 }
7354 }))
7355 .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
7356 if let Some(config_options_view) = this.config_options_view.clone() {
7357 let handled = config_options_view.update(cx, |view, cx| {
7358 view.cycle_category_option(
7359 acp::SessionConfigOptionCategory::Mode,
7360 false,
7361 cx,
7362 )
7363 });
7364 if handled {
7365 return;
7366 }
7367 }
7368
7369 if let Some(profile_selector) = this.profile_selector.clone() {
7370 profile_selector.update(cx, |profile_selector, cx| {
7371 profile_selector.cycle_profile(cx);
7372 });
7373 } else if let Some(mode_selector) = this.mode_selector.clone() {
7374 mode_selector.update(cx, |mode_selector, cx| {
7375 mode_selector.cycle_mode(window, cx);
7376 });
7377 }
7378 }))
7379 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
7380 if let Some(config_options_view) = this.config_options_view.clone() {
7381 let handled = config_options_view.update(cx, |view, cx| {
7382 view.toggle_category_picker(
7383 acp::SessionConfigOptionCategory::Model,
7384 window,
7385 cx,
7386 )
7387 });
7388 if handled {
7389 return;
7390 }
7391 }
7392
7393 if let Some(model_selector) = this.model_selector.clone() {
7394 model_selector
7395 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
7396 }
7397 }))
7398 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
7399 if let Some(config_options_view) = this.config_options_view.clone() {
7400 let handled = config_options_view.update(cx, |view, cx| {
7401 view.cycle_category_option(
7402 acp::SessionConfigOptionCategory::Model,
7403 true,
7404 cx,
7405 )
7406 });
7407 if handled {
7408 return;
7409 }
7410 }
7411
7412 if let Some(model_selector) = this.model_selector.clone() {
7413 model_selector.update(cx, |model_selector, cx| {
7414 model_selector.cycle_favorite_models(window, cx);
7415 });
7416 }
7417 }))
7418 .size_full()
7419 .children(self.render_subagent_titlebar(cx))
7420 .child(conversation)
7421 .children(self.render_activity_bar(window, cx))
7422 .when(self.show_codex_windows_warning, |this| {
7423 this.child(self.render_codex_windows_warning(cx))
7424 })
7425 .children(self.render_thread_retry_status_callout())
7426 .children(self.render_thread_error(window, cx))
7427 .when_some(
7428 match has_messages {
7429 true => None,
7430 false => self.new_server_version_available.clone(),
7431 },
7432 |this, version| this.child(self.render_new_version_callout(&version, cx)),
7433 )
7434 .children(self.render_token_limit_callout(cx))
7435 .child(self.render_message_editor(window, cx))
7436 }
7437}
7438
7439pub(crate) fn open_link(
7440 url: SharedString,
7441 workspace: &WeakEntity<Workspace>,
7442 window: &mut Window,
7443 cx: &mut App,
7444) {
7445 let Some(workspace) = workspace.upgrade() else {
7446 cx.open_url(&url);
7447 return;
7448 };
7449
7450 if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() {
7451 workspace.update(cx, |workspace, cx| match mention {
7452 MentionUri::File { abs_path } => {
7453 let project = workspace.project();
7454 let Some(path) =
7455 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
7456 else {
7457 return;
7458 };
7459
7460 workspace
7461 .open_path(path, None, true, window, cx)
7462 .detach_and_log_err(cx);
7463 }
7464 MentionUri::PastedImage => {}
7465 MentionUri::Directory { abs_path } => {
7466 let project = workspace.project();
7467 let Some(entry_id) = project.update(cx, |project, cx| {
7468 let path = project.find_project_path(abs_path, cx)?;
7469 project.entry_for_path(&path, cx).map(|entry| entry.id)
7470 }) else {
7471 return;
7472 };
7473
7474 project.update(cx, |_, cx| {
7475 cx.emit(project::Event::RevealInProjectPanel(entry_id));
7476 });
7477 }
7478 MentionUri::Symbol {
7479 abs_path: path,
7480 line_range,
7481 ..
7482 }
7483 | MentionUri::Selection {
7484 abs_path: Some(path),
7485 line_range,
7486 } => {
7487 let project = workspace.project();
7488 let Some(path) =
7489 project.update(cx, |project, cx| project.find_project_path(path, cx))
7490 else {
7491 return;
7492 };
7493
7494 let item = workspace.open_path(path, None, true, window, cx);
7495 window
7496 .spawn(cx, async move |cx| {
7497 let Some(editor) = item.await?.downcast::<Editor>() else {
7498 return Ok(());
7499 };
7500 let range =
7501 Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
7502 editor
7503 .update_in(cx, |editor, window, cx| {
7504 editor.change_selections(
7505 SelectionEffects::scroll(Autoscroll::center()),
7506 window,
7507 cx,
7508 |s| s.select_ranges(vec![range]),
7509 );
7510 })
7511 .ok();
7512 anyhow::Ok(())
7513 })
7514 .detach_and_log_err(cx);
7515 }
7516 MentionUri::Selection { abs_path: None, .. } => {}
7517 MentionUri::Thread { id, name } => {
7518 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
7519 panel.update(cx, |panel, cx| {
7520 panel.open_thread(
7521 AgentSessionInfo {
7522 session_id: id,
7523 cwd: None,
7524 title: Some(name.into()),
7525 updated_at: None,
7526 meta: None,
7527 },
7528 window,
7529 cx,
7530 )
7531 });
7532 }
7533 }
7534 MentionUri::TextThread { path, .. } => {
7535 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
7536 panel.update(cx, |panel, cx| {
7537 panel
7538 .open_saved_text_thread(path.as_path().into(), window, cx)
7539 .detach_and_log_err(cx);
7540 });
7541 }
7542 }
7543 MentionUri::Rule { id, .. } => {
7544 let PromptId::User { uuid } = id else {
7545 return;
7546 };
7547 window.dispatch_action(
7548 Box::new(OpenRulesLibrary {
7549 prompt_to_select: Some(uuid.0),
7550 }),
7551 cx,
7552 )
7553 }
7554 MentionUri::Fetch { url } => {
7555 cx.open_url(url.as_str());
7556 }
7557 MentionUri::Diagnostics { .. } => {}
7558 MentionUri::TerminalSelection { .. } => {}
7559 })
7560 } else {
7561 cx.open_url(&url);
7562 }
7563}