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