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