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