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