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