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