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