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