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