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