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