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