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