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