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