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