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