1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
3 AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent,
4 ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::ActionLog;
8use agent_client_protocol::{self as acp, PromptCapabilities};
9use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
10use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
11use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
12use anyhow::{Context as _, Result, anyhow, bail};
13use audio::{Audio, Sound};
14use buffer_diff::BufferDiff;
15use client::zed_urls;
16use collections::{HashMap, HashSet};
17use editor::scroll::Autoscroll;
18use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects};
19use file_icons::FileIcons;
20use fs::Fs;
21use futures::FutureExt as _;
22use gpui::{
23 Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
24 CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
25 ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
26 Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
27 WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*,
28 pulsating_between,
29};
30use language::Buffer;
31
32use language_model::LanguageModelRegistry;
33use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
34use project::{Project, ProjectEntryId};
35use prompt_store::{PromptId, PromptStore};
36use rope::Point;
37use settings::{Settings as _, SettingsStore};
38use std::cell::{Cell, RefCell};
39use std::path::Path;
40use std::sync::Arc;
41use std::time::Instant;
42use std::{collections::BTreeMap, rc::Rc, time::Duration};
43use task::SpawnInTerminal;
44use terminal_view::terminal_panel::TerminalPanel;
45use text::Anchor;
46use theme::ThemeSettings;
47use ui::{
48 Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
49 PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
50};
51use util::{ResultExt, size::format_file_size, time::duration_alt_display};
52use workspace::{CollaboratorId, Workspace};
53use zed_actions::agent::{Chat, ToggleModelSelector};
54use zed_actions::assistant::OpenRulesLibrary;
55
56use super::entry_view_state::EntryViewState;
57use crate::acp::AcpModelSelectorPopover;
58use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
59use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
60use crate::agent_diff::AgentDiff;
61use crate::profile_selector::{ProfileProvider, ProfileSelector};
62
63use crate::ui::preview::UsageCallout;
64use crate::ui::{
65 AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
66};
67use crate::{
68 AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
69 KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
70};
71
72pub const MIN_EDITOR_LINES: usize = 4;
73pub const MAX_EDITOR_LINES: usize = 8;
74
75#[derive(Copy, Clone, Debug, PartialEq, Eq)]
76enum ThreadFeedback {
77 Positive,
78 Negative,
79}
80
81#[derive(Debug)]
82enum ThreadError {
83 PaymentRequired,
84 ModelRequestLimitReached(cloud_llm_client::Plan),
85 ToolUseLimitReached,
86 Refusal,
87 AuthenticationRequired(SharedString),
88 Other(SharedString),
89}
90
91impl ThreadError {
92 fn from_err(error: anyhow::Error, agent: &Rc<dyn AgentServer>) -> Self {
93 if error.is::<language_model::PaymentRequiredError>() {
94 Self::PaymentRequired
95 } else if error.is::<language_model::ToolUseLimitReachedError>() {
96 Self::ToolUseLimitReached
97 } else if let Some(error) =
98 error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
99 {
100 Self::ModelRequestLimitReached(error.plan)
101 } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
102 && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code
103 {
104 Self::AuthenticationRequired(acp_error.message.clone().into())
105 } else {
106 let string = error.to_string();
107 // TODO: we should have Gemini return better errors here.
108 if agent.clone().downcast::<agent_servers::Gemini>().is_some()
109 && string.contains("Could not load the default credentials")
110 || string.contains("API key not valid")
111 || string.contains("Request had invalid authentication credentials")
112 {
113 Self::AuthenticationRequired(string.into())
114 } else {
115 Self::Other(error.to_string().into())
116 }
117 }
118 }
119}
120
121impl ProfileProvider for Entity<agent2::Thread> {
122 fn profile_id(&self, cx: &App) -> AgentProfileId {
123 self.read(cx).profile().clone()
124 }
125
126 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
127 self.update(cx, |thread, _cx| {
128 thread.set_profile(profile_id);
129 });
130 }
131
132 fn profiles_supported(&self, cx: &App) -> bool {
133 self.read(cx)
134 .model()
135 .is_some_and(|model| model.supports_tools())
136 }
137}
138
139#[derive(Default)]
140struct ThreadFeedbackState {
141 feedback: Option<ThreadFeedback>,
142 comments_editor: Option<Entity<Editor>>,
143}
144
145impl ThreadFeedbackState {
146 pub fn submit(
147 &mut self,
148 thread: Entity<AcpThread>,
149 feedback: ThreadFeedback,
150 window: &mut Window,
151 cx: &mut App,
152 ) {
153 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
154 return;
155 };
156
157 if self.feedback == Some(feedback) {
158 return;
159 }
160
161 self.feedback = Some(feedback);
162 match feedback {
163 ThreadFeedback::Positive => {
164 self.comments_editor = None;
165 }
166 ThreadFeedback::Negative => {
167 self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx));
168 }
169 }
170 let session_id = thread.read(cx).session_id().clone();
171 let agent_name = telemetry.agent_name();
172 let task = telemetry.thread_data(&session_id, cx);
173 let rating = match feedback {
174 ThreadFeedback::Positive => "positive",
175 ThreadFeedback::Negative => "negative",
176 };
177 cx.background_spawn(async move {
178 let thread = task.await?;
179 telemetry::event!(
180 "Agent Thread Rated",
181 session_id = session_id,
182 rating = rating,
183 agent = agent_name,
184 thread = thread
185 );
186 anyhow::Ok(())
187 })
188 .detach_and_log_err(cx);
189 }
190
191 pub fn submit_comments(&mut self, thread: Entity<AcpThread>, cx: &mut App) {
192 let Some(telemetry) = thread.read(cx).connection().telemetry() else {
193 return;
194 };
195
196 let Some(comments) = self
197 .comments_editor
198 .as_ref()
199 .map(|editor| editor.read(cx).text(cx))
200 .filter(|text| !text.trim().is_empty())
201 else {
202 return;
203 };
204
205 self.comments_editor.take();
206
207 let session_id = thread.read(cx).session_id().clone();
208 let agent_name = telemetry.agent_name();
209 let task = telemetry.thread_data(&session_id, cx);
210 cx.background_spawn(async move {
211 let thread = task.await?;
212 telemetry::event!(
213 "Agent Thread Feedback Comments",
214 session_id = session_id,
215 comments = comments,
216 agent = agent_name,
217 thread = thread
218 );
219 anyhow::Ok(())
220 })
221 .detach_and_log_err(cx);
222 }
223
224 pub fn clear(&mut self) {
225 *self = Self::default()
226 }
227
228 pub fn dismiss_comments(&mut self) {
229 self.comments_editor.take();
230 }
231
232 fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity<Editor> {
233 let buffer = cx.new(|cx| {
234 let empty_string = String::new();
235 MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx)
236 });
237
238 let editor = cx.new(|cx| {
239 let mut editor = Editor::new(
240 editor::EditorMode::AutoHeight {
241 min_lines: 1,
242 max_lines: Some(4),
243 },
244 buffer,
245 None,
246 window,
247 cx,
248 );
249 editor.set_placeholder_text(
250 "What went wrong? Share your feedback so we can improve.",
251 cx,
252 );
253 editor
254 });
255
256 editor.read(cx).focus_handle(cx).focus(window);
257 editor
258 }
259}
260
261pub struct AcpThreadView {
262 agent: Rc<dyn AgentServer>,
263 workspace: WeakEntity<Workspace>,
264 project: Entity<Project>,
265 thread_state: ThreadState,
266 history_store: Entity<HistoryStore>,
267 hovered_recent_history_item: Option<usize>,
268 entry_view_state: Entity<EntryViewState>,
269 message_editor: Entity<MessageEditor>,
270 focus_handle: FocusHandle,
271 model_selector: Option<Entity<AcpModelSelectorPopover>>,
272 profile_selector: Option<Entity<ProfileSelector>>,
273 notifications: Vec<WindowHandle<AgentNotification>>,
274 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
275 thread_retry_status: Option<RetryStatus>,
276 thread_error: Option<ThreadError>,
277 thread_feedback: ThreadFeedbackState,
278 list_state: ListState,
279 scrollbar_state: ScrollbarState,
280 auth_task: Option<Task<()>>,
281 expanded_tool_calls: HashSet<acp::ToolCallId>,
282 expanded_thinking_blocks: HashSet<(usize, usize)>,
283 edits_expanded: bool,
284 plan_expanded: bool,
285 editor_expanded: bool,
286 should_be_following: bool,
287 editing_message: Option<usize>,
288 prompt_capabilities: Rc<Cell<PromptCapabilities>>,
289 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
290 is_loading_contents: bool,
291 _cancel_task: Option<Task<()>>,
292 _subscriptions: [Subscription; 3],
293}
294
295enum ThreadState {
296 Loading(Entity<LoadingView>),
297 Ready {
298 thread: Entity<AcpThread>,
299 title_editor: Option<Entity<Editor>>,
300 _subscriptions: Vec<Subscription>,
301 },
302 LoadError(LoadError),
303 Unauthenticated {
304 connection: Rc<dyn AgentConnection>,
305 description: Option<Entity<Markdown>>,
306 configuration_view: Option<AnyView>,
307 pending_auth_method: Option<acp::AuthMethodId>,
308 _subscription: Option<Subscription>,
309 },
310}
311
312struct LoadingView {
313 title: SharedString,
314 _load_task: Task<()>,
315 _update_title_task: Task<anyhow::Result<()>>,
316}
317
318impl AcpThreadView {
319 pub fn new(
320 agent: Rc<dyn AgentServer>,
321 resume_thread: Option<DbThreadMetadata>,
322 summarize_thread: Option<DbThreadMetadata>,
323 workspace: WeakEntity<Workspace>,
324 project: Entity<Project>,
325 history_store: Entity<HistoryStore>,
326 prompt_store: Option<Entity<PromptStore>>,
327 window: &mut Window,
328 cx: &mut Context<Self>,
329 ) -> Self {
330 let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
331 let available_commands = Rc::new(RefCell::new(vec![]));
332
333 let placeholder = if agent.name() == "Zed Agent" {
334 format!("Message the {} — @ to include context", agent.name())
335 } else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() {
336 format!(
337 "Message {} — @ to include context, / for commands",
338 agent.name()
339 )
340 } else {
341 format!("Message {} — @ to include context", agent.name())
342 };
343
344 let message_editor = cx.new(|cx| {
345 let mut editor = MessageEditor::new(
346 workspace.clone(),
347 project.clone(),
348 history_store.clone(),
349 prompt_store.clone(),
350 prompt_capabilities.clone(),
351 available_commands.clone(),
352 agent.name(),
353 placeholder,
354 editor::EditorMode::AutoHeight {
355 min_lines: MIN_EDITOR_LINES,
356 max_lines: Some(MAX_EDITOR_LINES),
357 },
358 window,
359 cx,
360 );
361 if let Some(entry) = summarize_thread {
362 editor.insert_thread_summary(entry, window, cx);
363 }
364 editor
365 });
366
367 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
368
369 let entry_view_state = cx.new(|_| {
370 EntryViewState::new(
371 workspace.clone(),
372 project.clone(),
373 history_store.clone(),
374 prompt_store.clone(),
375 prompt_capabilities.clone(),
376 available_commands.clone(),
377 agent.name(),
378 )
379 });
380
381 let subscriptions = [
382 cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
383 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
384 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
385 ];
386
387 Self {
388 agent: agent.clone(),
389 workspace: workspace.clone(),
390 project: project.clone(),
391 entry_view_state,
392 thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx),
393 message_editor,
394 model_selector: None,
395 profile_selector: None,
396 notifications: Vec::new(),
397 notification_subscriptions: HashMap::default(),
398 list_state: list_state.clone(),
399 scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
400 thread_retry_status: None,
401 thread_error: None,
402 thread_feedback: Default::default(),
403 auth_task: None,
404 expanded_tool_calls: HashSet::default(),
405 expanded_thinking_blocks: HashSet::default(),
406 editing_message: None,
407 edits_expanded: false,
408 plan_expanded: false,
409 prompt_capabilities,
410 available_commands,
411 editor_expanded: false,
412 should_be_following: false,
413 history_store,
414 hovered_recent_history_item: None,
415 is_loading_contents: false,
416 _subscriptions: subscriptions,
417 _cancel_task: None,
418 focus_handle: cx.focus_handle(),
419 }
420 }
421
422 fn initial_state(
423 agent: Rc<dyn AgentServer>,
424 resume_thread: Option<DbThreadMetadata>,
425 workspace: WeakEntity<Workspace>,
426 project: Entity<Project>,
427 window: &mut Window,
428 cx: &mut Context<Self>,
429 ) -> ThreadState {
430 if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
431 return ThreadState::LoadError(LoadError::Other(
432 "External agents are not yet supported for remote projects.".into(),
433 ));
434 }
435 let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
436 // Pick the first non-single-file worktree for the root directory if there are any,
437 // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
438 worktrees.sort_by(|l, r| {
439 l.read(cx)
440 .is_single_file()
441 .cmp(&r.read(cx).is_single_file())
442 });
443 let root_dir = worktrees
444 .into_iter()
445 .filter_map(|worktree| {
446 if worktree.read(cx).is_single_file() {
447 Some(worktree.read(cx).abs_path().parent()?.into())
448 } else {
449 Some(worktree.read(cx).abs_path())
450 }
451 })
452 .next()
453 .unwrap_or_else(|| paths::home_dir().as_path().into());
454 let (tx, mut rx) = watch::channel("Loading…".into());
455 let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
456
457 let connect_task = agent.connect(&root_dir, delegate, cx);
458 let load_task = cx.spawn_in(window, async move |this, cx| {
459 let connection = match connect_task.await {
460 Ok(connection) => connection,
461 Err(err) => {
462 this.update_in(cx, |this, window, cx| {
463 if err.downcast_ref::<LoadError>().is_some() {
464 this.handle_load_error(err, window, cx);
465 } else {
466 this.handle_thread_error(err, cx);
467 }
468 cx.notify();
469 })
470 .log_err();
471 return;
472 }
473 };
474
475 let result = if let Some(native_agent) = connection
476 .clone()
477 .downcast::<agent2::NativeAgentConnection>()
478 && let Some(resume) = resume_thread.clone()
479 {
480 cx.update(|_, cx| {
481 native_agent
482 .0
483 .update(cx, |agent, cx| agent.open_thread(resume.id, cx))
484 })
485 .log_err()
486 } else {
487 cx.update(|_, cx| {
488 connection
489 .clone()
490 .new_thread(project.clone(), &root_dir, cx)
491 })
492 .log_err()
493 };
494
495 let Some(result) = result else {
496 return;
497 };
498
499 let result = match result.await {
500 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
501 Ok(err) => {
502 cx.update(|window, cx| {
503 Self::handle_auth_required(this, err, agent, connection, window, cx)
504 })
505 .log_err();
506 return;
507 }
508 Err(err) => Err(err),
509 },
510 Ok(thread) => Ok(thread),
511 };
512
513 this.update_in(cx, |this, window, cx| {
514 match result {
515 Ok(thread) => {
516 let action_log = thread.read(cx).action_log().clone();
517
518 let mut available_commands = thread.read(cx).available_commands();
519
520 if connection
521 .auth_methods()
522 .iter()
523 .any(|method| method.id.0.as_ref() == "claude-login")
524 {
525 available_commands.push(acp::AvailableCommand {
526 name: "login".to_owned(),
527 description: "Authenticate".to_owned(),
528 input: None,
529 });
530 available_commands.push(acp::AvailableCommand {
531 name: "logout".to_owned(),
532 description: "Authenticate".to_owned(),
533 input: None,
534 });
535 }
536 this.available_commands.replace(available_commands);
537
538 this.prompt_capabilities
539 .set(thread.read(cx).prompt_capabilities());
540
541 let count = thread.read(cx).entries().len();
542 this.entry_view_state.update(cx, |view_state, cx| {
543 for ix in 0..count {
544 view_state.sync_entry(ix, &thread, window, cx);
545 }
546 this.list_state.splice_focusable(
547 0..0,
548 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
549 );
550 });
551
552 if let Some(resume) = resume_thread {
553 this.history_store.update(cx, |history, cx| {
554 history.push_recently_opened_entry(
555 HistoryEntryId::AcpThread(resume.id),
556 cx,
557 );
558 });
559 }
560
561 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
562
563 this.model_selector =
564 thread
565 .read(cx)
566 .connection()
567 .model_selector()
568 .map(|selector| {
569 cx.new(|cx| {
570 AcpModelSelectorPopover::new(
571 thread.read(cx).session_id().clone(),
572 selector,
573 PopoverMenuHandle::default(),
574 this.focus_handle(cx),
575 window,
576 cx,
577 )
578 })
579 });
580
581 let mut subscriptions = vec![
582 cx.subscribe_in(&thread, window, Self::handle_thread_event),
583 cx.observe(&action_log, |_, _, cx| cx.notify()),
584 ];
585
586 let title_editor =
587 if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
588 let editor = cx.new(|cx| {
589 let mut editor = Editor::single_line(window, cx);
590 editor.set_text(thread.read(cx).title(), window, cx);
591 editor
592 });
593 subscriptions.push(cx.subscribe_in(
594 &editor,
595 window,
596 Self::handle_title_editor_event,
597 ));
598 Some(editor)
599 } else {
600 None
601 };
602 this.thread_state = ThreadState::Ready {
603 thread,
604 title_editor,
605 _subscriptions: subscriptions,
606 };
607 this.message_editor.focus_handle(cx).focus(window);
608
609 this.profile_selector = this.as_native_thread(cx).map(|thread| {
610 cx.new(|cx| {
611 ProfileSelector::new(
612 <dyn Fs>::global(cx),
613 Arc::new(thread.clone()),
614 this.focus_handle(cx),
615 cx,
616 )
617 })
618 });
619
620 cx.notify();
621 }
622 Err(err) => {
623 this.handle_load_error(err, window, cx);
624 }
625 };
626 })
627 .log_err();
628 });
629
630 let loading_view = cx.new(|cx| {
631 let update_title_task = cx.spawn(async move |this, cx| {
632 loop {
633 let status = rx.recv().await?;
634 this.update(cx, |this: &mut LoadingView, cx| {
635 this.title = status;
636 cx.notify();
637 })?;
638 }
639 });
640
641 LoadingView {
642 title: "Loading…".into(),
643 _load_task: load_task,
644 _update_title_task: update_title_task,
645 }
646 });
647
648 ThreadState::Loading(loading_view)
649 }
650
651 fn handle_auth_required(
652 this: WeakEntity<Self>,
653 err: AuthRequired,
654 agent: Rc<dyn AgentServer>,
655 connection: Rc<dyn AgentConnection>,
656 window: &mut Window,
657 cx: &mut App,
658 ) {
659 let agent_name = agent.name();
660 let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
661 let registry = LanguageModelRegistry::global(cx);
662
663 let sub = window.subscribe(®istry, cx, {
664 let provider_id = provider_id.clone();
665 let this = this.clone();
666 move |_, ev, window, cx| {
667 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
668 && &provider_id == updated_provider_id
669 && LanguageModelRegistry::global(cx)
670 .read(cx)
671 .provider(&provider_id)
672 .map_or(false, |provider| provider.is_authenticated(cx))
673 {
674 this.update(cx, |this, cx| {
675 this.thread_state = Self::initial_state(
676 agent.clone(),
677 None,
678 this.workspace.clone(),
679 this.project.clone(),
680 window,
681 cx,
682 );
683 cx.notify();
684 })
685 .ok();
686 }
687 }
688 });
689
690 let view = registry.read(cx).provider(&provider_id).map(|provider| {
691 provider.configuration_view(
692 language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
693 window,
694 cx,
695 )
696 });
697
698 (view, Some(sub))
699 } else {
700 (None, None)
701 };
702
703 this.update(cx, |this, cx| {
704 this.thread_state = ThreadState::Unauthenticated {
705 pending_auth_method: None,
706 connection,
707 configuration_view,
708 description: err
709 .description
710 .clone()
711 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
712 _subscription: subscription,
713 };
714 if this.message_editor.focus_handle(cx).is_focused(window) {
715 this.focus_handle.focus(window)
716 }
717 cx.notify();
718 })
719 .ok();
720 }
721
722 fn handle_load_error(
723 &mut self,
724 err: anyhow::Error,
725 window: &mut Window,
726 cx: &mut Context<Self>,
727 ) {
728 if let Some(load_err) = err.downcast_ref::<LoadError>() {
729 self.thread_state = ThreadState::LoadError(load_err.clone());
730 } else {
731 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
732 }
733 if self.message_editor.focus_handle(cx).is_focused(window) {
734 self.focus_handle.focus(window)
735 }
736 cx.notify();
737 }
738
739 pub fn workspace(&self) -> &WeakEntity<Workspace> {
740 &self.workspace
741 }
742
743 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
744 match &self.thread_state {
745 ThreadState::Ready { thread, .. } => Some(thread),
746 ThreadState::Unauthenticated { .. }
747 | ThreadState::Loading { .. }
748 | ThreadState::LoadError { .. } => None,
749 }
750 }
751
752 pub fn title(&self, cx: &App) -> SharedString {
753 match &self.thread_state {
754 ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
755 ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
756 ThreadState::LoadError(error) => match error {
757 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
758 LoadError::FailedToInstall(_) => {
759 format!("Failed to Install {}", self.agent.name()).into()
760 }
761 LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
762 LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
763 },
764 }
765 }
766
767 pub fn title_editor(&self) -> Option<Entity<Editor>> {
768 if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
769 title_editor.clone()
770 } else {
771 None
772 }
773 }
774
775 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
776 self.thread_error.take();
777 self.thread_retry_status.take();
778
779 if let Some(thread) = self.thread() {
780 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
781 }
782 }
783
784 pub fn expand_message_editor(
785 &mut self,
786 _: &ExpandMessageEditor,
787 _window: &mut Window,
788 cx: &mut Context<Self>,
789 ) {
790 self.set_editor_is_expanded(!self.editor_expanded, cx);
791 cx.notify();
792 }
793
794 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
795 self.editor_expanded = is_expanded;
796 self.message_editor.update(cx, |editor, cx| {
797 if is_expanded {
798 editor.set_mode(
799 EditorMode::Full {
800 scale_ui_elements_with_buffer_font_size: false,
801 show_active_line_background: false,
802 sized_by_content: false,
803 },
804 cx,
805 )
806 } else {
807 editor.set_mode(
808 EditorMode::AutoHeight {
809 min_lines: MIN_EDITOR_LINES,
810 max_lines: Some(MAX_EDITOR_LINES),
811 },
812 cx,
813 )
814 }
815 });
816 cx.notify();
817 }
818
819 pub fn handle_title_editor_event(
820 &mut self,
821 title_editor: &Entity<Editor>,
822 event: &EditorEvent,
823 window: &mut Window,
824 cx: &mut Context<Self>,
825 ) {
826 let Some(thread) = self.thread() else { return };
827
828 match event {
829 EditorEvent::BufferEdited => {
830 let new_title = title_editor.read(cx).text(cx);
831 thread.update(cx, |thread, cx| {
832 thread
833 .set_title(new_title.into(), cx)
834 .detach_and_log_err(cx);
835 })
836 }
837 EditorEvent::Blurred => {
838 if title_editor.read(cx).text(cx).is_empty() {
839 title_editor.update(cx, |editor, cx| {
840 editor.set_text("New Thread", window, cx);
841 });
842 }
843 }
844 _ => {}
845 }
846 }
847
848 pub fn handle_message_editor_event(
849 &mut self,
850 _: &Entity<MessageEditor>,
851 event: &MessageEditorEvent,
852 window: &mut Window,
853 cx: &mut Context<Self>,
854 ) {
855 match event {
856 MessageEditorEvent::Send => self.send(window, cx),
857 MessageEditorEvent::Cancel => self.cancel_generation(cx),
858 MessageEditorEvent::Focus => {
859 self.cancel_editing(&Default::default(), window, cx);
860 }
861 MessageEditorEvent::LostFocus => {}
862 }
863 }
864
865 pub fn handle_entry_view_event(
866 &mut self,
867 _: &Entity<EntryViewState>,
868 event: &EntryViewEvent,
869 window: &mut Window,
870 cx: &mut Context<Self>,
871 ) {
872 match &event.view_event {
873 ViewEvent::NewDiff(tool_call_id) => {
874 if AgentSettings::get_global(cx).expand_edit_card {
875 self.expanded_tool_calls.insert(tool_call_id.clone());
876 }
877 }
878 ViewEvent::NewTerminal(tool_call_id) => {
879 if AgentSettings::get_global(cx).expand_terminal_card {
880 self.expanded_tool_calls.insert(tool_call_id.clone());
881 }
882 }
883 ViewEvent::TerminalMovedToBackground(tool_call_id) => {
884 self.expanded_tool_calls.remove(tool_call_id);
885 }
886 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
887 if let Some(thread) = self.thread()
888 && let Some(AgentThreadEntry::UserMessage(user_message)) =
889 thread.read(cx).entries().get(event.entry_index)
890 && user_message.id.is_some()
891 {
892 self.editing_message = Some(event.entry_index);
893 cx.notify();
894 }
895 }
896 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
897 if let Some(thread) = self.thread()
898 && let Some(AgentThreadEntry::UserMessage(user_message)) =
899 thread.read(cx).entries().get(event.entry_index)
900 && user_message.id.is_some()
901 {
902 if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
903 self.editing_message = None;
904 cx.notify();
905 }
906 }
907 }
908 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
909 self.regenerate(event.entry_index, editor, window, cx);
910 }
911 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
912 self.cancel_editing(&Default::default(), window, cx);
913 }
914 }
915 }
916
917 fn resume_chat(&mut self, cx: &mut Context<Self>) {
918 self.thread_error.take();
919 let Some(thread) = self.thread() else {
920 return;
921 };
922 if !thread.read(cx).can_resume(cx) {
923 return;
924 }
925
926 let task = thread.update(cx, |thread, cx| thread.resume(cx));
927 cx.spawn(async move |this, cx| {
928 let result = task.await;
929
930 this.update(cx, |this, cx| {
931 if let Err(err) = result {
932 this.handle_thread_error(err, cx);
933 }
934 })
935 })
936 .detach();
937 }
938
939 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
940 let Some(thread) = self.thread() else { return };
941
942 if self.is_loading_contents {
943 return;
944 }
945
946 self.history_store.update(cx, |history, cx| {
947 history.push_recently_opened_entry(
948 HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
949 cx,
950 );
951 });
952
953 if thread.read(cx).status() != ThreadStatus::Idle {
954 self.stop_current_and_send_new_message(window, cx);
955 return;
956 }
957
958 let text = self.message_editor.read(cx).text(cx);
959 let text = text.trim();
960 if text == "/login" || text == "/logout" {
961 let ThreadState::Ready { thread, .. } = &self.thread_state else {
962 return;
963 };
964
965 let connection = thread.read(cx).connection().clone();
966 if !connection
967 .auth_methods()
968 .iter()
969 .any(|method| method.id.0.as_ref() == "claude-login")
970 {
971 return;
972 };
973 let this = cx.weak_entity();
974 let agent = self.agent.clone();
975 window.defer(cx, |window, cx| {
976 Self::handle_auth_required(
977 this,
978 AuthRequired {
979 description: None,
980 provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
981 },
982 agent,
983 connection,
984 window,
985 cx,
986 );
987 });
988 cx.notify();
989 return;
990 }
991
992 let contents = self
993 .message_editor
994 .update(cx, |message_editor, cx| message_editor.contents(cx));
995 self.send_impl(contents, window, cx)
996 }
997
998 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
999 let Some(thread) = self.thread().cloned() else {
1000 return;
1001 };
1002
1003 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
1004
1005 let contents = self
1006 .message_editor
1007 .update(cx, |message_editor, cx| message_editor.contents(cx));
1008
1009 cx.spawn_in(window, async move |this, cx| {
1010 cancelled.await;
1011
1012 this.update_in(cx, |this, window, cx| {
1013 this.send_impl(contents, window, cx);
1014 })
1015 .ok();
1016 })
1017 .detach();
1018 }
1019
1020 fn send_impl(
1021 &mut self,
1022 contents: Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
1023 window: &mut Window,
1024 cx: &mut Context<Self>,
1025 ) {
1026 let agent_telemetry_id = self.agent.telemetry_id();
1027
1028 self.thread_error.take();
1029 self.editing_message.take();
1030 self.thread_feedback.clear();
1031
1032 let Some(thread) = self.thread() else {
1033 return;
1034 };
1035 let thread = thread.downgrade();
1036 if self.should_be_following {
1037 self.workspace
1038 .update(cx, |workspace, cx| {
1039 workspace.follow(CollaboratorId::Agent, window, cx);
1040 })
1041 .ok();
1042 }
1043
1044 self.is_loading_contents = true;
1045 let guard = cx.new(|_| ());
1046 cx.observe_release(&guard, |this, _guard, cx| {
1047 this.is_loading_contents = false;
1048 cx.notify();
1049 })
1050 .detach();
1051
1052 let task = cx.spawn_in(window, async move |this, cx| {
1053 let (contents, tracked_buffers) = contents.await?;
1054
1055 if contents.is_empty() {
1056 return Ok(());
1057 }
1058
1059 this.update_in(cx, |this, window, cx| {
1060 this.set_editor_is_expanded(false, cx);
1061 this.scroll_to_bottom(cx);
1062 this.message_editor.update(cx, |message_editor, cx| {
1063 message_editor.clear(window, cx);
1064 });
1065 })?;
1066 let send = thread.update(cx, |thread, cx| {
1067 thread.action_log().update(cx, |action_log, cx| {
1068 for buffer in tracked_buffers {
1069 action_log.buffer_read(buffer, cx)
1070 }
1071 });
1072 drop(guard);
1073
1074 telemetry::event!("Agent Message Sent", agent = agent_telemetry_id);
1075
1076 thread.send(contents, cx)
1077 })?;
1078 send.await
1079 });
1080
1081 cx.spawn(async move |this, cx| {
1082 if let Err(err) = task.await {
1083 this.update(cx, |this, cx| {
1084 this.handle_thread_error(err, cx);
1085 })
1086 .ok();
1087 } else {
1088 this.update(cx, |this, cx| {
1089 this.should_be_following = this
1090 .workspace
1091 .update(cx, |workspace, _| {
1092 workspace.is_being_followed(CollaboratorId::Agent)
1093 })
1094 .unwrap_or_default();
1095 })
1096 .ok();
1097 }
1098 })
1099 .detach();
1100 }
1101
1102 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1103 let Some(thread) = self.thread().cloned() else {
1104 return;
1105 };
1106
1107 if let Some(index) = self.editing_message.take()
1108 && let Some(editor) = self
1109 .entry_view_state
1110 .read(cx)
1111 .entry(index)
1112 .and_then(|e| e.message_editor())
1113 .cloned()
1114 {
1115 editor.update(cx, |editor, cx| {
1116 if let Some(user_message) = thread
1117 .read(cx)
1118 .entries()
1119 .get(index)
1120 .and_then(|e| e.user_message())
1121 {
1122 editor.set_message(user_message.chunks.clone(), window, cx);
1123 }
1124 })
1125 };
1126 self.focus_handle(cx).focus(window);
1127 cx.notify();
1128 }
1129
1130 fn regenerate(
1131 &mut self,
1132 entry_ix: usize,
1133 message_editor: &Entity<MessageEditor>,
1134 window: &mut Window,
1135 cx: &mut Context<Self>,
1136 ) {
1137 let Some(thread) = self.thread().cloned() else {
1138 return;
1139 };
1140 if self.is_loading_contents {
1141 return;
1142 }
1143
1144 let Some(user_message_id) = thread.update(cx, |thread, _| {
1145 thread.entries().get(entry_ix)?.user_message()?.id.clone()
1146 }) else {
1147 return;
1148 };
1149
1150 let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
1151
1152 let task = cx.spawn(async move |_, cx| {
1153 let contents = contents.await?;
1154 thread
1155 .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
1156 .await?;
1157 Ok(contents)
1158 });
1159 self.send_impl(task, window, cx);
1160 }
1161
1162 fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
1163 if let Some(thread) = self.thread() {
1164 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
1165 }
1166 }
1167
1168 fn open_edited_buffer(
1169 &mut self,
1170 buffer: &Entity<Buffer>,
1171 window: &mut Window,
1172 cx: &mut Context<Self>,
1173 ) {
1174 let Some(thread) = self.thread() else {
1175 return;
1176 };
1177
1178 let Some(diff) =
1179 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
1180 else {
1181 return;
1182 };
1183
1184 diff.update(cx, |diff, cx| {
1185 diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
1186 })
1187 }
1188
1189 fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1190 let Some(thread) = self.as_native_thread(cx) else {
1191 return;
1192 };
1193 let project_context = thread.read(cx).project_context().read(cx);
1194
1195 let project_entry_ids = project_context
1196 .worktrees
1197 .iter()
1198 .flat_map(|worktree| worktree.rules_file.as_ref())
1199 .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
1200 .collect::<Vec<_>>();
1201
1202 self.workspace
1203 .update(cx, move |workspace, cx| {
1204 // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
1205 // files clear. For example, if rules file 1 is already open but rules file 2 is not,
1206 // this would open and focus rules file 2 in a tab that is not next to rules file 1.
1207 let project = workspace.project().read(cx);
1208 let project_paths = project_entry_ids
1209 .into_iter()
1210 .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
1211 .collect::<Vec<_>>();
1212 for project_path in project_paths {
1213 workspace
1214 .open_path(project_path, None, true, window, cx)
1215 .detach_and_log_err(cx);
1216 }
1217 })
1218 .ok();
1219 }
1220
1221 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
1222 self.thread_error = Some(ThreadError::from_err(error, &self.agent));
1223 cx.notify();
1224 }
1225
1226 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
1227 self.thread_error = None;
1228 cx.notify();
1229 }
1230
1231 fn handle_thread_event(
1232 &mut self,
1233 thread: &Entity<AcpThread>,
1234 event: &AcpThreadEvent,
1235 window: &mut Window,
1236 cx: &mut Context<Self>,
1237 ) {
1238 match event {
1239 AcpThreadEvent::NewEntry => {
1240 let len = thread.read(cx).entries().len();
1241 let index = len - 1;
1242 self.entry_view_state.update(cx, |view_state, cx| {
1243 view_state.sync_entry(index, thread, window, cx);
1244 self.list_state.splice_focusable(
1245 index..index,
1246 [view_state
1247 .entry(index)
1248 .and_then(|entry| entry.focus_handle(cx))],
1249 );
1250 });
1251 }
1252 AcpThreadEvent::EntryUpdated(index) => {
1253 self.entry_view_state.update(cx, |view_state, cx| {
1254 view_state.sync_entry(*index, thread, window, cx)
1255 });
1256 }
1257 AcpThreadEvent::EntriesRemoved(range) => {
1258 self.entry_view_state
1259 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
1260 self.list_state.splice(range.clone(), 0);
1261 }
1262 AcpThreadEvent::ToolAuthorizationRequired => {
1263 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1264 }
1265 AcpThreadEvent::Retry(retry) => {
1266 self.thread_retry_status = Some(retry.clone());
1267 }
1268 AcpThreadEvent::Stopped => {
1269 self.thread_retry_status.take();
1270 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1271 self.notify_with_sound(
1272 if used_tools {
1273 "Finished running tools"
1274 } else {
1275 "New message"
1276 },
1277 IconName::ZedAssistant,
1278 window,
1279 cx,
1280 );
1281 }
1282 AcpThreadEvent::Refusal => {
1283 self.thread_retry_status.take();
1284 self.thread_error = Some(ThreadError::Refusal);
1285 let model_or_agent_name = self.get_current_model_name(cx);
1286 let notification_message =
1287 format!("{} refused to respond to this request", model_or_agent_name);
1288 self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
1289 }
1290 AcpThreadEvent::Error => {
1291 self.thread_retry_status.take();
1292 self.notify_with_sound(
1293 "Agent stopped due to an error",
1294 IconName::Warning,
1295 window,
1296 cx,
1297 );
1298 }
1299 AcpThreadEvent::LoadError(error) => {
1300 self.thread_retry_status.take();
1301 self.thread_state = ThreadState::LoadError(error.clone());
1302 if self.message_editor.focus_handle(cx).is_focused(window) {
1303 self.focus_handle.focus(window)
1304 }
1305 }
1306 AcpThreadEvent::TitleUpdated => {
1307 let title = thread.read(cx).title();
1308 if let Some(title_editor) = self.title_editor() {
1309 title_editor.update(cx, |editor, cx| {
1310 if editor.text(cx) != title {
1311 editor.set_text(title, window, cx);
1312 }
1313 });
1314 }
1315 }
1316 AcpThreadEvent::PromptCapabilitiesUpdated => {
1317 self.prompt_capabilities
1318 .set(thread.read(cx).prompt_capabilities());
1319 }
1320 AcpThreadEvent::TokenUsageUpdated => {}
1321 }
1322 cx.notify();
1323 }
1324
1325 fn authenticate(
1326 &mut self,
1327 method: acp::AuthMethodId,
1328 window: &mut Window,
1329 cx: &mut Context<Self>,
1330 ) {
1331 let ThreadState::Unauthenticated {
1332 connection,
1333 pending_auth_method,
1334 configuration_view,
1335 ..
1336 } = &mut self.thread_state
1337 else {
1338 return;
1339 };
1340
1341 if method.0.as_ref() == "gemini-api-key" {
1342 let registry = LanguageModelRegistry::global(cx);
1343 let provider = registry
1344 .read(cx)
1345 .provider(&language_model::GOOGLE_PROVIDER_ID)
1346 .unwrap();
1347 if !provider.is_authenticated(cx) {
1348 let this = cx.weak_entity();
1349 let agent = self.agent.clone();
1350 let connection = connection.clone();
1351 window.defer(cx, |window, cx| {
1352 Self::handle_auth_required(
1353 this,
1354 AuthRequired {
1355 description: Some("GEMINI_API_KEY must be set".to_owned()),
1356 provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
1357 },
1358 agent,
1359 connection,
1360 window,
1361 cx,
1362 );
1363 });
1364 return;
1365 }
1366 } else if method.0.as_ref() == "anthropic-api-key" {
1367 let registry = LanguageModelRegistry::global(cx);
1368 let provider = registry
1369 .read(cx)
1370 .provider(&language_model::ANTHROPIC_PROVIDER_ID)
1371 .unwrap();
1372 let this = cx.weak_entity();
1373 let agent = self.agent.clone();
1374 let connection = connection.clone();
1375 window.defer(cx, move |window, cx| {
1376 if !provider.is_authenticated(cx) {
1377 Self::handle_auth_required(
1378 this,
1379 AuthRequired {
1380 description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
1381 provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
1382 },
1383 agent,
1384 connection,
1385 window,
1386 cx,
1387 );
1388 } else {
1389 this.update(cx, |this, cx| {
1390 this.thread_state = Self::initial_state(
1391 agent,
1392 None,
1393 this.workspace.clone(),
1394 this.project.clone(),
1395 window,
1396 cx,
1397 )
1398 })
1399 .ok();
1400 }
1401 });
1402 return;
1403 } else if method.0.as_ref() == "vertex-ai"
1404 && std::env::var("GOOGLE_API_KEY").is_err()
1405 && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
1406 || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()))
1407 {
1408 let this = cx.weak_entity();
1409 let agent = self.agent.clone();
1410 let connection = connection.clone();
1411
1412 window.defer(cx, |window, cx| {
1413 Self::handle_auth_required(
1414 this,
1415 AuthRequired {
1416 description: Some(
1417 "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed."
1418 .to_owned(),
1419 ),
1420 provider_id: None,
1421 },
1422 agent,
1423 connection,
1424 window,
1425 cx,
1426 )
1427 });
1428 return;
1429 }
1430
1431 self.thread_error.take();
1432 configuration_view.take();
1433 pending_auth_method.replace(method.clone());
1434 let authenticate = if method.0.as_ref() == "claude-login" {
1435 if let Some(workspace) = self.workspace.upgrade() {
1436 Self::spawn_claude_login(&workspace, window, cx)
1437 } else {
1438 Task::ready(Ok(()))
1439 }
1440 } else {
1441 connection.authenticate(method, cx)
1442 };
1443 cx.notify();
1444 self.auth_task =
1445 Some(cx.spawn_in(window, {
1446 let project = self.project.clone();
1447 let agent = self.agent.clone();
1448 async move |this, cx| {
1449 let result = authenticate.await;
1450
1451 match &result {
1452 Ok(_) => telemetry::event!(
1453 "Authenticate Agent Succeeded",
1454 agent = agent.telemetry_id()
1455 ),
1456 Err(_) => {
1457 telemetry::event!(
1458 "Authenticate Agent Failed",
1459 agent = agent.telemetry_id(),
1460 )
1461 }
1462 }
1463
1464 this.update_in(cx, |this, window, cx| {
1465 if let Err(err) = result {
1466 if let ThreadState::Unauthenticated {
1467 pending_auth_method,
1468 ..
1469 } = &mut this.thread_state
1470 {
1471 pending_auth_method.take();
1472 }
1473 this.handle_thread_error(err, cx);
1474 } else {
1475 this.thread_state = Self::initial_state(
1476 agent,
1477 None,
1478 this.workspace.clone(),
1479 project.clone(),
1480 window,
1481 cx,
1482 )
1483 }
1484 this.auth_task.take()
1485 })
1486 .ok();
1487 }
1488 }));
1489 }
1490
1491 fn spawn_claude_login(
1492 workspace: &Entity<Workspace>,
1493 window: &mut Window,
1494 cx: &mut App,
1495 ) -> Task<Result<()>> {
1496 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1497 return Task::ready(Ok(()));
1498 };
1499 let project_entity = workspace.read(cx).project();
1500 let project = project_entity.read(cx);
1501 let cwd = project.first_project_directory(cx);
1502 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1503
1504 let delegate = AgentServerDelegate::new(project_entity.clone(), None);
1505 let command = ClaudeCode::login_command(delegate, cx);
1506
1507 window.spawn(cx, async move |cx| {
1508 let login_command = command.await?;
1509 let command = login_command
1510 .path
1511 .to_str()
1512 .with_context(|| format!("invalid login command: {:?}", login_command.path))?;
1513 let command = shlex::try_quote(command)?;
1514 let args = login_command
1515 .arguments
1516 .iter()
1517 .map(|arg| {
1518 Ok(shlex::try_quote(arg)
1519 .context("Failed to quote argument")?
1520 .to_string())
1521 })
1522 .collect::<Result<Vec<_>>>()?;
1523
1524 let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
1525 terminal_panel.spawn_task(
1526 &SpawnInTerminal {
1527 id: task::TaskId("claude-login".into()),
1528 full_label: "claude /login".to_owned(),
1529 label: "claude /login".to_owned(),
1530 command: Some(command.into()),
1531 args,
1532 command_label: "claude /login".to_owned(),
1533 cwd,
1534 use_new_terminal: true,
1535 allow_concurrent_runs: true,
1536 hide: task::HideStrategy::Always,
1537 shell,
1538 ..Default::default()
1539 },
1540 window,
1541 cx,
1542 )
1543 })?;
1544
1545 let terminal = terminal.await?;
1546 let mut exit_status = terminal
1547 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1548 .fuse();
1549
1550 let logged_in = cx
1551 .spawn({
1552 let terminal = terminal.clone();
1553 async move |cx| {
1554 loop {
1555 cx.background_executor().timer(Duration::from_secs(1)).await;
1556 let content =
1557 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
1558 if content.contains("Login successful") {
1559 return anyhow::Ok(());
1560 }
1561 }
1562 }
1563 })
1564 .fuse();
1565 futures::pin_mut!(logged_in);
1566 futures::select_biased! {
1567 result = logged_in => {
1568 if let Err(e) = result {
1569 log::error!("{e}");
1570 return Err(anyhow!("exited before logging in"));
1571 }
1572 }
1573 _ = exit_status => {
1574 return Err(anyhow!("exited before logging in"));
1575 }
1576 }
1577 terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
1578 Ok(())
1579 })
1580 }
1581
1582 fn authorize_tool_call(
1583 &mut self,
1584 tool_call_id: acp::ToolCallId,
1585 option_id: acp::PermissionOptionId,
1586 option_kind: acp::PermissionOptionKind,
1587 window: &mut Window,
1588 cx: &mut Context<Self>,
1589 ) {
1590 let Some(thread) = self.thread() else {
1591 return;
1592 };
1593 thread.update(cx, |thread, cx| {
1594 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
1595 });
1596 if self.should_be_following {
1597 self.workspace
1598 .update(cx, |workspace, cx| {
1599 workspace.follow(CollaboratorId::Agent, window, cx);
1600 })
1601 .ok();
1602 }
1603 cx.notify();
1604 }
1605
1606 fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
1607 let Some(thread) = self.thread() else {
1608 return;
1609 };
1610 thread
1611 .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
1612 .detach_and_log_err(cx);
1613 cx.notify();
1614 }
1615
1616 fn render_entry(
1617 &self,
1618 entry_ix: usize,
1619 total_entries: usize,
1620 entry: &AgentThreadEntry,
1621 window: &mut Window,
1622 cx: &Context<Self>,
1623 ) -> AnyElement {
1624 let primary = match &entry {
1625 AgentThreadEntry::UserMessage(message) => {
1626 let Some(editor) = self
1627 .entry_view_state
1628 .read(cx)
1629 .entry(entry_ix)
1630 .and_then(|entry| entry.message_editor())
1631 .cloned()
1632 else {
1633 return Empty.into_any_element();
1634 };
1635
1636 let editing = self.editing_message == Some(entry_ix);
1637 let editor_focus = editor.focus_handle(cx).is_focused(window);
1638 let focus_border = cx.theme().colors().border_focused;
1639
1640 let rules_item = if entry_ix == 0 {
1641 self.render_rules_item(cx)
1642 } else {
1643 None
1644 };
1645
1646 let has_checkpoint_button = message
1647 .checkpoint
1648 .as_ref()
1649 .is_some_and(|checkpoint| checkpoint.show);
1650
1651 let agent_name = self.agent.name();
1652
1653 v_flex()
1654 .id(("user_message", entry_ix))
1655 .map(|this| {
1656 if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
1657 this.pt(rems_from_px(18.))
1658 } else if rules_item.is_some() {
1659 this.pt_3()
1660 } else {
1661 this.pt_2()
1662 }
1663 })
1664 .pb_3()
1665 .px_2()
1666 .gap_1p5()
1667 .w_full()
1668 .children(rules_item)
1669 .children(message.id.clone().and_then(|message_id| {
1670 message.checkpoint.as_ref()?.show.then(|| {
1671 h_flex()
1672 .px_3()
1673 .gap_2()
1674 .child(Divider::horizontal())
1675 .child(
1676 Button::new("restore-checkpoint", "Restore Checkpoint")
1677 .icon(IconName::Undo)
1678 .icon_size(IconSize::XSmall)
1679 .icon_position(IconPosition::Start)
1680 .label_size(LabelSize::XSmall)
1681 .icon_color(Color::Muted)
1682 .color(Color::Muted)
1683 .on_click(cx.listener(move |this, _, _window, cx| {
1684 this.rewind(&message_id, cx);
1685 }))
1686 )
1687 .child(Divider::horizontal())
1688 })
1689 }))
1690 .child(
1691 div()
1692 .relative()
1693 .child(
1694 div()
1695 .py_3()
1696 .px_2()
1697 .rounded_md()
1698 .shadow_md()
1699 .bg(cx.theme().colors().editor_background)
1700 .border_1()
1701 .when(editing && !editor_focus, |this| this.border_dashed())
1702 .border_color(cx.theme().colors().border)
1703 .map(|this|{
1704 if editing && editor_focus {
1705 this.border_color(focus_border)
1706 } else if message.id.is_some() {
1707 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
1708 } else {
1709 this
1710 }
1711 })
1712 .text_xs()
1713 .child(editor.clone().into_any_element()),
1714 )
1715 .when(editor_focus, |this| {
1716 let base_container = h_flex()
1717 .absolute()
1718 .top_neg_3p5()
1719 .right_3()
1720 .gap_1()
1721 .rounded_sm()
1722 .border_1()
1723 .border_color(cx.theme().colors().border)
1724 .bg(cx.theme().colors().editor_background)
1725 .overflow_hidden();
1726
1727 if message.id.is_some() {
1728 this.child(
1729 base_container
1730 .child(
1731 IconButton::new("cancel", IconName::Close)
1732 .disabled(self.is_loading_contents)
1733 .icon_color(Color::Error)
1734 .icon_size(IconSize::XSmall)
1735 .on_click(cx.listener(Self::cancel_editing))
1736 )
1737 .child(
1738 if self.is_loading_contents {
1739 div()
1740 .id("loading-edited-message-content")
1741 .tooltip(Tooltip::text("Loading Added Context…"))
1742 .child(loading_contents_spinner(IconSize::XSmall))
1743 .into_any_element()
1744 } else {
1745 IconButton::new("regenerate", IconName::Return)
1746 .icon_color(Color::Muted)
1747 .icon_size(IconSize::XSmall)
1748 .tooltip(Tooltip::text(
1749 "Editing will restart the thread from this point."
1750 ))
1751 .on_click(cx.listener({
1752 let editor = editor.clone();
1753 move |this, _, window, cx| {
1754 this.regenerate(
1755 entry_ix, &editor, window, cx,
1756 );
1757 }
1758 })).into_any_element()
1759 }
1760 )
1761 )
1762 } else {
1763 this.child(
1764 base_container
1765 .border_dashed()
1766 .child(
1767 IconButton::new("editing_unavailable", IconName::PencilUnavailable)
1768 .icon_size(IconSize::Small)
1769 .icon_color(Color::Muted)
1770 .style(ButtonStyle::Transparent)
1771 .tooltip(move |_window, cx| {
1772 cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
1773 .into()
1774 })
1775 )
1776 )
1777 }
1778 }),
1779 )
1780 .into_any()
1781 }
1782 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
1783 let is_last = entry_ix + 1 == total_entries;
1784
1785 let style = default_markdown_style(false, false, window, cx);
1786 let message_body = v_flex()
1787 .w_full()
1788 .gap_3()
1789 .children(chunks.iter().enumerate().filter_map(
1790 |(chunk_ix, chunk)| match chunk {
1791 AssistantMessageChunk::Message { block } => {
1792 block.markdown().map(|md| {
1793 self.render_markdown(md.clone(), style.clone())
1794 .into_any_element()
1795 })
1796 }
1797 AssistantMessageChunk::Thought { block } => {
1798 block.markdown().map(|md| {
1799 self.render_thinking_block(
1800 entry_ix,
1801 chunk_ix,
1802 md.clone(),
1803 window,
1804 cx,
1805 )
1806 .into_any_element()
1807 })
1808 }
1809 },
1810 ))
1811 .into_any();
1812
1813 v_flex()
1814 .px_5()
1815 .py_1p5()
1816 .when(is_last, |this| this.pb_4())
1817 .w_full()
1818 .text_ui(cx)
1819 .child(message_body)
1820 .into_any()
1821 }
1822 AgentThreadEntry::ToolCall(tool_call) => {
1823 let has_terminals = tool_call.terminals().next().is_some();
1824
1825 div().w_full().map(|this| {
1826 if has_terminals {
1827 this.children(tool_call.terminals().map(|terminal| {
1828 self.render_terminal_tool_call(
1829 entry_ix, terminal, tool_call, window, cx,
1830 )
1831 }))
1832 } else {
1833 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
1834 }
1835 })
1836 }
1837 .into_any(),
1838 };
1839
1840 let Some(thread) = self.thread() else {
1841 return primary;
1842 };
1843
1844 let primary = if entry_ix == total_entries - 1 {
1845 v_flex()
1846 .w_full()
1847 .child(primary)
1848 .child(self.render_thread_controls(&thread, cx))
1849 .when_some(
1850 self.thread_feedback.comments_editor.clone(),
1851 |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
1852 )
1853 .into_any_element()
1854 } else {
1855 primary
1856 };
1857
1858 if let Some(editing_index) = self.editing_message.as_ref()
1859 && *editing_index < entry_ix
1860 {
1861 let backdrop = div()
1862 .id(("backdrop", entry_ix))
1863 .size_full()
1864 .absolute()
1865 .inset_0()
1866 .bg(cx.theme().colors().panel_background)
1867 .opacity(0.8)
1868 .block_mouse_except_scroll()
1869 .on_click(cx.listener(Self::cancel_editing));
1870
1871 div()
1872 .relative()
1873 .child(primary)
1874 .child(backdrop)
1875 .into_any_element()
1876 } else {
1877 primary
1878 }
1879 }
1880
1881 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
1882 cx.theme()
1883 .colors()
1884 .element_background
1885 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
1886 }
1887
1888 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
1889 cx.theme().colors().border.opacity(0.8)
1890 }
1891
1892 fn tool_name_font_size(&self) -> Rems {
1893 rems_from_px(13.)
1894 }
1895
1896 fn render_thinking_block(
1897 &self,
1898 entry_ix: usize,
1899 chunk_ix: usize,
1900 chunk: Entity<Markdown>,
1901 window: &Window,
1902 cx: &Context<Self>,
1903 ) -> AnyElement {
1904 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
1905 let card_header_id = SharedString::from("inner-card-header");
1906
1907 let key = (entry_ix, chunk_ix);
1908
1909 let is_open = self.expanded_thinking_blocks.contains(&key);
1910
1911 let scroll_handle = self
1912 .entry_view_state
1913 .read(cx)
1914 .entry(entry_ix)
1915 .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
1916
1917 let thinking_content = {
1918 div()
1919 .id(("thinking-content", chunk_ix))
1920 .when_some(scroll_handle, |this, scroll_handle| {
1921 this.track_scroll(&scroll_handle)
1922 })
1923 .text_ui_sm(cx)
1924 .overflow_hidden()
1925 .child(
1926 self.render_markdown(chunk, default_markdown_style(false, false, window, cx)),
1927 )
1928 };
1929
1930 v_flex()
1931 .gap_1()
1932 .child(
1933 h_flex()
1934 .id(header_id)
1935 .group(&card_header_id)
1936 .relative()
1937 .w_full()
1938 .pr_1()
1939 .justify_between()
1940 .child(
1941 h_flex()
1942 .h(window.line_height() - px(2.))
1943 .gap_1p5()
1944 .overflow_hidden()
1945 .child(
1946 Icon::new(IconName::ToolThink)
1947 .size(IconSize::Small)
1948 .color(Color::Muted),
1949 )
1950 .child(
1951 div()
1952 .text_size(self.tool_name_font_size())
1953 .text_color(cx.theme().colors().text_muted)
1954 .child("Thinking"),
1955 ),
1956 )
1957 .child(
1958 Disclosure::new(("expand", entry_ix), is_open)
1959 .opened_icon(IconName::ChevronUp)
1960 .closed_icon(IconName::ChevronDown)
1961 .visible_on_hover(&card_header_id)
1962 .on_click(cx.listener({
1963 move |this, _event, _window, cx| {
1964 if is_open {
1965 this.expanded_thinking_blocks.remove(&key);
1966 } else {
1967 this.expanded_thinking_blocks.insert(key);
1968 }
1969 cx.notify();
1970 }
1971 })),
1972 )
1973 .on_click(cx.listener({
1974 move |this, _event, _window, cx| {
1975 if is_open {
1976 this.expanded_thinking_blocks.remove(&key);
1977 } else {
1978 this.expanded_thinking_blocks.insert(key);
1979 }
1980 cx.notify();
1981 }
1982 })),
1983 )
1984 .when(is_open, |this| {
1985 this.child(
1986 div()
1987 .ml_1p5()
1988 .pl_3p5()
1989 .border_l_1()
1990 .border_color(self.tool_card_border_color(cx))
1991 .child(thinking_content),
1992 )
1993 })
1994 .into_any_element()
1995 }
1996
1997 fn render_tool_call(
1998 &self,
1999 entry_ix: usize,
2000 tool_call: &ToolCall,
2001 window: &Window,
2002 cx: &Context<Self>,
2003 ) -> Div {
2004 let card_header_id = SharedString::from("inner-tool-call-header");
2005
2006 let tool_icon =
2007 if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 {
2008 FileIcons::get_icon(&tool_call.locations[0].path, cx)
2009 .map(Icon::from_path)
2010 .unwrap_or(Icon::new(IconName::ToolPencil))
2011 } else {
2012 Icon::new(match tool_call.kind {
2013 acp::ToolKind::Read => IconName::ToolSearch,
2014 acp::ToolKind::Edit => IconName::ToolPencil,
2015 acp::ToolKind::Delete => IconName::ToolDeleteFile,
2016 acp::ToolKind::Move => IconName::ArrowRightLeft,
2017 acp::ToolKind::Search => IconName::ToolSearch,
2018 acp::ToolKind::Execute => IconName::ToolTerminal,
2019 acp::ToolKind::Think => IconName::ToolThink,
2020 acp::ToolKind::Fetch => IconName::ToolWeb,
2021 acp::ToolKind::Other => IconName::ToolHammer,
2022 })
2023 }
2024 .size(IconSize::Small)
2025 .color(Color::Muted);
2026
2027 let failed_or_canceled = match &tool_call.status {
2028 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
2029 _ => false,
2030 };
2031
2032 let has_location = tool_call.locations.len() == 1;
2033 let needs_confirmation = matches!(
2034 tool_call.status,
2035 ToolCallStatus::WaitingForConfirmation { .. }
2036 );
2037 let is_edit =
2038 matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
2039 let use_card_layout = needs_confirmation || is_edit;
2040
2041 let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
2042
2043 let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
2044
2045 let gradient_overlay = {
2046 div()
2047 .absolute()
2048 .top_0()
2049 .right_0()
2050 .w_12()
2051 .h_full()
2052 .map(|this| {
2053 if use_card_layout {
2054 this.bg(linear_gradient(
2055 90.,
2056 linear_color_stop(self.tool_card_header_bg(cx), 1.),
2057 linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
2058 ))
2059 } else {
2060 this.bg(linear_gradient(
2061 90.,
2062 linear_color_stop(cx.theme().colors().panel_background, 1.),
2063 linear_color_stop(
2064 cx.theme().colors().panel_background.opacity(0.2),
2065 0.,
2066 ),
2067 ))
2068 }
2069 })
2070 };
2071
2072 let tool_output_display = if is_open {
2073 match &tool_call.status {
2074 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
2075 .w_full()
2076 .children(tool_call.content.iter().map(|content| {
2077 div()
2078 .child(self.render_tool_call_content(
2079 entry_ix,
2080 content,
2081 tool_call,
2082 use_card_layout,
2083 window,
2084 cx,
2085 ))
2086 .into_any_element()
2087 }))
2088 .child(self.render_permission_buttons(
2089 options,
2090 entry_ix,
2091 tool_call.id.clone(),
2092 cx,
2093 ))
2094 .into_any(),
2095 ToolCallStatus::Pending | ToolCallStatus::InProgress
2096 if is_edit
2097 && tool_call.content.is_empty()
2098 && self.as_native_connection(cx).is_some() =>
2099 {
2100 self.render_diff_loading(cx).into_any()
2101 }
2102 ToolCallStatus::Pending
2103 | ToolCallStatus::InProgress
2104 | ToolCallStatus::Completed
2105 | ToolCallStatus::Failed
2106 | ToolCallStatus::Canceled => v_flex()
2107 .w_full()
2108 .children(tool_call.content.iter().map(|content| {
2109 div().child(self.render_tool_call_content(
2110 entry_ix,
2111 content,
2112 tool_call,
2113 use_card_layout,
2114 window,
2115 cx,
2116 ))
2117 }))
2118 .into_any(),
2119 ToolCallStatus::Rejected => Empty.into_any(),
2120 }
2121 .into()
2122 } else {
2123 None
2124 };
2125
2126 v_flex()
2127 .map(|this| {
2128 if use_card_layout {
2129 this.my_1p5()
2130 .rounded_md()
2131 .border_1()
2132 .border_color(self.tool_card_border_color(cx))
2133 .bg(cx.theme().colors().editor_background)
2134 .overflow_hidden()
2135 } else {
2136 this.my_1()
2137 }
2138 })
2139 .map(|this| {
2140 if has_location && !use_card_layout {
2141 this.ml_4()
2142 } else {
2143 this.ml_5()
2144 }
2145 })
2146 .mr_5()
2147 .child(
2148 h_flex()
2149 .group(&card_header_id)
2150 .relative()
2151 .w_full()
2152 .gap_1()
2153 .justify_between()
2154 .when(use_card_layout, |this| {
2155 this.p_0p5()
2156 .rounded_t(rems_from_px(5.))
2157 .bg(self.tool_card_header_bg(cx))
2158 })
2159 .child(
2160 h_flex()
2161 .relative()
2162 .w_full()
2163 .h(window.line_height() - px(2.))
2164 .text_size(self.tool_name_font_size())
2165 .gap_1p5()
2166 .when(has_location || use_card_layout, |this| this.px_1())
2167 .when(has_location, |this| {
2168 this.cursor(CursorStyle::PointingHand)
2169 .rounded(rems_from_px(3.)) // Concentric border radius
2170 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
2171 })
2172 .overflow_hidden()
2173 .child(tool_icon)
2174 .child(if has_location {
2175 let name = tool_call.locations[0]
2176 .path
2177 .file_name()
2178 .unwrap_or_default()
2179 .display()
2180 .to_string();
2181
2182 h_flex()
2183 .id(("open-tool-call-location", entry_ix))
2184 .w_full()
2185 .map(|this| {
2186 if use_card_layout {
2187 this.text_color(cx.theme().colors().text)
2188 } else {
2189 this.text_color(cx.theme().colors().text_muted)
2190 }
2191 })
2192 .child(name)
2193 .tooltip(Tooltip::text("Jump to File"))
2194 .on_click(cx.listener(move |this, _, window, cx| {
2195 this.open_tool_call_location(entry_ix, 0, window, cx);
2196 }))
2197 .into_any_element()
2198 } else {
2199 h_flex()
2200 .w_full()
2201 .child(self.render_markdown(
2202 tool_call.label.clone(),
2203 default_markdown_style(false, true, window, cx),
2204 ))
2205 .into_any()
2206 })
2207 .when(!has_location, |this| this.child(gradient_overlay)),
2208 )
2209 .when(is_collapsible || failed_or_canceled, |this| {
2210 this.child(
2211 h_flex()
2212 .px_1()
2213 .gap_px()
2214 .when(is_collapsible, |this| {
2215 this.child(
2216 Disclosure::new(("expand", entry_ix), is_open)
2217 .opened_icon(IconName::ChevronUp)
2218 .closed_icon(IconName::ChevronDown)
2219 .visible_on_hover(&card_header_id)
2220 .on_click(cx.listener({
2221 let id = tool_call.id.clone();
2222 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
2223 if is_open {
2224 this.expanded_tool_calls.remove(&id);
2225 } else {
2226 this.expanded_tool_calls.insert(id.clone());
2227 }
2228 cx.notify();
2229 }
2230 })),
2231 )
2232 })
2233 .when(failed_or_canceled, |this| {
2234 this.child(
2235 Icon::new(IconName::Close)
2236 .color(Color::Error)
2237 .size(IconSize::Small),
2238 )
2239 }),
2240 )
2241 }),
2242 )
2243 .children(tool_output_display)
2244 }
2245
2246 fn render_tool_call_content(
2247 &self,
2248 entry_ix: usize,
2249 content: &ToolCallContent,
2250 tool_call: &ToolCall,
2251 card_layout: bool,
2252 window: &Window,
2253 cx: &Context<Self>,
2254 ) -> AnyElement {
2255 match content {
2256 ToolCallContent::ContentBlock(content) => {
2257 if let Some(resource_link) = content.resource_link() {
2258 self.render_resource_link(resource_link, cx)
2259 } else if let Some(markdown) = content.markdown() {
2260 self.render_markdown_output(
2261 markdown.clone(),
2262 tool_call.id.clone(),
2263 card_layout,
2264 window,
2265 cx,
2266 )
2267 } else {
2268 Empty.into_any_element()
2269 }
2270 }
2271 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx),
2272 ToolCallContent::Terminal(terminal) => {
2273 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
2274 }
2275 }
2276 }
2277
2278 fn render_markdown_output(
2279 &self,
2280 markdown: Entity<Markdown>,
2281 tool_call_id: acp::ToolCallId,
2282 card_layout: bool,
2283 window: &Window,
2284 cx: &Context<Self>,
2285 ) -> AnyElement {
2286 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
2287
2288 v_flex()
2289 .mt_1p5()
2290 .gap_2()
2291 .when(!card_layout, |this| {
2292 this.ml(rems(0.4))
2293 .px_3p5()
2294 .border_l_1()
2295 .border_color(self.tool_card_border_color(cx))
2296 })
2297 .when(card_layout, |this| {
2298 this.p_2()
2299 .border_t_1()
2300 .border_color(self.tool_card_border_color(cx))
2301 })
2302 .text_sm()
2303 .text_color(cx.theme().colors().text_muted)
2304 .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
2305 .when(!card_layout, |this| {
2306 this.child(
2307 IconButton::new(button_id, IconName::ChevronUp)
2308 .full_width()
2309 .style(ButtonStyle::Outlined)
2310 .icon_color(Color::Muted)
2311 .on_click(cx.listener({
2312 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
2313 this.expanded_tool_calls.remove(&tool_call_id);
2314 cx.notify();
2315 }
2316 })),
2317 )
2318 })
2319 .into_any_element()
2320 }
2321
2322 fn render_resource_link(
2323 &self,
2324 resource_link: &acp::ResourceLink,
2325 cx: &Context<Self>,
2326 ) -> AnyElement {
2327 let uri: SharedString = resource_link.uri.clone().into();
2328 let is_file = resource_link.uri.strip_prefix("file://");
2329
2330 let label: SharedString = if let Some(abs_path) = is_file {
2331 if let Some(project_path) = self
2332 .project
2333 .read(cx)
2334 .project_path_for_absolute_path(&Path::new(abs_path), cx)
2335 && let Some(worktree) = self
2336 .project
2337 .read(cx)
2338 .worktree_for_id(project_path.worktree_id, cx)
2339 {
2340 worktree
2341 .read(cx)
2342 .full_path(&project_path.path)
2343 .to_string_lossy()
2344 .to_string()
2345 .into()
2346 } else {
2347 abs_path.to_string().into()
2348 }
2349 } else {
2350 uri.clone()
2351 };
2352
2353 let button_id = SharedString::from(format!("item-{}", uri));
2354
2355 div()
2356 .ml(rems(0.4))
2357 .pl_2p5()
2358 .border_l_1()
2359 .border_color(self.tool_card_border_color(cx))
2360 .overflow_hidden()
2361 .child(
2362 Button::new(button_id, label)
2363 .label_size(LabelSize::Small)
2364 .color(Color::Muted)
2365 .truncate(true)
2366 .when(is_file.is_none(), |this| {
2367 this.icon(IconName::ArrowUpRight)
2368 .icon_size(IconSize::XSmall)
2369 .icon_color(Color::Muted)
2370 })
2371 .on_click(cx.listener({
2372 let workspace = self.workspace.clone();
2373 move |_, _, window, cx: &mut Context<Self>| {
2374 Self::open_link(uri.clone(), &workspace, window, cx);
2375 }
2376 })),
2377 )
2378 .into_any_element()
2379 }
2380
2381 fn render_permission_buttons(
2382 &self,
2383 options: &[acp::PermissionOption],
2384 entry_ix: usize,
2385 tool_call_id: acp::ToolCallId,
2386 cx: &Context<Self>,
2387 ) -> Div {
2388 h_flex()
2389 .py_1()
2390 .pl_2()
2391 .pr_1()
2392 .gap_1()
2393 .justify_between()
2394 .flex_wrap()
2395 .border_t_1()
2396 .border_color(self.tool_card_border_color(cx))
2397 .child(
2398 div()
2399 .min_w(rems_from_px(145.))
2400 .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
2401 )
2402 .child(h_flex().gap_0p5().children(options.iter().map(|option| {
2403 let option_id = SharedString::from(option.id.0.clone());
2404 Button::new((option_id, entry_ix), option.name.clone())
2405 .map(|this| match option.kind {
2406 acp::PermissionOptionKind::AllowOnce => {
2407 this.icon(IconName::Check).icon_color(Color::Success)
2408 }
2409 acp::PermissionOptionKind::AllowAlways => {
2410 this.icon(IconName::CheckDouble).icon_color(Color::Success)
2411 }
2412 acp::PermissionOptionKind::RejectOnce => {
2413 this.icon(IconName::Close).icon_color(Color::Error)
2414 }
2415 acp::PermissionOptionKind::RejectAlways => {
2416 this.icon(IconName::Close).icon_color(Color::Error)
2417 }
2418 })
2419 .icon_position(IconPosition::Start)
2420 .icon_size(IconSize::XSmall)
2421 .label_size(LabelSize::Small)
2422 .on_click(cx.listener({
2423 let tool_call_id = tool_call_id.clone();
2424 let option_id = option.id.clone();
2425 let option_kind = option.kind;
2426 move |this, _, window, cx| {
2427 this.authorize_tool_call(
2428 tool_call_id.clone(),
2429 option_id.clone(),
2430 option_kind,
2431 window,
2432 cx,
2433 );
2434 }
2435 }))
2436 })))
2437 }
2438
2439 fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
2440 let bar = |n: u64, width_class: &str| {
2441 let bg_color = cx.theme().colors().element_active;
2442 let base = h_flex().h_1().rounded_full();
2443
2444 let modified = match width_class {
2445 "w_4_5" => base.w_3_4(),
2446 "w_1_4" => base.w_1_4(),
2447 "w_2_4" => base.w_2_4(),
2448 "w_3_5" => base.w_3_5(),
2449 "w_2_5" => base.w_2_5(),
2450 _ => base.w_1_2(),
2451 };
2452
2453 modified.with_animation(
2454 ElementId::Integer(n),
2455 Animation::new(Duration::from_secs(2)).repeat(),
2456 move |tab, delta| {
2457 let delta = (delta - 0.15 * n as f32) / 0.7;
2458 let delta = 1.0 - (0.5 - delta).abs() * 2.;
2459 let delta = ease_in_out(delta.clamp(0., 1.));
2460 let delta = 0.1 + 0.9 * delta;
2461
2462 tab.bg(bg_color.opacity(delta))
2463 },
2464 )
2465 };
2466
2467 v_flex()
2468 .p_3()
2469 .gap_1()
2470 .rounded_b_md()
2471 .bg(cx.theme().colors().editor_background)
2472 .child(bar(0, "w_4_5"))
2473 .child(bar(1, "w_1_4"))
2474 .child(bar(2, "w_2_4"))
2475 .child(bar(3, "w_3_5"))
2476 .child(bar(4, "w_2_5"))
2477 .into_any_element()
2478 }
2479
2480 fn render_diff_editor(
2481 &self,
2482 entry_ix: usize,
2483 diff: &Entity<acp_thread::Diff>,
2484 tool_call: &ToolCall,
2485 cx: &Context<Self>,
2486 ) -> AnyElement {
2487 let tool_progress = matches!(
2488 &tool_call.status,
2489 ToolCallStatus::InProgress | ToolCallStatus::Pending
2490 );
2491
2492 v_flex()
2493 .h_full()
2494 .border_t_1()
2495 .border_color(self.tool_card_border_color(cx))
2496 .child(
2497 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
2498 && let Some(editor) = entry.editor_for_diff(diff)
2499 && diff.read(cx).has_revealed_range(cx)
2500 {
2501 editor.into_any_element()
2502 } else if tool_progress && self.as_native_connection(cx).is_some() {
2503 self.render_diff_loading(cx)
2504 } else {
2505 Empty.into_any()
2506 },
2507 )
2508 .into_any()
2509 }
2510
2511 fn render_terminal_tool_call(
2512 &self,
2513 entry_ix: usize,
2514 terminal: &Entity<acp_thread::Terminal>,
2515 tool_call: &ToolCall,
2516 window: &Window,
2517 cx: &Context<Self>,
2518 ) -> AnyElement {
2519 let terminal_data = terminal.read(cx);
2520 let working_dir = terminal_data.working_dir();
2521 let command = terminal_data.command();
2522 let started_at = terminal_data.started_at();
2523
2524 let tool_failed = matches!(
2525 &tool_call.status,
2526 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
2527 );
2528
2529 let output = terminal_data.output();
2530 let command_finished = output.is_some();
2531 let truncated_output =
2532 output.is_some_and(|output| output.original_content_len > output.content.len());
2533 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
2534
2535 let command_failed = command_finished
2536 && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
2537
2538 let time_elapsed = if let Some(output) = output {
2539 output.ended_at.duration_since(started_at)
2540 } else {
2541 started_at.elapsed()
2542 };
2543
2544 let header_id =
2545 SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
2546 let header_group = SharedString::from(format!(
2547 "terminal-tool-header-group-{}",
2548 terminal.entity_id()
2549 ));
2550 let header_bg = cx
2551 .theme()
2552 .colors()
2553 .element_background
2554 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
2555 let border_color = cx.theme().colors().border.opacity(0.6);
2556
2557 let working_dir = working_dir
2558 .as_ref()
2559 .map(|path| format!("{}", path.display()))
2560 .unwrap_or_else(|| "current directory".to_string());
2561
2562 let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
2563
2564 let header = h_flex()
2565 .id(header_id)
2566 .flex_none()
2567 .gap_1()
2568 .justify_between()
2569 .rounded_t_md()
2570 .child(
2571 div()
2572 .id(("command-target-path", terminal.entity_id()))
2573 .w_full()
2574 .max_w_full()
2575 .overflow_x_scroll()
2576 .child(
2577 Label::new(working_dir)
2578 .buffer_font(cx)
2579 .size(LabelSize::XSmall)
2580 .color(Color::Muted),
2581 ),
2582 )
2583 .when(!command_finished, |header| {
2584 header
2585 .gap_1p5()
2586 .child(
2587 Button::new(
2588 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
2589 "Stop",
2590 )
2591 .icon(IconName::Stop)
2592 .icon_position(IconPosition::Start)
2593 .icon_size(IconSize::Small)
2594 .icon_color(Color::Error)
2595 .label_size(LabelSize::Small)
2596 .tooltip(move |window, cx| {
2597 Tooltip::with_meta(
2598 "Stop This Command",
2599 None,
2600 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
2601 window,
2602 cx,
2603 )
2604 })
2605 .on_click({
2606 let terminal = terminal.clone();
2607 cx.listener(move |_this, _event, _window, cx| {
2608 let inner_terminal = terminal.read(cx).inner().clone();
2609 inner_terminal.update(cx, |inner_terminal, _cx| {
2610 inner_terminal.kill_active_task();
2611 });
2612 })
2613 }),
2614 )
2615 .child(Divider::vertical())
2616 .child(
2617 Icon::new(IconName::ArrowCircle)
2618 .size(IconSize::XSmall)
2619 .color(Color::Info)
2620 .with_rotate_animation(2)
2621 )
2622 })
2623 .child(
2624 Disclosure::new(
2625 SharedString::from(format!(
2626 "terminal-tool-disclosure-{}",
2627 terminal.entity_id()
2628 )),
2629 is_expanded,
2630 )
2631 .opened_icon(IconName::ChevronUp)
2632 .closed_icon(IconName::ChevronDown)
2633 .visible_on_hover(&header_group)
2634 .on_click(cx.listener({
2635 let id = tool_call.id.clone();
2636 move |this, _event, _window, _cx| {
2637 if is_expanded {
2638 this.expanded_tool_calls.remove(&id);
2639 } else {
2640 this.expanded_tool_calls.insert(id.clone());
2641 }
2642 }
2643 })),
2644 )
2645 .when(truncated_output, |header| {
2646 let tooltip = if let Some(output) = output {
2647 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
2648 format!("Output exceeded terminal max lines and was \
2649 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
2650 } else {
2651 format!(
2652 "Output is {} long, and to avoid unexpected token usage, \
2653 only {} was sent back to the agent.",
2654 format_file_size(output.original_content_len as u64, true),
2655 format_file_size(output.content.len() as u64, true)
2656 )
2657 }
2658 } else {
2659 "Output was truncated".to_string()
2660 };
2661
2662 header.child(
2663 h_flex()
2664 .id(("terminal-tool-truncated-label", terminal.entity_id()))
2665 .gap_1()
2666 .child(
2667 Icon::new(IconName::Info)
2668 .size(IconSize::XSmall)
2669 .color(Color::Ignored),
2670 )
2671 .child(
2672 Label::new("Truncated")
2673 .color(Color::Muted)
2674 .size(LabelSize::XSmall),
2675 )
2676 .tooltip(Tooltip::text(tooltip)),
2677 )
2678 })
2679 .when(time_elapsed > Duration::from_secs(10), |header| {
2680 header.child(
2681 Label::new(format!("({})", duration_alt_display(time_elapsed)))
2682 .buffer_font(cx)
2683 .color(Color::Muted)
2684 .size(LabelSize::XSmall),
2685 )
2686 })
2687 .when(tool_failed || command_failed, |header| {
2688 header.child(
2689 div()
2690 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
2691 .child(
2692 Icon::new(IconName::Close)
2693 .size(IconSize::Small)
2694 .color(Color::Error),
2695 )
2696 .when_some(output.and_then(|o| o.exit_status), |this, status| {
2697 this.tooltip(Tooltip::text(format!(
2698 "Exited with code {}",
2699 status.code().unwrap_or(-1),
2700 )))
2701 }),
2702 )
2703 });
2704
2705 let terminal_view = self
2706 .entry_view_state
2707 .read(cx)
2708 .entry(entry_ix)
2709 .and_then(|entry| entry.terminal(terminal));
2710 let show_output = is_expanded && terminal_view.is_some();
2711
2712 v_flex()
2713 .my_1p5()
2714 .mx_5()
2715 .border_1()
2716 .when(tool_failed || command_failed, |card| card.border_dashed())
2717 .border_color(border_color)
2718 .rounded_md()
2719 .overflow_hidden()
2720 .child(
2721 v_flex()
2722 .group(&header_group)
2723 .py_1p5()
2724 .pr_1p5()
2725 .pl_2()
2726 .gap_0p5()
2727 .bg(header_bg)
2728 .text_xs()
2729 .child(header)
2730 .child(
2731 MarkdownElement::new(
2732 command.clone(),
2733 terminal_command_markdown_style(window, cx),
2734 )
2735 .code_block_renderer(
2736 markdown::CodeBlockRenderer::Default {
2737 copy_button: false,
2738 copy_button_on_hover: true,
2739 border: false,
2740 },
2741 ),
2742 ),
2743 )
2744 .when(show_output, |this| {
2745 this.child(
2746 div()
2747 .pt_2()
2748 .border_t_1()
2749 .when(tool_failed || command_failed, |card| card.border_dashed())
2750 .border_color(border_color)
2751 .bg(cx.theme().colors().editor_background)
2752 .rounded_b_md()
2753 .text_ui_sm(cx)
2754 .h_full()
2755 .children(terminal_view.map(|terminal_view| {
2756 if terminal_view
2757 .read(cx)
2758 .content_mode(window, cx)
2759 .is_scrollable()
2760 {
2761 div().h_72().child(terminal_view).into_any_element()
2762 } else {
2763 terminal_view.into_any_element()
2764 }
2765 })),
2766 )
2767 })
2768 .into_any()
2769 }
2770
2771 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
2772 let project_context = self
2773 .as_native_thread(cx)?
2774 .read(cx)
2775 .project_context()
2776 .read(cx);
2777
2778 let user_rules_text = if project_context.user_rules.is_empty() {
2779 None
2780 } else if project_context.user_rules.len() == 1 {
2781 let user_rules = &project_context.user_rules[0];
2782
2783 match user_rules.title.as_ref() {
2784 Some(title) => Some(format!("Using \"{title}\" user rule")),
2785 None => Some("Using user rule".into()),
2786 }
2787 } else {
2788 Some(format!(
2789 "Using {} user rules",
2790 project_context.user_rules.len()
2791 ))
2792 };
2793
2794 let first_user_rules_id = project_context
2795 .user_rules
2796 .first()
2797 .map(|user_rules| user_rules.uuid.0);
2798
2799 let rules_files = project_context
2800 .worktrees
2801 .iter()
2802 .filter_map(|worktree| worktree.rules_file.as_ref())
2803 .collect::<Vec<_>>();
2804
2805 let rules_file_text = match rules_files.as_slice() {
2806 &[] => None,
2807 &[rules_file] => Some(format!(
2808 "Using project {:?} file",
2809 rules_file.path_in_worktree
2810 )),
2811 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
2812 };
2813
2814 if user_rules_text.is_none() && rules_file_text.is_none() {
2815 return None;
2816 }
2817
2818 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
2819
2820 Some(
2821 h_flex()
2822 .px_2p5()
2823 .child(
2824 Icon::new(IconName::Attach)
2825 .size(IconSize::XSmall)
2826 .color(Color::Disabled),
2827 )
2828 .when_some(user_rules_text, |parent, user_rules_text| {
2829 parent.child(
2830 h_flex()
2831 .id("user-rules")
2832 .ml_1()
2833 .mr_1p5()
2834 .child(
2835 Label::new(user_rules_text)
2836 .size(LabelSize::XSmall)
2837 .color(Color::Muted)
2838 .truncate(),
2839 )
2840 .hover(|s| s.bg(cx.theme().colors().element_hover))
2841 .tooltip(Tooltip::text("View User Rules"))
2842 .on_click(move |_event, window, cx| {
2843 window.dispatch_action(
2844 Box::new(OpenRulesLibrary {
2845 prompt_to_select: first_user_rules_id,
2846 }),
2847 cx,
2848 )
2849 }),
2850 )
2851 })
2852 .when(has_both, |this| {
2853 this.child(
2854 Label::new("•")
2855 .size(LabelSize::XSmall)
2856 .color(Color::Disabled),
2857 )
2858 })
2859 .when_some(rules_file_text, |parent, rules_file_text| {
2860 parent.child(
2861 h_flex()
2862 .id("project-rules")
2863 .ml_1p5()
2864 .child(
2865 Label::new(rules_file_text)
2866 .size(LabelSize::XSmall)
2867 .color(Color::Muted),
2868 )
2869 .hover(|s| s.bg(cx.theme().colors().element_hover))
2870 .tooltip(Tooltip::text("View Project Rules"))
2871 .on_click(cx.listener(Self::handle_open_rules)),
2872 )
2873 })
2874 .into_any(),
2875 )
2876 }
2877
2878 fn render_empty_state_section_header(
2879 &self,
2880 label: impl Into<SharedString>,
2881 action_slot: Option<AnyElement>,
2882 cx: &mut Context<Self>,
2883 ) -> impl IntoElement {
2884 div().pl_1().pr_1p5().child(
2885 h_flex()
2886 .mt_2()
2887 .pl_1p5()
2888 .pb_1()
2889 .w_full()
2890 .justify_between()
2891 .border_b_1()
2892 .border_color(cx.theme().colors().border_variant)
2893 .child(
2894 Label::new(label.into())
2895 .size(LabelSize::Small)
2896 .color(Color::Muted),
2897 )
2898 .children(action_slot),
2899 )
2900 }
2901
2902 fn render_recent_history(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2903 let render_history = self
2904 .agent
2905 .clone()
2906 .downcast::<agent2::NativeAgentServer>()
2907 .is_some()
2908 && self
2909 .history_store
2910 .update(cx, |history_store, cx| !history_store.is_empty(cx));
2911
2912 v_flex()
2913 .size_full()
2914 .when(render_history, |this| {
2915 let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| {
2916 history_store.entries().take(3).collect()
2917 });
2918 this.justify_end().child(
2919 v_flex()
2920 .child(
2921 self.render_empty_state_section_header(
2922 "Recent",
2923 Some(
2924 Button::new("view-history", "View All")
2925 .style(ButtonStyle::Subtle)
2926 .label_size(LabelSize::Small)
2927 .key_binding(
2928 KeyBinding::for_action_in(
2929 &OpenHistory,
2930 &self.focus_handle(cx),
2931 window,
2932 cx,
2933 )
2934 .map(|kb| kb.size(rems_from_px(12.))),
2935 )
2936 .on_click(move |_event, window, cx| {
2937 window.dispatch_action(OpenHistory.boxed_clone(), cx);
2938 })
2939 .into_any_element(),
2940 ),
2941 cx,
2942 ),
2943 )
2944 .child(
2945 v_flex().p_1().pr_1p5().gap_1().children(
2946 recent_history
2947 .into_iter()
2948 .enumerate()
2949 .map(|(index, entry)| {
2950 // TODO: Add keyboard navigation.
2951 let is_hovered =
2952 self.hovered_recent_history_item == Some(index);
2953 crate::acp::thread_history::AcpHistoryEntryElement::new(
2954 entry,
2955 cx.entity().downgrade(),
2956 )
2957 .hovered(is_hovered)
2958 .on_hover(cx.listener(
2959 move |this, is_hovered, _window, cx| {
2960 if *is_hovered {
2961 this.hovered_recent_history_item = Some(index);
2962 } else if this.hovered_recent_history_item
2963 == Some(index)
2964 {
2965 this.hovered_recent_history_item = None;
2966 }
2967 cx.notify();
2968 },
2969 ))
2970 .into_any_element()
2971 }),
2972 ),
2973 ),
2974 )
2975 })
2976 .into_any()
2977 }
2978
2979 fn render_auth_required_state(
2980 &self,
2981 connection: &Rc<dyn AgentConnection>,
2982 description: Option<&Entity<Markdown>>,
2983 configuration_view: Option<&AnyView>,
2984 pending_auth_method: Option<&acp::AuthMethodId>,
2985 window: &mut Window,
2986 cx: &Context<Self>,
2987 ) -> Div {
2988 let show_description =
2989 configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
2990
2991 v_flex().flex_1().size_full().justify_end().child(
2992 v_flex()
2993 .p_2()
2994 .pr_3()
2995 .w_full()
2996 .gap_1()
2997 .border_t_1()
2998 .border_color(cx.theme().colors().border)
2999 .bg(cx.theme().status().warning.opacity(0.04))
3000 .child(
3001 h_flex()
3002 .gap_1p5()
3003 .child(
3004 Icon::new(IconName::Warning)
3005 .color(Color::Warning)
3006 .size(IconSize::Small),
3007 )
3008 .child(Label::new("Authentication Required").size(LabelSize::Small)),
3009 )
3010 .children(description.map(|desc| {
3011 div().text_ui(cx).child(self.render_markdown(
3012 desc.clone(),
3013 default_markdown_style(false, false, window, cx),
3014 ))
3015 }))
3016 .children(
3017 configuration_view
3018 .cloned()
3019 .map(|view| div().w_full().child(view)),
3020 )
3021 .when(
3022 show_description,
3023 |el| {
3024 el.child(
3025 Label::new(format!(
3026 "You are not currently authenticated with {}. Please choose one of the following options:",
3027 self.agent.name()
3028 ))
3029 .size(LabelSize::Small)
3030 .color(Color::Muted)
3031 .mb_1()
3032 .ml_5(),
3033 )
3034 },
3035 )
3036 .when_some(pending_auth_method, |el, _| {
3037 el.child(
3038 h_flex()
3039 .py_4()
3040 .w_full()
3041 .justify_center()
3042 .gap_1()
3043 .child(
3044 Icon::new(IconName::ArrowCircle)
3045 .size(IconSize::Small)
3046 .color(Color::Muted)
3047 .with_rotate_animation(2)
3048 )
3049 .child(Label::new("Authenticating…").size(LabelSize::Small)),
3050 )
3051 })
3052 .when(!connection.auth_methods().is_empty(), |this| {
3053 this.child(
3054 h_flex()
3055 .justify_end()
3056 .flex_wrap()
3057 .gap_1()
3058 .when(!show_description, |this| {
3059 this.border_t_1()
3060 .mt_1()
3061 .pt_2()
3062 .border_color(cx.theme().colors().border.opacity(0.8))
3063 })
3064 .children(
3065 connection
3066 .auth_methods()
3067 .iter()
3068 .enumerate()
3069 .rev()
3070 .map(|(ix, method)| {
3071 Button::new(
3072 SharedString::from(method.id.0.clone()),
3073 method.name.clone(),
3074 )
3075 .when(ix == 0, |el| {
3076 el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
3077 })
3078 .label_size(LabelSize::Small)
3079 .on_click({
3080 let method_id = method.id.clone();
3081 cx.listener(move |this, _, window, cx| {
3082 telemetry::event!(
3083 "Authenticate Agent Started",
3084 agent = this.agent.telemetry_id(),
3085 method = method_id
3086 );
3087
3088 this.authenticate(method_id.clone(), window, cx)
3089 })
3090 })
3091 }),
3092 ),
3093 )
3094 })
3095
3096 )
3097 }
3098
3099 fn render_load_error(
3100 &self,
3101 e: &LoadError,
3102 window: &mut Window,
3103 cx: &mut Context<Self>,
3104 ) -> AnyElement {
3105 let (title, message, action_slot): (_, SharedString, _) = match e {
3106 LoadError::Unsupported {
3107 command: path,
3108 current_version,
3109 minimum_version,
3110 } => {
3111 return self.render_unsupported(path, current_version, minimum_version, window, cx);
3112 }
3113 LoadError::FailedToInstall(msg) => (
3114 "Failed to Install",
3115 msg.into(),
3116 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3117 ),
3118 LoadError::Exited { status } => (
3119 "Failed to Launch",
3120 format!("Server exited with status {status}").into(),
3121 None,
3122 ),
3123 LoadError::Other(msg) => (
3124 "Failed to Launch",
3125 msg.into(),
3126 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3127 ),
3128 };
3129
3130 Callout::new()
3131 .severity(Severity::Error)
3132 .icon(IconName::XCircleFilled)
3133 .title(title)
3134 .description(message)
3135 .actions_slot(div().children(action_slot))
3136 .into_any_element()
3137 }
3138
3139 fn render_unsupported(
3140 &self,
3141 path: &SharedString,
3142 version: &SharedString,
3143 minimum_version: &SharedString,
3144 _window: &mut Window,
3145 cx: &mut Context<Self>,
3146 ) -> AnyElement {
3147 let (heading_label, description_label) = (
3148 format!("Upgrade {} to work with Zed", self.agent.name()),
3149 if version.is_empty() {
3150 format!(
3151 "Currently using {}, which does not report a valid --version",
3152 path,
3153 )
3154 } else {
3155 format!(
3156 "Currently using {}, which is only version {} (need at least {minimum_version})",
3157 path, version
3158 )
3159 },
3160 );
3161
3162 v_flex()
3163 .w_full()
3164 .p_3p5()
3165 .gap_2p5()
3166 .border_t_1()
3167 .border_color(cx.theme().colors().border)
3168 .bg(linear_gradient(
3169 180.,
3170 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
3171 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
3172 ))
3173 .child(
3174 v_flex().gap_0p5().child(Label::new(heading_label)).child(
3175 Label::new(description_label)
3176 .size(LabelSize::Small)
3177 .color(Color::Muted),
3178 ),
3179 )
3180 .into_any_element()
3181 }
3182
3183 fn render_activity_bar(
3184 &self,
3185 thread_entity: &Entity<AcpThread>,
3186 window: &mut Window,
3187 cx: &Context<Self>,
3188 ) -> Option<AnyElement> {
3189 let thread = thread_entity.read(cx);
3190 let action_log = thread.action_log();
3191 let changed_buffers = action_log.read(cx).changed_buffers(cx);
3192 let plan = thread.plan();
3193
3194 if changed_buffers.is_empty() && plan.is_empty() {
3195 return None;
3196 }
3197
3198 let editor_bg_color = cx.theme().colors().editor_background;
3199 let active_color = cx.theme().colors().element_selected;
3200 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
3201
3202 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
3203 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
3204 // be, which blocks you from being able to accept or reject edits. This switches the
3205 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
3206 // block you from using the panel.
3207 let pending_edits = false;
3208
3209 v_flex()
3210 .mt_1()
3211 .mx_2()
3212 .bg(bg_edit_files_disclosure)
3213 .border_1()
3214 .border_b_0()
3215 .border_color(cx.theme().colors().border)
3216 .rounded_t_md()
3217 .shadow(vec![gpui::BoxShadow {
3218 color: gpui::black().opacity(0.15),
3219 offset: point(px(1.), px(-1.)),
3220 blur_radius: px(3.),
3221 spread_radius: px(0.),
3222 }])
3223 .when(!plan.is_empty(), |this| {
3224 this.child(self.render_plan_summary(plan, window, cx))
3225 .when(self.plan_expanded, |parent| {
3226 parent.child(self.render_plan_entries(plan, window, cx))
3227 })
3228 })
3229 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
3230 this.child(Divider::horizontal().color(DividerColor::Border))
3231 })
3232 .when(!changed_buffers.is_empty(), |this| {
3233 this.child(self.render_edits_summary(
3234 &changed_buffers,
3235 self.edits_expanded,
3236 pending_edits,
3237 window,
3238 cx,
3239 ))
3240 .when(self.edits_expanded, |parent| {
3241 parent.child(self.render_edited_files(
3242 action_log,
3243 &changed_buffers,
3244 pending_edits,
3245 cx,
3246 ))
3247 })
3248 })
3249 .into_any()
3250 .into()
3251 }
3252
3253 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3254 let stats = plan.stats();
3255
3256 let title = if let Some(entry) = stats.in_progress_entry
3257 && !self.plan_expanded
3258 {
3259 h_flex()
3260 .w_full()
3261 .cursor_default()
3262 .gap_1()
3263 .text_xs()
3264 .text_color(cx.theme().colors().text_muted)
3265 .justify_between()
3266 .child(
3267 h_flex()
3268 .gap_1()
3269 .child(
3270 Label::new("Current:")
3271 .size(LabelSize::Small)
3272 .color(Color::Muted),
3273 )
3274 .child(MarkdownElement::new(
3275 entry.content.clone(),
3276 plan_label_markdown_style(&entry.status, window, cx),
3277 )),
3278 )
3279 .when(stats.pending > 0, |this| {
3280 this.child(
3281 Label::new(format!("{} left", stats.pending))
3282 .size(LabelSize::Small)
3283 .color(Color::Muted)
3284 .mr_1(),
3285 )
3286 })
3287 } else {
3288 let status_label = if stats.pending == 0 {
3289 "All Done".to_string()
3290 } else if stats.completed == 0 {
3291 format!("{} Tasks", plan.entries.len())
3292 } else {
3293 format!("{}/{}", stats.completed, plan.entries.len())
3294 };
3295
3296 h_flex()
3297 .w_full()
3298 .gap_1()
3299 .justify_between()
3300 .child(
3301 Label::new("Plan")
3302 .size(LabelSize::Small)
3303 .color(Color::Muted),
3304 )
3305 .child(
3306 Label::new(status_label)
3307 .size(LabelSize::Small)
3308 .color(Color::Muted)
3309 .mr_1(),
3310 )
3311 };
3312
3313 h_flex()
3314 .p_1()
3315 .justify_between()
3316 .when(self.plan_expanded, |this| {
3317 this.border_b_1().border_color(cx.theme().colors().border)
3318 })
3319 .child(
3320 h_flex()
3321 .id("plan_summary")
3322 .w_full()
3323 .gap_1()
3324 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
3325 .child(title)
3326 .on_click(cx.listener(|this, _, _, cx| {
3327 this.plan_expanded = !this.plan_expanded;
3328 cx.notify();
3329 })),
3330 )
3331 }
3332
3333 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3334 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
3335 let element = h_flex()
3336 .py_1()
3337 .px_2()
3338 .gap_2()
3339 .justify_between()
3340 .bg(cx.theme().colors().editor_background)
3341 .when(index < plan.entries.len() - 1, |parent| {
3342 parent.border_color(cx.theme().colors().border).border_b_1()
3343 })
3344 .child(
3345 h_flex()
3346 .id(("plan_entry", index))
3347 .gap_1p5()
3348 .max_w_full()
3349 .overflow_x_scroll()
3350 .text_xs()
3351 .text_color(cx.theme().colors().text_muted)
3352 .child(match entry.status {
3353 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
3354 .size(IconSize::Small)
3355 .color(Color::Muted)
3356 .into_any_element(),
3357 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
3358 .size(IconSize::Small)
3359 .color(Color::Accent)
3360 .with_rotate_animation(2)
3361 .into_any_element(),
3362 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
3363 .size(IconSize::Small)
3364 .color(Color::Success)
3365 .into_any_element(),
3366 })
3367 .child(MarkdownElement::new(
3368 entry.content.clone(),
3369 plan_label_markdown_style(&entry.status, window, cx),
3370 )),
3371 );
3372
3373 Some(element)
3374 }))
3375 }
3376
3377 fn render_edits_summary(
3378 &self,
3379 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3380 expanded: bool,
3381 pending_edits: bool,
3382 window: &mut Window,
3383 cx: &Context<Self>,
3384 ) -> Div {
3385 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
3386
3387 let focus_handle = self.focus_handle(cx);
3388
3389 h_flex()
3390 .p_1()
3391 .justify_between()
3392 .flex_wrap()
3393 .when(expanded, |this| {
3394 this.border_b_1().border_color(cx.theme().colors().border)
3395 })
3396 .child(
3397 h_flex()
3398 .id("edits-container")
3399 .gap_1()
3400 .child(Disclosure::new("edits-disclosure", expanded))
3401 .map(|this| {
3402 if pending_edits {
3403 this.child(
3404 Label::new(format!(
3405 "Editing {} {}…",
3406 changed_buffers.len(),
3407 if changed_buffers.len() == 1 {
3408 "file"
3409 } else {
3410 "files"
3411 }
3412 ))
3413 .color(Color::Muted)
3414 .size(LabelSize::Small)
3415 .with_animation(
3416 "edit-label",
3417 Animation::new(Duration::from_secs(2))
3418 .repeat()
3419 .with_easing(pulsating_between(0.3, 0.7)),
3420 |label, delta| label.alpha(delta),
3421 ),
3422 )
3423 } else {
3424 this.child(
3425 Label::new("Edits")
3426 .size(LabelSize::Small)
3427 .color(Color::Muted),
3428 )
3429 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
3430 .child(
3431 Label::new(format!(
3432 "{} {}",
3433 changed_buffers.len(),
3434 if changed_buffers.len() == 1 {
3435 "file"
3436 } else {
3437 "files"
3438 }
3439 ))
3440 .size(LabelSize::Small)
3441 .color(Color::Muted),
3442 )
3443 }
3444 })
3445 .on_click(cx.listener(|this, _, _, cx| {
3446 this.edits_expanded = !this.edits_expanded;
3447 cx.notify();
3448 })),
3449 )
3450 .child(
3451 h_flex()
3452 .gap_1()
3453 .child(
3454 IconButton::new("review-changes", IconName::ListTodo)
3455 .icon_size(IconSize::Small)
3456 .tooltip({
3457 let focus_handle = focus_handle.clone();
3458 move |window, cx| {
3459 Tooltip::for_action_in(
3460 "Review Changes",
3461 &OpenAgentDiff,
3462 &focus_handle,
3463 window,
3464 cx,
3465 )
3466 }
3467 })
3468 .on_click(cx.listener(|_, _, window, cx| {
3469 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
3470 })),
3471 )
3472 .child(Divider::vertical().color(DividerColor::Border))
3473 .child(
3474 Button::new("reject-all-changes", "Reject All")
3475 .label_size(LabelSize::Small)
3476 .disabled(pending_edits)
3477 .when(pending_edits, |this| {
3478 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3479 })
3480 .key_binding(
3481 KeyBinding::for_action_in(
3482 &RejectAll,
3483 &focus_handle.clone(),
3484 window,
3485 cx,
3486 )
3487 .map(|kb| kb.size(rems_from_px(10.))),
3488 )
3489 .on_click(cx.listener(move |this, _, window, cx| {
3490 this.reject_all(&RejectAll, window, cx);
3491 })),
3492 )
3493 .child(
3494 Button::new("keep-all-changes", "Keep All")
3495 .label_size(LabelSize::Small)
3496 .disabled(pending_edits)
3497 .when(pending_edits, |this| {
3498 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3499 })
3500 .key_binding(
3501 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
3502 .map(|kb| kb.size(rems_from_px(10.))),
3503 )
3504 .on_click(cx.listener(move |this, _, window, cx| {
3505 this.keep_all(&KeepAll, window, cx);
3506 })),
3507 ),
3508 )
3509 }
3510
3511 fn render_edited_files(
3512 &self,
3513 action_log: &Entity<ActionLog>,
3514 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3515 pending_edits: bool,
3516 cx: &Context<Self>,
3517 ) -> Div {
3518 let editor_bg_color = cx.theme().colors().editor_background;
3519
3520 v_flex().children(changed_buffers.iter().enumerate().flat_map(
3521 |(index, (buffer, _diff))| {
3522 let file = buffer.read(cx).file()?;
3523 let path = file.path();
3524
3525 let file_path = path.parent().and_then(|parent| {
3526 let parent_str = parent.to_string_lossy();
3527
3528 if parent_str.is_empty() {
3529 None
3530 } else {
3531 Some(
3532 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
3533 .color(Color::Muted)
3534 .size(LabelSize::XSmall)
3535 .buffer_font(cx),
3536 )
3537 }
3538 });
3539
3540 let file_name = path.file_name().map(|name| {
3541 Label::new(name.to_string_lossy().to_string())
3542 .size(LabelSize::XSmall)
3543 .buffer_font(cx)
3544 });
3545
3546 let file_icon = FileIcons::get_icon(path, cx)
3547 .map(Icon::from_path)
3548 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
3549 .unwrap_or_else(|| {
3550 Icon::new(IconName::File)
3551 .color(Color::Muted)
3552 .size(IconSize::Small)
3553 });
3554
3555 let overlay_gradient = linear_gradient(
3556 90.,
3557 linear_color_stop(editor_bg_color, 1.),
3558 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
3559 );
3560
3561 let element = h_flex()
3562 .group("edited-code")
3563 .id(("file-container", index))
3564 .py_1()
3565 .pl_2()
3566 .pr_1()
3567 .gap_2()
3568 .justify_between()
3569 .bg(editor_bg_color)
3570 .when(index < changed_buffers.len() - 1, |parent| {
3571 parent.border_color(cx.theme().colors().border).border_b_1()
3572 })
3573 .child(
3574 h_flex()
3575 .relative()
3576 .id(("file-name", index))
3577 .pr_8()
3578 .gap_1p5()
3579 .max_w_full()
3580 .overflow_x_scroll()
3581 .child(file_icon)
3582 .child(h_flex().gap_0p5().children(file_name).children(file_path))
3583 .child(
3584 div()
3585 .absolute()
3586 .h_full()
3587 .w_12()
3588 .top_0()
3589 .bottom_0()
3590 .right_0()
3591 .bg(overlay_gradient),
3592 )
3593 .on_click({
3594 let buffer = buffer.clone();
3595 cx.listener(move |this, _, window, cx| {
3596 this.open_edited_buffer(&buffer, window, cx);
3597 })
3598 }),
3599 )
3600 .child(
3601 h_flex()
3602 .gap_1()
3603 .visible_on_hover("edited-code")
3604 .child(
3605 Button::new("review", "Review")
3606 .label_size(LabelSize::Small)
3607 .on_click({
3608 let buffer = buffer.clone();
3609 cx.listener(move |this, _, window, cx| {
3610 this.open_edited_buffer(&buffer, window, cx);
3611 })
3612 }),
3613 )
3614 .child(Divider::vertical().color(DividerColor::BorderVariant))
3615 .child(
3616 Button::new("reject-file", "Reject")
3617 .label_size(LabelSize::Small)
3618 .disabled(pending_edits)
3619 .on_click({
3620 let buffer = buffer.clone();
3621 let action_log = action_log.clone();
3622 move |_, _, cx| {
3623 action_log.update(cx, |action_log, cx| {
3624 action_log
3625 .reject_edits_in_ranges(
3626 buffer.clone(),
3627 vec![Anchor::MIN..Anchor::MAX],
3628 cx,
3629 )
3630 .detach_and_log_err(cx);
3631 })
3632 }
3633 }),
3634 )
3635 .child(
3636 Button::new("keep-file", "Keep")
3637 .label_size(LabelSize::Small)
3638 .disabled(pending_edits)
3639 .on_click({
3640 let buffer = buffer.clone();
3641 let action_log = action_log.clone();
3642 move |_, _, cx| {
3643 action_log.update(cx, |action_log, cx| {
3644 action_log.keep_edits_in_range(
3645 buffer.clone(),
3646 Anchor::MIN..Anchor::MAX,
3647 cx,
3648 );
3649 })
3650 }
3651 }),
3652 ),
3653 );
3654
3655 Some(element)
3656 },
3657 ))
3658 }
3659
3660 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
3661 let focus_handle = self.message_editor.focus_handle(cx);
3662 let editor_bg_color = cx.theme().colors().editor_background;
3663 let (expand_icon, expand_tooltip) = if self.editor_expanded {
3664 (IconName::Minimize, "Minimize Message Editor")
3665 } else {
3666 (IconName::Maximize, "Expand Message Editor")
3667 };
3668
3669 let backdrop = div()
3670 .size_full()
3671 .absolute()
3672 .inset_0()
3673 .bg(cx.theme().colors().panel_background)
3674 .opacity(0.8)
3675 .block_mouse_except_scroll();
3676
3677 let enable_editor = match self.thread_state {
3678 ThreadState::Loading { .. } | ThreadState::Ready { .. } => true,
3679 ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false,
3680 };
3681
3682 v_flex()
3683 .on_action(cx.listener(Self::expand_message_editor))
3684 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
3685 if let Some(profile_selector) = this.profile_selector.as_ref() {
3686 profile_selector.read(cx).menu_handle().toggle(window, cx);
3687 }
3688 }))
3689 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
3690 if let Some(model_selector) = this.model_selector.as_ref() {
3691 model_selector
3692 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
3693 }
3694 }))
3695 .p_2()
3696 .gap_2()
3697 .border_t_1()
3698 .border_color(cx.theme().colors().border)
3699 .bg(editor_bg_color)
3700 .when(self.editor_expanded, |this| {
3701 this.h(vh(0.8, window)).size_full().justify_between()
3702 })
3703 .child(
3704 v_flex()
3705 .relative()
3706 .size_full()
3707 .pt_1()
3708 .pr_2p5()
3709 .child(self.message_editor.clone())
3710 .child(
3711 h_flex()
3712 .absolute()
3713 .top_0()
3714 .right_0()
3715 .opacity(0.5)
3716 .hover(|this| this.opacity(1.0))
3717 .child(
3718 IconButton::new("toggle-height", expand_icon)
3719 .icon_size(IconSize::Small)
3720 .icon_color(Color::Muted)
3721 .tooltip({
3722 move |window, cx| {
3723 Tooltip::for_action_in(
3724 expand_tooltip,
3725 &ExpandMessageEditor,
3726 &focus_handle,
3727 window,
3728 cx,
3729 )
3730 }
3731 })
3732 .on_click(cx.listener(|_, _, window, cx| {
3733 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
3734 })),
3735 ),
3736 ),
3737 )
3738 .child(
3739 h_flex()
3740 .flex_none()
3741 .flex_wrap()
3742 .justify_between()
3743 .child(
3744 h_flex()
3745 .child(self.render_follow_toggle(cx))
3746 .children(self.render_burn_mode_toggle(cx)),
3747 )
3748 .child(
3749 h_flex()
3750 .gap_1()
3751 .children(self.render_token_usage(cx))
3752 .children(self.profile_selector.clone())
3753 .children(self.model_selector.clone())
3754 .child(self.render_send_button(cx)),
3755 ),
3756 )
3757 .when(!enable_editor, |this| this.child(backdrop))
3758 .into_any()
3759 }
3760
3761 pub(crate) fn as_native_connection(
3762 &self,
3763 cx: &App,
3764 ) -> Option<Rc<agent2::NativeAgentConnection>> {
3765 let acp_thread = self.thread()?.read(cx);
3766 acp_thread.connection().clone().downcast()
3767 }
3768
3769 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
3770 let acp_thread = self.thread()?.read(cx);
3771 self.as_native_connection(cx)?
3772 .thread(acp_thread.session_id(), cx)
3773 }
3774
3775 fn is_using_zed_ai_models(&self, cx: &App) -> bool {
3776 self.as_native_thread(cx)
3777 .and_then(|thread| thread.read(cx).model())
3778 .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID)
3779 }
3780
3781 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
3782 let thread = self.thread()?.read(cx);
3783 let usage = thread.token_usage()?;
3784 let is_generating = thread.status() != ThreadStatus::Idle;
3785
3786 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
3787 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
3788
3789 Some(
3790 h_flex()
3791 .flex_shrink_0()
3792 .gap_0p5()
3793 .mr_1p5()
3794 .child(
3795 Label::new(used)
3796 .size(LabelSize::Small)
3797 .color(Color::Muted)
3798 .map(|label| {
3799 if is_generating {
3800 label
3801 .with_animation(
3802 "used-tokens-label",
3803 Animation::new(Duration::from_secs(2))
3804 .repeat()
3805 .with_easing(pulsating_between(0.3, 0.8)),
3806 |label, delta| label.alpha(delta),
3807 )
3808 .into_any()
3809 } else {
3810 label.into_any_element()
3811 }
3812 }),
3813 )
3814 .child(
3815 Label::new("/")
3816 .size(LabelSize::Small)
3817 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
3818 )
3819 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
3820 )
3821 }
3822
3823 fn toggle_burn_mode(
3824 &mut self,
3825 _: &ToggleBurnMode,
3826 _window: &mut Window,
3827 cx: &mut Context<Self>,
3828 ) {
3829 let Some(thread) = self.as_native_thread(cx) else {
3830 return;
3831 };
3832
3833 thread.update(cx, |thread, cx| {
3834 let current_mode = thread.completion_mode();
3835 thread.set_completion_mode(
3836 match current_mode {
3837 CompletionMode::Burn => CompletionMode::Normal,
3838 CompletionMode::Normal => CompletionMode::Burn,
3839 },
3840 cx,
3841 );
3842 });
3843 }
3844
3845 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
3846 let Some(thread) = self.thread() else {
3847 return;
3848 };
3849 let action_log = thread.read(cx).action_log().clone();
3850 action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx));
3851 }
3852
3853 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
3854 let Some(thread) = self.thread() else {
3855 return;
3856 };
3857 let action_log = thread.read(cx).action_log().clone();
3858 action_log
3859 .update(cx, |action_log, cx| action_log.reject_all_edits(cx))
3860 .detach();
3861 }
3862
3863 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3864 let thread = self.as_native_thread(cx)?.read(cx);
3865
3866 if thread
3867 .model()
3868 .is_none_or(|model| !model.supports_burn_mode())
3869 {
3870 return None;
3871 }
3872
3873 let active_completion_mode = thread.completion_mode();
3874 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
3875 let icon = if burn_mode_enabled {
3876 IconName::ZedBurnModeOn
3877 } else {
3878 IconName::ZedBurnMode
3879 };
3880
3881 Some(
3882 IconButton::new("burn-mode", icon)
3883 .icon_size(IconSize::Small)
3884 .icon_color(Color::Muted)
3885 .toggle_state(burn_mode_enabled)
3886 .selected_icon_color(Color::Error)
3887 .on_click(cx.listener(|this, _event, window, cx| {
3888 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
3889 }))
3890 .tooltip(move |_window, cx| {
3891 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
3892 .into()
3893 })
3894 .into_any_element(),
3895 )
3896 }
3897
3898 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3899 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
3900 let is_generating = self
3901 .thread()
3902 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
3903
3904 if self.is_loading_contents {
3905 div()
3906 .id("loading-message-content")
3907 .px_1()
3908 .tooltip(Tooltip::text("Loading Added Context…"))
3909 .child(loading_contents_spinner(IconSize::default()))
3910 .into_any_element()
3911 } else if is_generating && is_editor_empty {
3912 IconButton::new("stop-generation", IconName::Stop)
3913 .icon_color(Color::Error)
3914 .style(ButtonStyle::Tinted(ui::TintColor::Error))
3915 .tooltip(move |window, cx| {
3916 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
3917 })
3918 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3919 .into_any_element()
3920 } else {
3921 let send_btn_tooltip = if is_editor_empty && !is_generating {
3922 "Type to Send"
3923 } else if is_generating {
3924 "Stop and Send Message"
3925 } else {
3926 "Send"
3927 };
3928
3929 IconButton::new("send-message", IconName::Send)
3930 .style(ButtonStyle::Filled)
3931 .map(|this| {
3932 if is_editor_empty && !is_generating {
3933 this.disabled(true).icon_color(Color::Muted)
3934 } else {
3935 this.icon_color(Color::Accent)
3936 }
3937 })
3938 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
3939 .on_click(cx.listener(|this, _, window, cx| {
3940 this.send(window, cx);
3941 }))
3942 .into_any_element()
3943 }
3944 }
3945
3946 fn is_following(&self, cx: &App) -> bool {
3947 match self.thread().map(|thread| thread.read(cx).status()) {
3948 Some(ThreadStatus::Generating) => self
3949 .workspace
3950 .read_with(cx, |workspace, _| {
3951 workspace.is_being_followed(CollaboratorId::Agent)
3952 })
3953 .unwrap_or(false),
3954 _ => self.should_be_following,
3955 }
3956 }
3957
3958 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3959 let following = self.is_following(cx);
3960
3961 self.should_be_following = !following;
3962 if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
3963 self.workspace
3964 .update(cx, |workspace, cx| {
3965 if following {
3966 workspace.unfollow(CollaboratorId::Agent, window, cx);
3967 } else {
3968 workspace.follow(CollaboratorId::Agent, window, cx);
3969 }
3970 })
3971 .ok();
3972 }
3973
3974 telemetry::event!("Follow Agent Selected", following = !following);
3975 }
3976
3977 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
3978 let following = self.is_following(cx);
3979
3980 let tooltip_label = if following {
3981 if self.agent.name() == "Zed Agent" {
3982 format!("Stop Following the {}", self.agent.name())
3983 } else {
3984 format!("Stop Following {}", self.agent.name())
3985 }
3986 } else {
3987 if self.agent.name() == "Zed Agent" {
3988 format!("Follow the {}", self.agent.name())
3989 } else {
3990 format!("Follow {}", self.agent.name())
3991 }
3992 };
3993
3994 IconButton::new("follow-agent", IconName::Crosshair)
3995 .icon_size(IconSize::Small)
3996 .icon_color(Color::Muted)
3997 .toggle_state(following)
3998 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
3999 .tooltip(move |window, cx| {
4000 if following {
4001 Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx)
4002 } else {
4003 Tooltip::with_meta(
4004 tooltip_label.clone(),
4005 Some(&Follow),
4006 "Track the agent's location as it reads and edits files.",
4007 window,
4008 cx,
4009 )
4010 }
4011 })
4012 .on_click(cx.listener(move |this, _, window, cx| {
4013 this.toggle_following(window, cx);
4014 }))
4015 }
4016
4017 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
4018 let workspace = self.workspace.clone();
4019 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
4020 Self::open_link(text, &workspace, window, cx);
4021 })
4022 }
4023
4024 fn open_link(
4025 url: SharedString,
4026 workspace: &WeakEntity<Workspace>,
4027 window: &mut Window,
4028 cx: &mut App,
4029 ) {
4030 let Some(workspace) = workspace.upgrade() else {
4031 cx.open_url(&url);
4032 return;
4033 };
4034
4035 if let Some(mention) = MentionUri::parse(&url).log_err() {
4036 workspace.update(cx, |workspace, cx| match mention {
4037 MentionUri::File { abs_path } => {
4038 let project = workspace.project();
4039 let Some(path) =
4040 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
4041 else {
4042 return;
4043 };
4044
4045 workspace
4046 .open_path(path, None, true, window, cx)
4047 .detach_and_log_err(cx);
4048 }
4049 MentionUri::PastedImage => {}
4050 MentionUri::Directory { abs_path } => {
4051 let project = workspace.project();
4052 let Some(entry) = project.update(cx, |project, cx| {
4053 let path = project.find_project_path(abs_path, cx)?;
4054 project.entry_for_path(&path, cx)
4055 }) else {
4056 return;
4057 };
4058
4059 project.update(cx, |_, cx| {
4060 cx.emit(project::Event::RevealInProjectPanel(entry.id));
4061 });
4062 }
4063 MentionUri::Symbol {
4064 abs_path: path,
4065 line_range,
4066 ..
4067 }
4068 | MentionUri::Selection {
4069 abs_path: Some(path),
4070 line_range,
4071 } => {
4072 let project = workspace.project();
4073 let Some((path, _)) = project.update(cx, |project, cx| {
4074 let path = project.find_project_path(path, cx)?;
4075 let entry = project.entry_for_path(&path, cx)?;
4076 Some((path, entry))
4077 }) else {
4078 return;
4079 };
4080
4081 let item = workspace.open_path(path, None, true, window, cx);
4082 window
4083 .spawn(cx, async move |cx| {
4084 let Some(editor) = item.await?.downcast::<Editor>() else {
4085 return Ok(());
4086 };
4087 let range = Point::new(*line_range.start(), 0)
4088 ..Point::new(*line_range.start(), 0);
4089 editor
4090 .update_in(cx, |editor, window, cx| {
4091 editor.change_selections(
4092 SelectionEffects::scroll(Autoscroll::center()),
4093 window,
4094 cx,
4095 |s| s.select_ranges(vec![range]),
4096 );
4097 })
4098 .ok();
4099 anyhow::Ok(())
4100 })
4101 .detach_and_log_err(cx);
4102 }
4103 MentionUri::Selection { abs_path: None, .. } => {}
4104 MentionUri::Thread { id, name } => {
4105 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4106 panel.update(cx, |panel, cx| {
4107 panel.load_agent_thread(
4108 DbThreadMetadata {
4109 id,
4110 title: name.into(),
4111 updated_at: Default::default(),
4112 },
4113 window,
4114 cx,
4115 )
4116 });
4117 }
4118 }
4119 MentionUri::TextThread { path, .. } => {
4120 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4121 panel.update(cx, |panel, cx| {
4122 panel
4123 .open_saved_prompt_editor(path.as_path().into(), window, cx)
4124 .detach_and_log_err(cx);
4125 });
4126 }
4127 }
4128 MentionUri::Rule { id, .. } => {
4129 let PromptId::User { uuid } = id else {
4130 return;
4131 };
4132 window.dispatch_action(
4133 Box::new(OpenRulesLibrary {
4134 prompt_to_select: Some(uuid.0),
4135 }),
4136 cx,
4137 )
4138 }
4139 MentionUri::Fetch { url } => {
4140 cx.open_url(url.as_str());
4141 }
4142 })
4143 } else {
4144 cx.open_url(&url);
4145 }
4146 }
4147
4148 fn open_tool_call_location(
4149 &self,
4150 entry_ix: usize,
4151 location_ix: usize,
4152 window: &mut Window,
4153 cx: &mut Context<Self>,
4154 ) -> Option<()> {
4155 let (tool_call_location, agent_location) = self
4156 .thread()?
4157 .read(cx)
4158 .entries()
4159 .get(entry_ix)?
4160 .location(location_ix)?;
4161
4162 let project_path = self
4163 .project
4164 .read(cx)
4165 .find_project_path(&tool_call_location.path, cx)?;
4166
4167 let open_task = self
4168 .workspace
4169 .update(cx, |workspace, cx| {
4170 workspace.open_path(project_path, None, true, window, cx)
4171 })
4172 .log_err()?;
4173 window
4174 .spawn(cx, async move |cx| {
4175 let item = open_task.await?;
4176
4177 let Some(active_editor) = item.downcast::<Editor>() else {
4178 return anyhow::Ok(());
4179 };
4180
4181 active_editor.update_in(cx, |editor, window, cx| {
4182 let multibuffer = editor.buffer().read(cx);
4183 let buffer = multibuffer.as_singleton();
4184 if agent_location.buffer.upgrade() == buffer {
4185 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
4186 let anchor = editor::Anchor::in_buffer(
4187 excerpt_id.unwrap(),
4188 buffer.unwrap().read(cx).remote_id(),
4189 agent_location.position,
4190 );
4191 editor.change_selections(Default::default(), window, cx, |selections| {
4192 selections.select_anchor_ranges([anchor..anchor]);
4193 })
4194 } else {
4195 let row = tool_call_location.line.unwrap_or_default();
4196 editor.change_selections(Default::default(), window, cx, |selections| {
4197 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
4198 })
4199 }
4200 })?;
4201
4202 anyhow::Ok(())
4203 })
4204 .detach_and_log_err(cx);
4205
4206 None
4207 }
4208
4209 pub fn open_thread_as_markdown(
4210 &self,
4211 workspace: Entity<Workspace>,
4212 window: &mut Window,
4213 cx: &mut App,
4214 ) -> Task<Result<()>> {
4215 let markdown_language_task = workspace
4216 .read(cx)
4217 .app_state()
4218 .languages
4219 .language_for_name("Markdown");
4220
4221 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
4222 let thread = thread.read(cx);
4223 (thread.title().to_string(), thread.to_markdown(cx))
4224 } else {
4225 return Task::ready(Ok(()));
4226 };
4227
4228 window.spawn(cx, async move |cx| {
4229 let markdown_language = markdown_language_task.await?;
4230
4231 workspace.update_in(cx, |workspace, window, cx| {
4232 let project = workspace.project().clone();
4233
4234 if !project.read(cx).is_local() {
4235 bail!("failed to open active thread as markdown in remote project");
4236 }
4237
4238 let buffer = project.update(cx, |project, cx| {
4239 project.create_local_buffer(&markdown, Some(markdown_language), cx)
4240 });
4241 let buffer = cx.new(|cx| {
4242 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
4243 });
4244
4245 workspace.add_item_to_active_pane(
4246 Box::new(cx.new(|cx| {
4247 let mut editor =
4248 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4249 editor.set_breadcrumb_header(thread_summary);
4250 editor
4251 })),
4252 None,
4253 true,
4254 window,
4255 cx,
4256 );
4257
4258 anyhow::Ok(())
4259 })??;
4260 anyhow::Ok(())
4261 })
4262 }
4263
4264 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
4265 self.list_state.scroll_to(ListOffset::default());
4266 cx.notify();
4267 }
4268
4269 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
4270 if let Some(thread) = self.thread() {
4271 let entry_count = thread.read(cx).entries().len();
4272 self.list_state.reset(entry_count);
4273 cx.notify();
4274 }
4275 }
4276
4277 fn notify_with_sound(
4278 &mut self,
4279 caption: impl Into<SharedString>,
4280 icon: IconName,
4281 window: &mut Window,
4282 cx: &mut Context<Self>,
4283 ) {
4284 self.play_notification_sound(window, cx);
4285 self.show_notification(caption, icon, window, cx);
4286 }
4287
4288 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
4289 let settings = AgentSettings::get_global(cx);
4290 if settings.play_sound_when_agent_done && !window.is_window_active() {
4291 Audio::play_sound(Sound::AgentDone, cx);
4292 }
4293 }
4294
4295 fn show_notification(
4296 &mut self,
4297 caption: impl Into<SharedString>,
4298 icon: IconName,
4299 window: &mut Window,
4300 cx: &mut Context<Self>,
4301 ) {
4302 if window.is_window_active() || !self.notifications.is_empty() {
4303 return;
4304 }
4305
4306 // TODO: Change this once we have title summarization for external agents.
4307 let title = self.agent.name();
4308
4309 match AgentSettings::get_global(cx).notify_when_agent_waiting {
4310 NotifyWhenAgentWaiting::PrimaryScreen => {
4311 if let Some(primary) = cx.primary_display() {
4312 self.pop_up(icon, caption.into(), title, window, primary, cx);
4313 }
4314 }
4315 NotifyWhenAgentWaiting::AllScreens => {
4316 let caption = caption.into();
4317 for screen in cx.displays() {
4318 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
4319 }
4320 }
4321 NotifyWhenAgentWaiting::Never => {
4322 // Don't show anything
4323 }
4324 }
4325 }
4326
4327 fn pop_up(
4328 &mut self,
4329 icon: IconName,
4330 caption: SharedString,
4331 title: SharedString,
4332 window: &mut Window,
4333 screen: Rc<dyn PlatformDisplay>,
4334 cx: &mut Context<Self>,
4335 ) {
4336 let options = AgentNotification::window_options(screen, cx);
4337
4338 let project_name = self.workspace.upgrade().and_then(|workspace| {
4339 workspace
4340 .read(cx)
4341 .project()
4342 .read(cx)
4343 .visible_worktrees(cx)
4344 .next()
4345 .map(|worktree| worktree.read(cx).root_name().to_string())
4346 });
4347
4348 if let Some(screen_window) = cx
4349 .open_window(options, |_, cx| {
4350 cx.new(|_| {
4351 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
4352 })
4353 })
4354 .log_err()
4355 && let Some(pop_up) = screen_window.entity(cx).log_err()
4356 {
4357 self.notification_subscriptions
4358 .entry(screen_window)
4359 .or_insert_with(Vec::new)
4360 .push(cx.subscribe_in(&pop_up, window, {
4361 |this, _, event, window, cx| match event {
4362 AgentNotificationEvent::Accepted => {
4363 let handle = window.window_handle();
4364 cx.activate(true);
4365
4366 let workspace_handle = this.workspace.clone();
4367
4368 // If there are multiple Zed windows, activate the correct one.
4369 cx.defer(move |cx| {
4370 handle
4371 .update(cx, |_view, window, _cx| {
4372 window.activate_window();
4373
4374 if let Some(workspace) = workspace_handle.upgrade() {
4375 workspace.update(_cx, |workspace, cx| {
4376 workspace.focus_panel::<AgentPanel>(window, cx);
4377 });
4378 }
4379 })
4380 .log_err();
4381 });
4382
4383 this.dismiss_notifications(cx);
4384 }
4385 AgentNotificationEvent::Dismissed => {
4386 this.dismiss_notifications(cx);
4387 }
4388 }
4389 }));
4390
4391 self.notifications.push(screen_window);
4392
4393 // If the user manually refocuses the original window, dismiss the popup.
4394 self.notification_subscriptions
4395 .entry(screen_window)
4396 .or_insert_with(Vec::new)
4397 .push({
4398 let pop_up_weak = pop_up.downgrade();
4399
4400 cx.observe_window_activation(window, move |_, window, cx| {
4401 if window.is_window_active()
4402 && let Some(pop_up) = pop_up_weak.upgrade()
4403 {
4404 pop_up.update(cx, |_, cx| {
4405 cx.emit(AgentNotificationEvent::Dismissed);
4406 });
4407 }
4408 })
4409 });
4410 }
4411 }
4412
4413 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
4414 for window in self.notifications.drain(..) {
4415 window
4416 .update(cx, |_, window, _| {
4417 window.remove_window();
4418 })
4419 .ok();
4420
4421 self.notification_subscriptions.remove(&window);
4422 }
4423 }
4424
4425 fn render_thread_controls(
4426 &self,
4427 thread: &Entity<AcpThread>,
4428 cx: &Context<Self>,
4429 ) -> impl IntoElement {
4430 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
4431 if is_generating {
4432 return h_flex().id("thread-controls-container").child(
4433 div()
4434 .py_2()
4435 .px(rems_from_px(22.))
4436 .child(SpinnerLabel::new().size(LabelSize::Small)),
4437 );
4438 }
4439
4440 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4441 .shape(ui::IconButtonShape::Square)
4442 .icon_size(IconSize::Small)
4443 .icon_color(Color::Ignored)
4444 .tooltip(Tooltip::text("Open Thread as Markdown"))
4445 .on_click(cx.listener(move |this, _, window, cx| {
4446 if let Some(workspace) = this.workspace.upgrade() {
4447 this.open_thread_as_markdown(workspace, window, cx)
4448 .detach_and_log_err(cx);
4449 }
4450 }));
4451
4452 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4453 .shape(ui::IconButtonShape::Square)
4454 .icon_size(IconSize::Small)
4455 .icon_color(Color::Ignored)
4456 .tooltip(Tooltip::text("Scroll To Top"))
4457 .on_click(cx.listener(move |this, _, _, cx| {
4458 this.scroll_to_top(cx);
4459 }));
4460
4461 let mut container = h_flex()
4462 .id("thread-controls-container")
4463 .group("thread-controls-container")
4464 .w_full()
4465 .py_2()
4466 .px_5()
4467 .gap_px()
4468 .opacity(0.6)
4469 .hover(|style| style.opacity(1.))
4470 .flex_wrap()
4471 .justify_end();
4472
4473 if AgentSettings::get_global(cx).enable_feedback
4474 && self
4475 .thread()
4476 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
4477 {
4478 let feedback = self.thread_feedback.feedback;
4479
4480 container = container
4481 .child(
4482 div().visible_on_hover("thread-controls-container").child(
4483 Label::new(match feedback {
4484 Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
4485 Some(ThreadFeedback::Negative) => {
4486 "We appreciate your feedback and will use it to improve."
4487 }
4488 None => {
4489 "Rating the thread sends all of your current conversation to the Zed team."
4490 }
4491 })
4492 .color(Color::Muted)
4493 .size(LabelSize::XSmall)
4494 .truncate(),
4495 ),
4496 )
4497 .child(
4498 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4499 .shape(ui::IconButtonShape::Square)
4500 .icon_size(IconSize::Small)
4501 .icon_color(match feedback {
4502 Some(ThreadFeedback::Positive) => Color::Accent,
4503 _ => Color::Ignored,
4504 })
4505 .tooltip(Tooltip::text("Helpful Response"))
4506 .on_click(cx.listener(move |this, _, window, cx| {
4507 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
4508 })),
4509 )
4510 .child(
4511 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4512 .shape(ui::IconButtonShape::Square)
4513 .icon_size(IconSize::Small)
4514 .icon_color(match feedback {
4515 Some(ThreadFeedback::Negative) => Color::Accent,
4516 _ => Color::Ignored,
4517 })
4518 .tooltip(Tooltip::text("Not Helpful"))
4519 .on_click(cx.listener(move |this, _, window, cx| {
4520 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
4521 })),
4522 );
4523 }
4524
4525 container.child(open_as_markdown).child(scroll_to_top)
4526 }
4527
4528 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
4529 h_flex()
4530 .key_context("AgentFeedbackMessageEditor")
4531 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4532 this.thread_feedback.dismiss_comments();
4533 cx.notify();
4534 }))
4535 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4536 this.submit_feedback_message(cx);
4537 }))
4538 .p_2()
4539 .mb_2()
4540 .mx_5()
4541 .gap_1()
4542 .rounded_md()
4543 .border_1()
4544 .border_color(cx.theme().colors().border)
4545 .bg(cx.theme().colors().editor_background)
4546 .child(div().w_full().child(editor))
4547 .child(
4548 h_flex()
4549 .child(
4550 IconButton::new("dismiss-feedback-message", IconName::Close)
4551 .icon_color(Color::Error)
4552 .icon_size(IconSize::XSmall)
4553 .shape(ui::IconButtonShape::Square)
4554 .on_click(cx.listener(move |this, _, _window, cx| {
4555 this.thread_feedback.dismiss_comments();
4556 cx.notify();
4557 })),
4558 )
4559 .child(
4560 IconButton::new("submit-feedback-message", IconName::Return)
4561 .icon_size(IconSize::XSmall)
4562 .shape(ui::IconButtonShape::Square)
4563 .on_click(cx.listener(move |this, _, _window, cx| {
4564 this.submit_feedback_message(cx);
4565 })),
4566 ),
4567 )
4568 }
4569
4570 fn handle_feedback_click(
4571 &mut self,
4572 feedback: ThreadFeedback,
4573 window: &mut Window,
4574 cx: &mut Context<Self>,
4575 ) {
4576 let Some(thread) = self.thread().cloned() else {
4577 return;
4578 };
4579
4580 self.thread_feedback.submit(thread, feedback, window, cx);
4581 cx.notify();
4582 }
4583
4584 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4585 let Some(thread) = self.thread().cloned() else {
4586 return;
4587 };
4588
4589 self.thread_feedback.submit_comments(thread, cx);
4590 cx.notify();
4591 }
4592
4593 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
4594 div()
4595 .id("acp-thread-scrollbar")
4596 .occlude()
4597 .on_mouse_move(cx.listener(|_, _, _, cx| {
4598 cx.notify();
4599 cx.stop_propagation()
4600 }))
4601 .on_hover(|_, _, cx| {
4602 cx.stop_propagation();
4603 })
4604 .on_any_mouse_down(|_, _, cx| {
4605 cx.stop_propagation();
4606 })
4607 .on_mouse_up(
4608 MouseButton::Left,
4609 cx.listener(|_, _, _, cx| {
4610 cx.stop_propagation();
4611 }),
4612 )
4613 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4614 cx.notify();
4615 }))
4616 .h_full()
4617 .absolute()
4618 .right_1()
4619 .top_1()
4620 .bottom_0()
4621 .w(px(12.))
4622 .cursor_default()
4623 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
4624 }
4625
4626 fn render_token_limit_callout(
4627 &self,
4628 line_height: Pixels,
4629 cx: &mut Context<Self>,
4630 ) -> Option<Callout> {
4631 let token_usage = self.thread()?.read(cx).token_usage()?;
4632 let ratio = token_usage.ratio();
4633
4634 let (severity, title) = match ratio {
4635 acp_thread::TokenUsageRatio::Normal => return None,
4636 acp_thread::TokenUsageRatio::Warning => {
4637 (Severity::Warning, "Thread reaching the token limit soon")
4638 }
4639 acp_thread::TokenUsageRatio::Exceeded => {
4640 (Severity::Error, "Thread reached the token limit")
4641 }
4642 };
4643
4644 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
4645 thread.read(cx).completion_mode() == CompletionMode::Normal
4646 && thread
4647 .read(cx)
4648 .model()
4649 .is_some_and(|model| model.supports_burn_mode())
4650 });
4651
4652 let description = if burn_mode_available {
4653 "To continue, start a new thread from a summary or turn Burn Mode on."
4654 } else {
4655 "To continue, start a new thread from a summary."
4656 };
4657
4658 Some(
4659 Callout::new()
4660 .severity(severity)
4661 .line_height(line_height)
4662 .title(title)
4663 .description(description)
4664 .actions_slot(
4665 h_flex()
4666 .gap_0p5()
4667 .child(
4668 Button::new("start-new-thread", "Start New Thread")
4669 .label_size(LabelSize::Small)
4670 .on_click(cx.listener(|this, _, window, cx| {
4671 let Some(thread) = this.thread() else {
4672 return;
4673 };
4674 let session_id = thread.read(cx).session_id().clone();
4675 window.dispatch_action(
4676 crate::NewNativeAgentThreadFromSummary {
4677 from_session_id: session_id,
4678 }
4679 .boxed_clone(),
4680 cx,
4681 );
4682 })),
4683 )
4684 .when(burn_mode_available, |this| {
4685 this.child(
4686 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
4687 .icon_size(IconSize::XSmall)
4688 .on_click(cx.listener(|this, _event, window, cx| {
4689 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4690 })),
4691 )
4692 }),
4693 ),
4694 )
4695 }
4696
4697 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
4698 if !self.is_using_zed_ai_models(cx) {
4699 return None;
4700 }
4701
4702 let user_store = self.project.read(cx).user_store().read(cx);
4703 if user_store.is_usage_based_billing_enabled() {
4704 return None;
4705 }
4706
4707 let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
4708
4709 let usage = user_store.model_request_usage()?;
4710
4711 Some(
4712 div()
4713 .child(UsageCallout::new(plan, usage))
4714 .line_height(line_height),
4715 )
4716 }
4717
4718 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4719 self.entry_view_state.update(cx, |entry_view_state, cx| {
4720 entry_view_state.settings_changed(cx);
4721 });
4722 }
4723
4724 pub(crate) fn insert_dragged_files(
4725 &self,
4726 paths: Vec<project::ProjectPath>,
4727 added_worktrees: Vec<Entity<project::Worktree>>,
4728 window: &mut Window,
4729 cx: &mut Context<Self>,
4730 ) {
4731 self.message_editor.update(cx, |message_editor, cx| {
4732 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
4733 })
4734 }
4735
4736 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
4737 self.message_editor.update(cx, |message_editor, cx| {
4738 message_editor.insert_selections(window, cx);
4739 })
4740 }
4741
4742 fn render_thread_retry_status_callout(
4743 &self,
4744 _window: &mut Window,
4745 _cx: &mut Context<Self>,
4746 ) -> Option<Callout> {
4747 let state = self.thread_retry_status.as_ref()?;
4748
4749 let next_attempt_in = state
4750 .duration
4751 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
4752 if next_attempt_in.is_zero() {
4753 return None;
4754 }
4755
4756 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
4757
4758 let retry_message = if state.max_attempts == 1 {
4759 if next_attempt_in_secs == 1 {
4760 "Retrying. Next attempt in 1 second.".to_string()
4761 } else {
4762 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
4763 }
4764 } else if next_attempt_in_secs == 1 {
4765 format!(
4766 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
4767 state.attempt, state.max_attempts,
4768 )
4769 } else {
4770 format!(
4771 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
4772 state.attempt, state.max_attempts,
4773 )
4774 };
4775
4776 Some(
4777 Callout::new()
4778 .severity(Severity::Warning)
4779 .title(state.last_error.clone())
4780 .description(retry_message),
4781 )
4782 }
4783
4784 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
4785 let content = match self.thread_error.as_ref()? {
4786 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
4787 ThreadError::Refusal => self.render_refusal_error(cx),
4788 ThreadError::AuthenticationRequired(error) => {
4789 self.render_authentication_required_error(error.clone(), cx)
4790 }
4791 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
4792 ThreadError::ModelRequestLimitReached(plan) => {
4793 self.render_model_request_limit_reached_error(*plan, cx)
4794 }
4795 ThreadError::ToolUseLimitReached => {
4796 self.render_tool_use_limit_reached_error(window, cx)?
4797 }
4798 };
4799
4800 Some(div().child(content))
4801 }
4802
4803 fn get_current_model_name(&self, cx: &App) -> SharedString {
4804 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
4805 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
4806 // This provides better clarity about what refused the request
4807 if self
4808 .agent
4809 .clone()
4810 .downcast::<agent2::NativeAgentServer>()
4811 .is_some()
4812 {
4813 // Native agent - use the model name
4814 self.model_selector
4815 .as_ref()
4816 .and_then(|selector| selector.read(cx).active_model_name(cx))
4817 .unwrap_or_else(|| SharedString::from("The model"))
4818 } else {
4819 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
4820 self.agent.name()
4821 }
4822 }
4823
4824 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
4825 let model_or_agent_name = self.get_current_model_name(cx);
4826 let refusal_message = format!(
4827 "{} 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.",
4828 model_or_agent_name
4829 );
4830
4831 Callout::new()
4832 .severity(Severity::Error)
4833 .title("Request Refused")
4834 .icon(IconName::XCircle)
4835 .description(refusal_message.clone())
4836 .actions_slot(self.create_copy_button(&refusal_message))
4837 .dismiss_action(self.dismiss_error_button(cx))
4838 }
4839
4840 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
4841 let can_resume = self
4842 .thread()
4843 .map_or(false, |thread| thread.read(cx).can_resume(cx));
4844
4845 let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| {
4846 let thread = thread.read(cx);
4847 let supports_burn_mode = thread
4848 .model()
4849 .map_or(false, |model| model.supports_burn_mode());
4850 supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
4851 });
4852
4853 Callout::new()
4854 .severity(Severity::Error)
4855 .title("Error")
4856 .icon(IconName::XCircle)
4857 .description(error.clone())
4858 .actions_slot(
4859 h_flex()
4860 .gap_0p5()
4861 .when(can_resume && can_enable_burn_mode, |this| {
4862 this.child(
4863 Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry")
4864 .icon(IconName::ZedBurnMode)
4865 .icon_position(IconPosition::Start)
4866 .icon_size(IconSize::Small)
4867 .label_size(LabelSize::Small)
4868 .on_click(cx.listener(|this, _, window, cx| {
4869 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4870 this.resume_chat(cx);
4871 })),
4872 )
4873 })
4874 .when(can_resume, |this| {
4875 this.child(
4876 Button::new("retry", "Retry")
4877 .icon(IconName::RotateCw)
4878 .icon_position(IconPosition::Start)
4879 .icon_size(IconSize::Small)
4880 .label_size(LabelSize::Small)
4881 .on_click(cx.listener(|this, _, _window, cx| {
4882 this.resume_chat(cx);
4883 })),
4884 )
4885 })
4886 .child(self.create_copy_button(error.to_string())),
4887 )
4888 .dismiss_action(self.dismiss_error_button(cx))
4889 }
4890
4891 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
4892 const ERROR_MESSAGE: &str =
4893 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
4894
4895 Callout::new()
4896 .severity(Severity::Error)
4897 .icon(IconName::XCircle)
4898 .title("Free Usage Exceeded")
4899 .description(ERROR_MESSAGE)
4900 .actions_slot(
4901 h_flex()
4902 .gap_0p5()
4903 .child(self.upgrade_button(cx))
4904 .child(self.create_copy_button(ERROR_MESSAGE)),
4905 )
4906 .dismiss_action(self.dismiss_error_button(cx))
4907 }
4908
4909 fn render_authentication_required_error(
4910 &self,
4911 error: SharedString,
4912 cx: &mut Context<Self>,
4913 ) -> Callout {
4914 Callout::new()
4915 .severity(Severity::Error)
4916 .title("Authentication Required")
4917 .icon(IconName::XCircle)
4918 .description(error.clone())
4919 .actions_slot(
4920 h_flex()
4921 .gap_0p5()
4922 .child(self.authenticate_button(cx))
4923 .child(self.create_copy_button(error)),
4924 )
4925 .dismiss_action(self.dismiss_error_button(cx))
4926 }
4927
4928 fn render_model_request_limit_reached_error(
4929 &self,
4930 plan: cloud_llm_client::Plan,
4931 cx: &mut Context<Self>,
4932 ) -> Callout {
4933 let error_message = match plan {
4934 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
4935 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
4936 "Upgrade to Zed Pro for more prompts."
4937 }
4938 };
4939
4940 Callout::new()
4941 .severity(Severity::Error)
4942 .title("Model Prompt Limit Reached")
4943 .icon(IconName::XCircle)
4944 .description(error_message)
4945 .actions_slot(
4946 h_flex()
4947 .gap_0p5()
4948 .child(self.upgrade_button(cx))
4949 .child(self.create_copy_button(error_message)),
4950 )
4951 .dismiss_action(self.dismiss_error_button(cx))
4952 }
4953
4954 fn render_tool_use_limit_reached_error(
4955 &self,
4956 window: &mut Window,
4957 cx: &mut Context<Self>,
4958 ) -> Option<Callout> {
4959 let thread = self.as_native_thread(cx)?;
4960 let supports_burn_mode = thread
4961 .read(cx)
4962 .model()
4963 .is_some_and(|model| model.supports_burn_mode());
4964
4965 let focus_handle = self.focus_handle(cx);
4966
4967 Some(
4968 Callout::new()
4969 .icon(IconName::Info)
4970 .title("Consecutive tool use limit reached.")
4971 .actions_slot(
4972 h_flex()
4973 .gap_0p5()
4974 .when(supports_burn_mode, |this| {
4975 this.child(
4976 Button::new("continue-burn-mode", "Continue with Burn Mode")
4977 .style(ButtonStyle::Filled)
4978 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
4979 .layer(ElevationIndex::ModalSurface)
4980 .label_size(LabelSize::Small)
4981 .key_binding(
4982 KeyBinding::for_action_in(
4983 &ContinueWithBurnMode,
4984 &focus_handle,
4985 window,
4986 cx,
4987 )
4988 .map(|kb| kb.size(rems_from_px(10.))),
4989 )
4990 .tooltip(Tooltip::text(
4991 "Enable Burn Mode for unlimited tool use.",
4992 ))
4993 .on_click({
4994 cx.listener(move |this, _, _window, cx| {
4995 thread.update(cx, |thread, cx| {
4996 thread
4997 .set_completion_mode(CompletionMode::Burn, cx);
4998 });
4999 this.resume_chat(cx);
5000 })
5001 }),
5002 )
5003 })
5004 .child(
5005 Button::new("continue-conversation", "Continue")
5006 .layer(ElevationIndex::ModalSurface)
5007 .label_size(LabelSize::Small)
5008 .key_binding(
5009 KeyBinding::for_action_in(
5010 &ContinueThread,
5011 &focus_handle,
5012 window,
5013 cx,
5014 )
5015 .map(|kb| kb.size(rems_from_px(10.))),
5016 )
5017 .on_click(cx.listener(|this, _, _window, cx| {
5018 this.resume_chat(cx);
5019 })),
5020 ),
5021 ),
5022 )
5023 }
5024
5025 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
5026 let message = message.into();
5027
5028 IconButton::new("copy", IconName::Copy)
5029 .icon_size(IconSize::Small)
5030 .icon_color(Color::Muted)
5031 .tooltip(Tooltip::text("Copy Error Message"))
5032 .on_click(move |_, _, cx| {
5033 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
5034 })
5035 }
5036
5037 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5038 IconButton::new("dismiss", IconName::Close)
5039 .icon_size(IconSize::Small)
5040 .icon_color(Color::Muted)
5041 .tooltip(Tooltip::text("Dismiss Error"))
5042 .on_click(cx.listener({
5043 move |this, _, _, cx| {
5044 this.clear_thread_error(cx);
5045 cx.notify();
5046 }
5047 }))
5048 }
5049
5050 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5051 Button::new("authenticate", "Authenticate")
5052 .label_size(LabelSize::Small)
5053 .style(ButtonStyle::Filled)
5054 .on_click(cx.listener({
5055 move |this, _, window, cx| {
5056 let agent = this.agent.clone();
5057 let ThreadState::Ready { thread, .. } = &this.thread_state else {
5058 return;
5059 };
5060
5061 let connection = thread.read(cx).connection().clone();
5062 let err = AuthRequired {
5063 description: None,
5064 provider_id: None,
5065 };
5066 this.clear_thread_error(cx);
5067 let this = cx.weak_entity();
5068 window.defer(cx, |window, cx| {
5069 Self::handle_auth_required(this, err, agent, connection, window, cx);
5070 })
5071 }
5072 }))
5073 }
5074
5075 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5076 let agent = self.agent.clone();
5077 let ThreadState::Ready { thread, .. } = &self.thread_state else {
5078 return;
5079 };
5080
5081 let connection = thread.read(cx).connection().clone();
5082 let err = AuthRequired {
5083 description: None,
5084 provider_id: None,
5085 };
5086 self.clear_thread_error(cx);
5087 let this = cx.weak_entity();
5088 window.defer(cx, |window, cx| {
5089 Self::handle_auth_required(this, err, agent, connection, window, cx);
5090 })
5091 }
5092
5093 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5094 Button::new("upgrade", "Upgrade")
5095 .label_size(LabelSize::Small)
5096 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
5097 .on_click(cx.listener({
5098 move |this, _, _, cx| {
5099 this.clear_thread_error(cx);
5100 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
5101 }
5102 }))
5103 }
5104
5105 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
5106 let task = match entry {
5107 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
5108 history.delete_thread(thread.id.clone(), cx)
5109 }),
5110 HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
5111 history.delete_text_thread(context.path.clone(), cx)
5112 }),
5113 };
5114 task.detach_and_log_err(cx);
5115 }
5116}
5117
5118fn loading_contents_spinner(size: IconSize) -> AnyElement {
5119 Icon::new(IconName::LoadCircle)
5120 .size(size)
5121 .color(Color::Accent)
5122 .with_rotate_animation(3)
5123 .into_any_element()
5124}
5125
5126impl Focusable for AcpThreadView {
5127 fn focus_handle(&self, cx: &App) -> FocusHandle {
5128 match self.thread_state {
5129 ThreadState::Loading { .. } | ThreadState::Ready { .. } => {
5130 self.message_editor.focus_handle(cx)
5131 }
5132 ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => {
5133 self.focus_handle.clone()
5134 }
5135 }
5136 }
5137}
5138
5139impl Render for AcpThreadView {
5140 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5141 let has_messages = self.list_state.item_count() > 0;
5142 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
5143
5144 v_flex()
5145 .size_full()
5146 .key_context("AcpThread")
5147 .on_action(cx.listener(Self::open_agent_diff))
5148 .on_action(cx.listener(Self::toggle_burn_mode))
5149 .on_action(cx.listener(Self::keep_all))
5150 .on_action(cx.listener(Self::reject_all))
5151 .track_focus(&self.focus_handle)
5152 .bg(cx.theme().colors().panel_background)
5153 .child(match &self.thread_state {
5154 ThreadState::Unauthenticated {
5155 connection,
5156 description,
5157 configuration_view,
5158 pending_auth_method,
5159 ..
5160 } => self.render_auth_required_state(
5161 connection,
5162 description.as_ref(),
5163 configuration_view.as_ref(),
5164 pending_auth_method.as_ref(),
5165 window,
5166 cx,
5167 ),
5168 ThreadState::Loading { .. } => v_flex()
5169 .flex_1()
5170 .child(self.render_recent_history(window, cx)),
5171 ThreadState::LoadError(e) => v_flex()
5172 .flex_1()
5173 .size_full()
5174 .items_center()
5175 .justify_end()
5176 .child(self.render_load_error(e, window, cx)),
5177 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
5178 if has_messages {
5179 this.child(
5180 list(
5181 self.list_state.clone(),
5182 cx.processor(|this, index: usize, window, cx| {
5183 let Some((entry, len)) = this.thread().and_then(|thread| {
5184 let entries = &thread.read(cx).entries();
5185 Some((entries.get(index)?, entries.len()))
5186 }) else {
5187 return Empty.into_any();
5188 };
5189 this.render_entry(index, len, entry, window, cx)
5190 }),
5191 )
5192 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
5193 .flex_grow()
5194 .into_any(),
5195 )
5196 .child(self.render_vertical_scrollbar(cx))
5197 } else {
5198 this.child(self.render_recent_history(window, cx))
5199 }
5200 }),
5201 })
5202 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
5203 // above so that the scrollbar doesn't render behind it. The current setup allows
5204 // the scrollbar to stop exactly at the activity bar start.
5205 .when(has_messages, |this| match &self.thread_state {
5206 ThreadState::Ready { thread, .. } => {
5207 this.children(self.render_activity_bar(thread, window, cx))
5208 }
5209 _ => this,
5210 })
5211 .children(self.render_thread_retry_status_callout(window, cx))
5212 .children(self.render_thread_error(window, cx))
5213 .children(
5214 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
5215 Some(usage_callout.into_any_element())
5216 } else {
5217 self.render_token_limit_callout(line_height, cx)
5218 .map(|token_limit_callout| token_limit_callout.into_any_element())
5219 },
5220 )
5221 .child(self.render_message_editor(window, cx))
5222 }
5223}
5224
5225fn default_markdown_style(
5226 buffer_font: bool,
5227 muted_text: bool,
5228 window: &Window,
5229 cx: &App,
5230) -> MarkdownStyle {
5231 let theme_settings = ThemeSettings::get_global(cx);
5232 let colors = cx.theme().colors();
5233
5234 let buffer_font_size = TextSize::Small.rems(cx);
5235
5236 let mut text_style = window.text_style();
5237 let line_height = buffer_font_size * 1.75;
5238
5239 let font_family = if buffer_font {
5240 theme_settings.buffer_font.family.clone()
5241 } else {
5242 theme_settings.ui_font.family.clone()
5243 };
5244
5245 let font_size = if buffer_font {
5246 TextSize::Small.rems(cx)
5247 } else {
5248 TextSize::Default.rems(cx)
5249 };
5250
5251 let text_color = if muted_text {
5252 colors.text_muted
5253 } else {
5254 colors.text
5255 };
5256
5257 text_style.refine(&TextStyleRefinement {
5258 font_family: Some(font_family),
5259 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
5260 font_features: Some(theme_settings.ui_font.features.clone()),
5261 font_size: Some(font_size.into()),
5262 line_height: Some(line_height.into()),
5263 color: Some(text_color),
5264 ..Default::default()
5265 });
5266
5267 MarkdownStyle {
5268 base_text_style: text_style.clone(),
5269 syntax: cx.theme().syntax().clone(),
5270 selection_background_color: colors.element_selection_background,
5271 code_block_overflow_x_scroll: true,
5272 table_overflow_x_scroll: true,
5273 heading_level_styles: Some(HeadingLevelStyles {
5274 h1: Some(TextStyleRefinement {
5275 font_size: Some(rems(1.15).into()),
5276 ..Default::default()
5277 }),
5278 h2: Some(TextStyleRefinement {
5279 font_size: Some(rems(1.1).into()),
5280 ..Default::default()
5281 }),
5282 h3: Some(TextStyleRefinement {
5283 font_size: Some(rems(1.05).into()),
5284 ..Default::default()
5285 }),
5286 h4: Some(TextStyleRefinement {
5287 font_size: Some(rems(1.).into()),
5288 ..Default::default()
5289 }),
5290 h5: Some(TextStyleRefinement {
5291 font_size: Some(rems(0.95).into()),
5292 ..Default::default()
5293 }),
5294 h6: Some(TextStyleRefinement {
5295 font_size: Some(rems(0.875).into()),
5296 ..Default::default()
5297 }),
5298 }),
5299 code_block: StyleRefinement {
5300 padding: EdgesRefinement {
5301 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5302 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5303 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5304 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5305 },
5306 margin: EdgesRefinement {
5307 top: Some(Length::Definite(Pixels(8.).into())),
5308 left: Some(Length::Definite(Pixels(0.).into())),
5309 right: Some(Length::Definite(Pixels(0.).into())),
5310 bottom: Some(Length::Definite(Pixels(12.).into())),
5311 },
5312 border_style: Some(BorderStyle::Solid),
5313 border_widths: EdgesRefinement {
5314 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
5315 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
5316 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
5317 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
5318 },
5319 border_color: Some(colors.border_variant),
5320 background: Some(colors.editor_background.into()),
5321 text: Some(TextStyleRefinement {
5322 font_family: Some(theme_settings.buffer_font.family.clone()),
5323 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5324 font_features: Some(theme_settings.buffer_font.features.clone()),
5325 font_size: Some(buffer_font_size.into()),
5326 ..Default::default()
5327 }),
5328 ..Default::default()
5329 },
5330 inline_code: TextStyleRefinement {
5331 font_family: Some(theme_settings.buffer_font.family.clone()),
5332 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5333 font_features: Some(theme_settings.buffer_font.features.clone()),
5334 font_size: Some(buffer_font_size.into()),
5335 background_color: Some(colors.editor_foreground.opacity(0.08)),
5336 ..Default::default()
5337 },
5338 link: TextStyleRefinement {
5339 background_color: Some(colors.editor_foreground.opacity(0.025)),
5340 underline: Some(UnderlineStyle {
5341 color: Some(colors.text_accent.opacity(0.5)),
5342 thickness: px(1.),
5343 ..Default::default()
5344 }),
5345 ..Default::default()
5346 },
5347 ..Default::default()
5348 }
5349}
5350
5351fn plan_label_markdown_style(
5352 status: &acp::PlanEntryStatus,
5353 window: &Window,
5354 cx: &App,
5355) -> MarkdownStyle {
5356 let default_md_style = default_markdown_style(false, false, window, cx);
5357
5358 MarkdownStyle {
5359 base_text_style: TextStyle {
5360 color: cx.theme().colors().text_muted,
5361 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
5362 Some(gpui::StrikethroughStyle {
5363 thickness: px(1.),
5364 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
5365 })
5366 } else {
5367 None
5368 },
5369 ..default_md_style.base_text_style
5370 },
5371 ..default_md_style
5372 }
5373}
5374
5375fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
5376 let default_md_style = default_markdown_style(true, false, window, cx);
5377
5378 MarkdownStyle {
5379 base_text_style: TextStyle {
5380 ..default_md_style.base_text_style
5381 },
5382 selection_background_color: cx.theme().colors().element_selection_background,
5383 ..Default::default()
5384 }
5385}
5386
5387#[cfg(test)]
5388pub(crate) mod tests {
5389 use acp_thread::StubAgentConnection;
5390 use agent_client_protocol::SessionId;
5391 use assistant_context::ContextStore;
5392 use editor::EditorSettings;
5393 use fs::FakeFs;
5394 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
5395 use project::Project;
5396 use serde_json::json;
5397 use settings::SettingsStore;
5398 use std::any::Any;
5399 use std::path::Path;
5400 use workspace::Item;
5401
5402 use super::*;
5403
5404 #[gpui::test]
5405 async fn test_drop(cx: &mut TestAppContext) {
5406 init_test(cx);
5407
5408 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5409 let weak_view = thread_view.downgrade();
5410 drop(thread_view);
5411 assert!(!weak_view.is_upgradable());
5412 }
5413
5414 #[gpui::test]
5415 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
5416 init_test(cx);
5417
5418 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5419
5420 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5421 message_editor.update_in(cx, |editor, window, cx| {
5422 editor.set_text("Hello", window, cx);
5423 });
5424
5425 cx.deactivate_window();
5426
5427 thread_view.update_in(cx, |thread_view, window, cx| {
5428 thread_view.send(window, cx);
5429 });
5430
5431 cx.run_until_parked();
5432
5433 assert!(
5434 cx.windows()
5435 .iter()
5436 .any(|window| window.downcast::<AgentNotification>().is_some())
5437 );
5438 }
5439
5440 #[gpui::test]
5441 async fn test_notification_for_error(cx: &mut TestAppContext) {
5442 init_test(cx);
5443
5444 let (thread_view, cx) =
5445 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
5446
5447 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5448 message_editor.update_in(cx, |editor, window, cx| {
5449 editor.set_text("Hello", window, cx);
5450 });
5451
5452 cx.deactivate_window();
5453
5454 thread_view.update_in(cx, |thread_view, window, cx| {
5455 thread_view.send(window, cx);
5456 });
5457
5458 cx.run_until_parked();
5459
5460 assert!(
5461 cx.windows()
5462 .iter()
5463 .any(|window| window.downcast::<AgentNotification>().is_some())
5464 );
5465 }
5466
5467 #[gpui::test]
5468 async fn test_refusal_handling(cx: &mut TestAppContext) {
5469 init_test(cx);
5470
5471 let (thread_view, cx) =
5472 setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
5473
5474 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5475 message_editor.update_in(cx, |editor, window, cx| {
5476 editor.set_text("Do something harmful", window, cx);
5477 });
5478
5479 thread_view.update_in(cx, |thread_view, window, cx| {
5480 thread_view.send(window, cx);
5481 });
5482
5483 cx.run_until_parked();
5484
5485 // Check that the refusal error is set
5486 thread_view.read_with(cx, |thread_view, _cx| {
5487 assert!(
5488 matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
5489 "Expected refusal error to be set"
5490 );
5491 });
5492 }
5493
5494 #[gpui::test]
5495 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
5496 init_test(cx);
5497
5498 let tool_call_id = acp::ToolCallId("1".into());
5499 let tool_call = acp::ToolCall {
5500 id: tool_call_id.clone(),
5501 title: "Label".into(),
5502 kind: acp::ToolKind::Edit,
5503 status: acp::ToolCallStatus::Pending,
5504 content: vec!["hi".into()],
5505 locations: vec![],
5506 raw_input: None,
5507 raw_output: None,
5508 };
5509 let connection =
5510 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5511 tool_call_id,
5512 vec![acp::PermissionOption {
5513 id: acp::PermissionOptionId("1".into()),
5514 name: "Allow".into(),
5515 kind: acp::PermissionOptionKind::AllowOnce,
5516 }],
5517 )]));
5518
5519 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5520
5521 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5522
5523 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5524 message_editor.update_in(cx, |editor, window, cx| {
5525 editor.set_text("Hello", window, cx);
5526 });
5527
5528 cx.deactivate_window();
5529
5530 thread_view.update_in(cx, |thread_view, window, cx| {
5531 thread_view.send(window, cx);
5532 });
5533
5534 cx.run_until_parked();
5535
5536 assert!(
5537 cx.windows()
5538 .iter()
5539 .any(|window| window.downcast::<AgentNotification>().is_some())
5540 );
5541 }
5542
5543 async fn setup_thread_view(
5544 agent: impl AgentServer + 'static,
5545 cx: &mut TestAppContext,
5546 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
5547 let fs = FakeFs::new(cx.executor());
5548 let project = Project::test(fs, [], cx).await;
5549 let (workspace, cx) =
5550 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5551
5552 let context_store =
5553 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5554 let history_store =
5555 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5556
5557 let thread_view = cx.update(|window, cx| {
5558 cx.new(|cx| {
5559 AcpThreadView::new(
5560 Rc::new(agent),
5561 None,
5562 None,
5563 workspace.downgrade(),
5564 project,
5565 history_store,
5566 None,
5567 window,
5568 cx,
5569 )
5570 })
5571 });
5572 cx.run_until_parked();
5573 (thread_view, cx)
5574 }
5575
5576 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
5577 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
5578
5579 workspace
5580 .update_in(cx, |workspace, window, cx| {
5581 workspace.add_item_to_active_pane(
5582 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
5583 None,
5584 true,
5585 window,
5586 cx,
5587 );
5588 })
5589 .unwrap();
5590 }
5591
5592 struct ThreadViewItem(Entity<AcpThreadView>);
5593
5594 impl Item for ThreadViewItem {
5595 type Event = ();
5596
5597 fn include_in_nav_history() -> bool {
5598 false
5599 }
5600
5601 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
5602 "Test".into()
5603 }
5604 }
5605
5606 impl EventEmitter<()> for ThreadViewItem {}
5607
5608 impl Focusable for ThreadViewItem {
5609 fn focus_handle(&self, cx: &App) -> FocusHandle {
5610 self.0.read(cx).focus_handle(cx)
5611 }
5612 }
5613
5614 impl Render for ThreadViewItem {
5615 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5616 self.0.clone().into_any_element()
5617 }
5618 }
5619
5620 struct StubAgentServer<C> {
5621 connection: C,
5622 }
5623
5624 impl<C> StubAgentServer<C> {
5625 fn new(connection: C) -> Self {
5626 Self { connection }
5627 }
5628 }
5629
5630 impl StubAgentServer<StubAgentConnection> {
5631 fn default_response() -> Self {
5632 let conn = StubAgentConnection::new();
5633 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5634 content: "Default response".into(),
5635 }]);
5636 Self::new(conn)
5637 }
5638 }
5639
5640 impl<C> AgentServer for StubAgentServer<C>
5641 where
5642 C: 'static + AgentConnection + Send + Clone,
5643 {
5644 fn telemetry_id(&self) -> &'static str {
5645 "test"
5646 }
5647
5648 fn logo(&self) -> ui::IconName {
5649 ui::IconName::Ai
5650 }
5651
5652 fn name(&self) -> SharedString {
5653 "Test".into()
5654 }
5655
5656 fn connect(
5657 &self,
5658 _root_dir: &Path,
5659 _delegate: AgentServerDelegate,
5660 _cx: &mut App,
5661 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5662 Task::ready(Ok(Rc::new(self.connection.clone())))
5663 }
5664
5665 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5666 self
5667 }
5668 }
5669
5670 #[derive(Clone)]
5671 struct SaboteurAgentConnection;
5672
5673 impl AgentConnection for SaboteurAgentConnection {
5674 fn new_thread(
5675 self: Rc<Self>,
5676 project: Entity<Project>,
5677 _cwd: &Path,
5678 cx: &mut gpui::App,
5679 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5680 Task::ready(Ok(cx.new(|cx| {
5681 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5682 AcpThread::new(
5683 "SaboteurAgentConnection",
5684 self,
5685 project,
5686 action_log,
5687 SessionId("test".into()),
5688 watch::Receiver::constant(acp::PromptCapabilities {
5689 image: true,
5690 audio: true,
5691 embedded_context: true,
5692 }),
5693 vec![],
5694 cx,
5695 )
5696 })))
5697 }
5698
5699 fn auth_methods(&self) -> &[acp::AuthMethod] {
5700 &[]
5701 }
5702
5703 fn authenticate(
5704 &self,
5705 _method_id: acp::AuthMethodId,
5706 _cx: &mut App,
5707 ) -> Task<gpui::Result<()>> {
5708 unimplemented!()
5709 }
5710
5711 fn prompt(
5712 &self,
5713 _id: Option<acp_thread::UserMessageId>,
5714 _params: acp::PromptRequest,
5715 _cx: &mut App,
5716 ) -> Task<gpui::Result<acp::PromptResponse>> {
5717 Task::ready(Err(anyhow::anyhow!("Error prompting")))
5718 }
5719
5720 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5721 unimplemented!()
5722 }
5723
5724 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5725 self
5726 }
5727 }
5728
5729 /// Simulates a model which always returns a refusal response
5730 #[derive(Clone)]
5731 struct RefusalAgentConnection;
5732
5733 impl AgentConnection for RefusalAgentConnection {
5734 fn new_thread(
5735 self: Rc<Self>,
5736 project: Entity<Project>,
5737 _cwd: &Path,
5738 cx: &mut gpui::App,
5739 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5740 Task::ready(Ok(cx.new(|cx| {
5741 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5742 AcpThread::new(
5743 "RefusalAgentConnection",
5744 self,
5745 project,
5746 action_log,
5747 SessionId("test".into()),
5748 watch::Receiver::constant(acp::PromptCapabilities {
5749 image: true,
5750 audio: true,
5751 embedded_context: true,
5752 }),
5753 Vec::new(),
5754 cx,
5755 )
5756 })))
5757 }
5758
5759 fn auth_methods(&self) -> &[acp::AuthMethod] {
5760 &[]
5761 }
5762
5763 fn authenticate(
5764 &self,
5765 _method_id: acp::AuthMethodId,
5766 _cx: &mut App,
5767 ) -> Task<gpui::Result<()>> {
5768 unimplemented!()
5769 }
5770
5771 fn prompt(
5772 &self,
5773 _id: Option<acp_thread::UserMessageId>,
5774 _params: acp::PromptRequest,
5775 _cx: &mut App,
5776 ) -> Task<gpui::Result<acp::PromptResponse>> {
5777 Task::ready(Ok(acp::PromptResponse {
5778 stop_reason: acp::StopReason::Refusal,
5779 }))
5780 }
5781
5782 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5783 unimplemented!()
5784 }
5785
5786 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5787 self
5788 }
5789 }
5790
5791 pub(crate) fn init_test(cx: &mut TestAppContext) {
5792 cx.update(|cx| {
5793 let settings_store = SettingsStore::test(cx);
5794 cx.set_global(settings_store);
5795 language::init(cx);
5796 Project::init_settings(cx);
5797 AgentSettings::register(cx);
5798 workspace::init_settings(cx);
5799 ThemeSettings::register(cx);
5800 release_channel::init(SemanticVersion::default(), cx);
5801 EditorSettings::register(cx);
5802 prompt_store::init(cx)
5803 });
5804 }
5805
5806 #[gpui::test]
5807 async fn test_rewind_views(cx: &mut TestAppContext) {
5808 init_test(cx);
5809
5810 let fs = FakeFs::new(cx.executor());
5811 fs.insert_tree(
5812 "/project",
5813 json!({
5814 "test1.txt": "old content 1",
5815 "test2.txt": "old content 2"
5816 }),
5817 )
5818 .await;
5819 let project = Project::test(fs, [Path::new("/project")], cx).await;
5820 let (workspace, cx) =
5821 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5822
5823 let context_store =
5824 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5825 let history_store =
5826 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5827
5828 let connection = Rc::new(StubAgentConnection::new());
5829 let thread_view = cx.update(|window, cx| {
5830 cx.new(|cx| {
5831 AcpThreadView::new(
5832 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
5833 None,
5834 None,
5835 workspace.downgrade(),
5836 project.clone(),
5837 history_store.clone(),
5838 None,
5839 window,
5840 cx,
5841 )
5842 })
5843 });
5844
5845 cx.run_until_parked();
5846
5847 let thread = thread_view
5848 .read_with(cx, |view, _| view.thread().cloned())
5849 .unwrap();
5850
5851 // First user message
5852 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5853 id: acp::ToolCallId("tool1".into()),
5854 title: "Edit file 1".into(),
5855 kind: acp::ToolKind::Edit,
5856 status: acp::ToolCallStatus::Completed,
5857 content: vec![acp::ToolCallContent::Diff {
5858 diff: acp::Diff {
5859 path: "/project/test1.txt".into(),
5860 old_text: Some("old content 1".into()),
5861 new_text: "new content 1".into(),
5862 },
5863 }],
5864 locations: vec![],
5865 raw_input: None,
5866 raw_output: None,
5867 })]);
5868
5869 thread
5870 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
5871 .await
5872 .unwrap();
5873 cx.run_until_parked();
5874
5875 thread.read_with(cx, |thread, _| {
5876 assert_eq!(thread.entries().len(), 2);
5877 });
5878
5879 thread_view.read_with(cx, |view, cx| {
5880 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5881 assert!(
5882 entry_view_state
5883 .entry(0)
5884 .unwrap()
5885 .message_editor()
5886 .is_some()
5887 );
5888 assert!(entry_view_state.entry(1).unwrap().has_content());
5889 });
5890 });
5891
5892 // Second user message
5893 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5894 id: acp::ToolCallId("tool2".into()),
5895 title: "Edit file 2".into(),
5896 kind: acp::ToolKind::Edit,
5897 status: acp::ToolCallStatus::Completed,
5898 content: vec![acp::ToolCallContent::Diff {
5899 diff: acp::Diff {
5900 path: "/project/test2.txt".into(),
5901 old_text: Some("old content 2".into()),
5902 new_text: "new content 2".into(),
5903 },
5904 }],
5905 locations: vec![],
5906 raw_input: None,
5907 raw_output: None,
5908 })]);
5909
5910 thread
5911 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5912 .await
5913 .unwrap();
5914 cx.run_until_parked();
5915
5916 let second_user_message_id = thread.read_with(cx, |thread, _| {
5917 assert_eq!(thread.entries().len(), 4);
5918 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5919 panic!();
5920 };
5921 user_message.id.clone().unwrap()
5922 });
5923
5924 thread_view.read_with(cx, |view, cx| {
5925 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5926 assert!(
5927 entry_view_state
5928 .entry(0)
5929 .unwrap()
5930 .message_editor()
5931 .is_some()
5932 );
5933 assert!(entry_view_state.entry(1).unwrap().has_content());
5934 assert!(
5935 entry_view_state
5936 .entry(2)
5937 .unwrap()
5938 .message_editor()
5939 .is_some()
5940 );
5941 assert!(entry_view_state.entry(3).unwrap().has_content());
5942 });
5943 });
5944
5945 // Rewind to first message
5946 thread
5947 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5948 .await
5949 .unwrap();
5950
5951 cx.run_until_parked();
5952
5953 thread.read_with(cx, |thread, _| {
5954 assert_eq!(thread.entries().len(), 2);
5955 });
5956
5957 thread_view.read_with(cx, |view, cx| {
5958 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5959 assert!(
5960 entry_view_state
5961 .entry(0)
5962 .unwrap()
5963 .message_editor()
5964 .is_some()
5965 );
5966 assert!(entry_view_state.entry(1).unwrap().has_content());
5967
5968 // Old views should be dropped
5969 assert!(entry_view_state.entry(2).is_none());
5970 assert!(entry_view_state.entry(3).is_none());
5971 });
5972 });
5973 }
5974
5975 #[gpui::test]
5976 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5977 init_test(cx);
5978
5979 let connection = StubAgentConnection::new();
5980
5981 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5982 content: acp::ContentBlock::Text(acp::TextContent {
5983 text: "Response".into(),
5984 annotations: None,
5985 }),
5986 }]);
5987
5988 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5989 add_to_workspace(thread_view.clone(), cx);
5990
5991 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5992 message_editor.update_in(cx, |editor, window, cx| {
5993 editor.set_text("Original message to edit", window, cx);
5994 });
5995 thread_view.update_in(cx, |thread_view, window, cx| {
5996 thread_view.send(window, cx);
5997 });
5998
5999 cx.run_until_parked();
6000
6001 let user_message_editor = thread_view.read_with(cx, |view, cx| {
6002 assert_eq!(view.editing_message, None);
6003
6004 view.entry_view_state
6005 .read(cx)
6006 .entry(0)
6007 .unwrap()
6008 .message_editor()
6009 .unwrap()
6010 .clone()
6011 });
6012
6013 // Focus
6014 cx.focus(&user_message_editor);
6015 thread_view.read_with(cx, |view, _cx| {
6016 assert_eq!(view.editing_message, Some(0));
6017 });
6018
6019 // Edit
6020 user_message_editor.update_in(cx, |editor, window, cx| {
6021 editor.set_text("Edited message content", window, cx);
6022 });
6023
6024 // Cancel
6025 user_message_editor.update_in(cx, |_editor, window, cx| {
6026 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
6027 });
6028
6029 thread_view.read_with(cx, |view, _cx| {
6030 assert_eq!(view.editing_message, None);
6031 });
6032
6033 user_message_editor.read_with(cx, |editor, cx| {
6034 assert_eq!(editor.text(cx), "Original message to edit");
6035 });
6036 }
6037
6038 #[gpui::test]
6039 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
6040 init_test(cx);
6041
6042 let connection = StubAgentConnection::new();
6043
6044 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
6045 add_to_workspace(thread_view.clone(), cx);
6046
6047 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6048 let mut events = cx.events(&message_editor);
6049 message_editor.update_in(cx, |editor, window, cx| {
6050 editor.set_text("", window, cx);
6051 });
6052
6053 message_editor.update_in(cx, |_editor, window, cx| {
6054 window.dispatch_action(Box::new(Chat), cx);
6055 });
6056 cx.run_until_parked();
6057 // We shouldn't have received any messages
6058 assert!(matches!(
6059 events.try_next(),
6060 Err(futures::channel::mpsc::TryRecvError { .. })
6061 ));
6062 }
6063
6064 #[gpui::test]
6065 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
6066 init_test(cx);
6067
6068 let connection = StubAgentConnection::new();
6069
6070 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
6071 content: acp::ContentBlock::Text(acp::TextContent {
6072 text: "Response".into(),
6073 annotations: None,
6074 }),
6075 }]);
6076
6077 let (thread_view, cx) =
6078 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6079 add_to_workspace(thread_view.clone(), cx);
6080
6081 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6082 message_editor.update_in(cx, |editor, window, cx| {
6083 editor.set_text("Original message to edit", window, cx);
6084 });
6085 thread_view.update_in(cx, |thread_view, window, cx| {
6086 thread_view.send(window, cx);
6087 });
6088
6089 cx.run_until_parked();
6090
6091 let user_message_editor = thread_view.read_with(cx, |view, cx| {
6092 assert_eq!(view.editing_message, None);
6093 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
6094
6095 view.entry_view_state
6096 .read(cx)
6097 .entry(0)
6098 .unwrap()
6099 .message_editor()
6100 .unwrap()
6101 .clone()
6102 });
6103
6104 // Focus
6105 cx.focus(&user_message_editor);
6106
6107 // Edit
6108 user_message_editor.update_in(cx, |editor, window, cx| {
6109 editor.set_text("Edited message content", window, cx);
6110 });
6111
6112 // Send
6113 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
6114 content: acp::ContentBlock::Text(acp::TextContent {
6115 text: "New Response".into(),
6116 annotations: None,
6117 }),
6118 }]);
6119
6120 user_message_editor.update_in(cx, |_editor, window, cx| {
6121 window.dispatch_action(Box::new(Chat), cx);
6122 });
6123
6124 cx.run_until_parked();
6125
6126 thread_view.read_with(cx, |view, cx| {
6127 assert_eq!(view.editing_message, None);
6128
6129 let entries = view.thread().unwrap().read(cx).entries();
6130 assert_eq!(entries.len(), 2);
6131 assert_eq!(
6132 entries[0].to_markdown(cx),
6133 "## User\n\nEdited message content\n\n"
6134 );
6135 assert_eq!(
6136 entries[1].to_markdown(cx),
6137 "## Assistant\n\nNew Response\n\n"
6138 );
6139
6140 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
6141 assert!(!state.entry(1).unwrap().has_content());
6142 state.entry(0).unwrap().message_editor().unwrap().clone()
6143 });
6144
6145 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
6146 })
6147 }
6148
6149 #[gpui::test]
6150 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
6151 init_test(cx);
6152
6153 let connection = StubAgentConnection::new();
6154
6155 let (thread_view, cx) =
6156 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6157 add_to_workspace(thread_view.clone(), cx);
6158
6159 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6160 message_editor.update_in(cx, |editor, window, cx| {
6161 editor.set_text("Original message to edit", window, cx);
6162 });
6163 thread_view.update_in(cx, |thread_view, window, cx| {
6164 thread_view.send(window, cx);
6165 });
6166
6167 cx.run_until_parked();
6168
6169 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
6170 let thread = view.thread().unwrap().read(cx);
6171 assert_eq!(thread.entries().len(), 1);
6172
6173 let editor = view
6174 .entry_view_state
6175 .read(cx)
6176 .entry(0)
6177 .unwrap()
6178 .message_editor()
6179 .unwrap()
6180 .clone();
6181
6182 (editor, thread.session_id().clone())
6183 });
6184
6185 // Focus
6186 cx.focus(&user_message_editor);
6187
6188 thread_view.read_with(cx, |view, _cx| {
6189 assert_eq!(view.editing_message, Some(0));
6190 });
6191
6192 // Edit
6193 user_message_editor.update_in(cx, |editor, window, cx| {
6194 editor.set_text("Edited message content", window, cx);
6195 });
6196
6197 thread_view.read_with(cx, |view, _cx| {
6198 assert_eq!(view.editing_message, Some(0));
6199 });
6200
6201 // Finish streaming response
6202 cx.update(|_, cx| {
6203 connection.send_update(
6204 session_id.clone(),
6205 acp::SessionUpdate::AgentMessageChunk {
6206 content: acp::ContentBlock::Text(acp::TextContent {
6207 text: "Response".into(),
6208 annotations: None,
6209 }),
6210 },
6211 cx,
6212 );
6213 connection.end_turn(session_id, acp::StopReason::EndTurn);
6214 });
6215
6216 thread_view.read_with(cx, |view, _cx| {
6217 assert_eq!(view.editing_message, Some(0));
6218 });
6219
6220 cx.run_until_parked();
6221
6222 // Should still be editing
6223 cx.update(|window, cx| {
6224 assert!(user_message_editor.focus_handle(cx).is_focused(window));
6225 assert_eq!(thread_view.read(cx).editing_message, Some(0));
6226 assert_eq!(
6227 user_message_editor.read(cx).text(cx),
6228 "Edited message content"
6229 );
6230 });
6231 }
6232
6233 #[gpui::test]
6234 async fn test_interrupt(cx: &mut TestAppContext) {
6235 init_test(cx);
6236
6237 let connection = StubAgentConnection::new();
6238
6239 let (thread_view, cx) =
6240 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6241 add_to_workspace(thread_view.clone(), cx);
6242
6243 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6244 message_editor.update_in(cx, |editor, window, cx| {
6245 editor.set_text("Message 1", window, cx);
6246 });
6247 thread_view.update_in(cx, |thread_view, window, cx| {
6248 thread_view.send(window, cx);
6249 });
6250
6251 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
6252 let thread = view.thread().unwrap();
6253
6254 (thread.clone(), thread.read(cx).session_id().clone())
6255 });
6256
6257 cx.run_until_parked();
6258
6259 cx.update(|_, cx| {
6260 connection.send_update(
6261 session_id.clone(),
6262 acp::SessionUpdate::AgentMessageChunk {
6263 content: "Message 1 resp".into(),
6264 },
6265 cx,
6266 );
6267 });
6268
6269 cx.run_until_parked();
6270
6271 thread.read_with(cx, |thread, cx| {
6272 assert_eq!(
6273 thread.to_markdown(cx),
6274 indoc::indoc! {"
6275 ## User
6276
6277 Message 1
6278
6279 ## Assistant
6280
6281 Message 1 resp
6282
6283 "}
6284 )
6285 });
6286
6287 message_editor.update_in(cx, |editor, window, cx| {
6288 editor.set_text("Message 2", window, cx);
6289 });
6290 thread_view.update_in(cx, |thread_view, window, cx| {
6291 thread_view.send(window, cx);
6292 });
6293
6294 cx.update(|_, cx| {
6295 // Simulate a response sent after beginning to cancel
6296 connection.send_update(
6297 session_id.clone(),
6298 acp::SessionUpdate::AgentMessageChunk {
6299 content: "onse".into(),
6300 },
6301 cx,
6302 );
6303 });
6304
6305 cx.run_until_parked();
6306
6307 // Last Message 1 response should appear before Message 2
6308 thread.read_with(cx, |thread, cx| {
6309 assert_eq!(
6310 thread.to_markdown(cx),
6311 indoc::indoc! {"
6312 ## User
6313
6314 Message 1
6315
6316 ## Assistant
6317
6318 Message 1 response
6319
6320 ## User
6321
6322 Message 2
6323
6324 "}
6325 )
6326 });
6327
6328 cx.update(|_, cx| {
6329 connection.send_update(
6330 session_id.clone(),
6331 acp::SessionUpdate::AgentMessageChunk {
6332 content: "Message 2 response".into(),
6333 },
6334 cx,
6335 );
6336 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
6337 });
6338
6339 cx.run_until_parked();
6340
6341 thread.read_with(cx, |thread, cx| {
6342 assert_eq!(
6343 thread.to_markdown(cx),
6344 indoc::indoc! {"
6345 ## User
6346
6347 Message 1
6348
6349 ## Assistant
6350
6351 Message 1 response
6352
6353 ## User
6354
6355 Message 2
6356
6357 ## Assistant
6358
6359 Message 2 response
6360
6361 "}
6362 )
6363 });
6364 }
6365}