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