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, Transformation, UnderlineStyle, WeakEntity,
27 Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
28 point, prelude::*, 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, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
49 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_animation(
2621 "arrow-circle",
2622 Animation::new(Duration::from_secs(2)).repeat(),
2623 |icon, delta| {
2624 icon.transform(Transformation::rotate(percentage(delta)))
2625 },
2626 ),
2627 )
2628 })
2629 .child(
2630 Disclosure::new(
2631 SharedString::from(format!(
2632 "terminal-tool-disclosure-{}",
2633 terminal.entity_id()
2634 )),
2635 is_expanded,
2636 )
2637 .opened_icon(IconName::ChevronUp)
2638 .closed_icon(IconName::ChevronDown)
2639 .visible_on_hover(&header_group)
2640 .on_click(cx.listener({
2641 let id = tool_call.id.clone();
2642 move |this, _event, _window, _cx| {
2643 if is_expanded {
2644 this.expanded_tool_calls.remove(&id);
2645 } else {
2646 this.expanded_tool_calls.insert(id.clone());
2647 }
2648 }
2649 })),
2650 )
2651 .when(truncated_output, |header| {
2652 let tooltip = if let Some(output) = output {
2653 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
2654 format!("Output exceeded terminal max lines and was \
2655 truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
2656 } else {
2657 format!(
2658 "Output is {} long, and to avoid unexpected token usage, \
2659 only {} was sent back to the agent.",
2660 format_file_size(output.original_content_len as u64, true),
2661 format_file_size(output.content.len() as u64, true)
2662 )
2663 }
2664 } else {
2665 "Output was truncated".to_string()
2666 };
2667
2668 header.child(
2669 h_flex()
2670 .id(("terminal-tool-truncated-label", terminal.entity_id()))
2671 .gap_1()
2672 .child(
2673 Icon::new(IconName::Info)
2674 .size(IconSize::XSmall)
2675 .color(Color::Ignored),
2676 )
2677 .child(
2678 Label::new("Truncated")
2679 .color(Color::Muted)
2680 .size(LabelSize::XSmall),
2681 )
2682 .tooltip(Tooltip::text(tooltip)),
2683 )
2684 })
2685 .when(time_elapsed > Duration::from_secs(10), |header| {
2686 header.child(
2687 Label::new(format!("({})", duration_alt_display(time_elapsed)))
2688 .buffer_font(cx)
2689 .color(Color::Muted)
2690 .size(LabelSize::XSmall),
2691 )
2692 })
2693 .when(tool_failed || command_failed, |header| {
2694 header.child(
2695 div()
2696 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
2697 .child(
2698 Icon::new(IconName::Close)
2699 .size(IconSize::Small)
2700 .color(Color::Error),
2701 )
2702 .when_some(output.and_then(|o| o.exit_status), |this, status| {
2703 this.tooltip(Tooltip::text(format!(
2704 "Exited with code {}",
2705 status.code().unwrap_or(-1),
2706 )))
2707 }),
2708 )
2709 });
2710
2711 let terminal_view = self
2712 .entry_view_state
2713 .read(cx)
2714 .entry(entry_ix)
2715 .and_then(|entry| entry.terminal(terminal));
2716 let show_output = is_expanded && terminal_view.is_some();
2717
2718 v_flex()
2719 .my_1p5()
2720 .mx_5()
2721 .border_1()
2722 .when(tool_failed || command_failed, |card| card.border_dashed())
2723 .border_color(border_color)
2724 .rounded_md()
2725 .overflow_hidden()
2726 .child(
2727 v_flex()
2728 .group(&header_group)
2729 .py_1p5()
2730 .pr_1p5()
2731 .pl_2()
2732 .gap_0p5()
2733 .bg(header_bg)
2734 .text_xs()
2735 .child(header)
2736 .child(
2737 MarkdownElement::new(
2738 command.clone(),
2739 terminal_command_markdown_style(window, cx),
2740 )
2741 .code_block_renderer(
2742 markdown::CodeBlockRenderer::Default {
2743 copy_button: false,
2744 copy_button_on_hover: true,
2745 border: false,
2746 },
2747 ),
2748 ),
2749 )
2750 .when(show_output, |this| {
2751 this.child(
2752 div()
2753 .pt_2()
2754 .border_t_1()
2755 .when(tool_failed || command_failed, |card| card.border_dashed())
2756 .border_color(border_color)
2757 .bg(cx.theme().colors().editor_background)
2758 .rounded_b_md()
2759 .text_ui_sm(cx)
2760 .h_full()
2761 .children(terminal_view.map(|terminal_view| {
2762 if terminal_view
2763 .read(cx)
2764 .content_mode(window, cx)
2765 .is_scrollable()
2766 {
2767 div().h_72().child(terminal_view).into_any_element()
2768 } else {
2769 terminal_view.into_any_element()
2770 }
2771 })),
2772 )
2773 })
2774 .into_any()
2775 }
2776
2777 fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
2778 let project_context = self
2779 .as_native_thread(cx)?
2780 .read(cx)
2781 .project_context()
2782 .read(cx);
2783
2784 let user_rules_text = if project_context.user_rules.is_empty() {
2785 None
2786 } else if project_context.user_rules.len() == 1 {
2787 let user_rules = &project_context.user_rules[0];
2788
2789 match user_rules.title.as_ref() {
2790 Some(title) => Some(format!("Using \"{title}\" user rule")),
2791 None => Some("Using user rule".into()),
2792 }
2793 } else {
2794 Some(format!(
2795 "Using {} user rules",
2796 project_context.user_rules.len()
2797 ))
2798 };
2799
2800 let first_user_rules_id = project_context
2801 .user_rules
2802 .first()
2803 .map(|user_rules| user_rules.uuid.0);
2804
2805 let rules_files = project_context
2806 .worktrees
2807 .iter()
2808 .filter_map(|worktree| worktree.rules_file.as_ref())
2809 .collect::<Vec<_>>();
2810
2811 let rules_file_text = match rules_files.as_slice() {
2812 &[] => None,
2813 &[rules_file] => Some(format!(
2814 "Using project {:?} file",
2815 rules_file.path_in_worktree
2816 )),
2817 rules_files => Some(format!("Using {} project rules files", rules_files.len())),
2818 };
2819
2820 if user_rules_text.is_none() && rules_file_text.is_none() {
2821 return None;
2822 }
2823
2824 let has_both = user_rules_text.is_some() && rules_file_text.is_some();
2825
2826 Some(
2827 h_flex()
2828 .px_2p5()
2829 .child(
2830 Icon::new(IconName::Attach)
2831 .size(IconSize::XSmall)
2832 .color(Color::Disabled),
2833 )
2834 .when_some(user_rules_text, |parent, user_rules_text| {
2835 parent.child(
2836 h_flex()
2837 .id("user-rules")
2838 .ml_1()
2839 .mr_1p5()
2840 .child(
2841 Label::new(user_rules_text)
2842 .size(LabelSize::XSmall)
2843 .color(Color::Muted)
2844 .truncate(),
2845 )
2846 .hover(|s| s.bg(cx.theme().colors().element_hover))
2847 .tooltip(Tooltip::text("View User Rules"))
2848 .on_click(move |_event, window, cx| {
2849 window.dispatch_action(
2850 Box::new(OpenRulesLibrary {
2851 prompt_to_select: first_user_rules_id,
2852 }),
2853 cx,
2854 )
2855 }),
2856 )
2857 })
2858 .when(has_both, |this| {
2859 this.child(
2860 Label::new("•")
2861 .size(LabelSize::XSmall)
2862 .color(Color::Disabled),
2863 )
2864 })
2865 .when_some(rules_file_text, |parent, rules_file_text| {
2866 parent.child(
2867 h_flex()
2868 .id("project-rules")
2869 .ml_1p5()
2870 .child(
2871 Label::new(rules_file_text)
2872 .size(LabelSize::XSmall)
2873 .color(Color::Muted),
2874 )
2875 .hover(|s| s.bg(cx.theme().colors().element_hover))
2876 .tooltip(Tooltip::text("View Project Rules"))
2877 .on_click(cx.listener(Self::handle_open_rules)),
2878 )
2879 })
2880 .into_any(),
2881 )
2882 }
2883
2884 fn render_empty_state_section_header(
2885 &self,
2886 label: impl Into<SharedString>,
2887 action_slot: Option<AnyElement>,
2888 cx: &mut Context<Self>,
2889 ) -> impl IntoElement {
2890 div().pl_1().pr_1p5().child(
2891 h_flex()
2892 .mt_2()
2893 .pl_1p5()
2894 .pb_1()
2895 .w_full()
2896 .justify_between()
2897 .border_b_1()
2898 .border_color(cx.theme().colors().border_variant)
2899 .child(
2900 Label::new(label.into())
2901 .size(LabelSize::Small)
2902 .color(Color::Muted),
2903 )
2904 .children(action_slot),
2905 )
2906 }
2907
2908 fn render_recent_history(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2909 let render_history = self
2910 .agent
2911 .clone()
2912 .downcast::<agent2::NativeAgentServer>()
2913 .is_some()
2914 && self
2915 .history_store
2916 .update(cx, |history_store, cx| !history_store.is_empty(cx));
2917
2918 v_flex()
2919 .size_full()
2920 .when(render_history, |this| {
2921 let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| {
2922 history_store.entries().take(3).collect()
2923 });
2924 this.justify_end().child(
2925 v_flex()
2926 .child(
2927 self.render_empty_state_section_header(
2928 "Recent",
2929 Some(
2930 Button::new("view-history", "View All")
2931 .style(ButtonStyle::Subtle)
2932 .label_size(LabelSize::Small)
2933 .key_binding(
2934 KeyBinding::for_action_in(
2935 &OpenHistory,
2936 &self.focus_handle(cx),
2937 window,
2938 cx,
2939 )
2940 .map(|kb| kb.size(rems_from_px(12.))),
2941 )
2942 .on_click(move |_event, window, cx| {
2943 window.dispatch_action(OpenHistory.boxed_clone(), cx);
2944 })
2945 .into_any_element(),
2946 ),
2947 cx,
2948 ),
2949 )
2950 .child(
2951 v_flex().p_1().pr_1p5().gap_1().children(
2952 recent_history
2953 .into_iter()
2954 .enumerate()
2955 .map(|(index, entry)| {
2956 // TODO: Add keyboard navigation.
2957 let is_hovered =
2958 self.hovered_recent_history_item == Some(index);
2959 crate::acp::thread_history::AcpHistoryEntryElement::new(
2960 entry,
2961 cx.entity().downgrade(),
2962 )
2963 .hovered(is_hovered)
2964 .on_hover(cx.listener(
2965 move |this, is_hovered, _window, cx| {
2966 if *is_hovered {
2967 this.hovered_recent_history_item = Some(index);
2968 } else if this.hovered_recent_history_item
2969 == Some(index)
2970 {
2971 this.hovered_recent_history_item = None;
2972 }
2973 cx.notify();
2974 },
2975 ))
2976 .into_any_element()
2977 }),
2978 ),
2979 ),
2980 )
2981 })
2982 .into_any()
2983 }
2984
2985 fn render_auth_required_state(
2986 &self,
2987 connection: &Rc<dyn AgentConnection>,
2988 description: Option<&Entity<Markdown>>,
2989 configuration_view: Option<&AnyView>,
2990 pending_auth_method: Option<&acp::AuthMethodId>,
2991 window: &mut Window,
2992 cx: &Context<Self>,
2993 ) -> Div {
2994 let show_description =
2995 configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
2996
2997 v_flex().flex_1().size_full().justify_end().child(
2998 v_flex()
2999 .p_2()
3000 .pr_3()
3001 .w_full()
3002 .gap_1()
3003 .border_t_1()
3004 .border_color(cx.theme().colors().border)
3005 .bg(cx.theme().status().warning.opacity(0.04))
3006 .child(
3007 h_flex()
3008 .gap_1p5()
3009 .child(
3010 Icon::new(IconName::Warning)
3011 .color(Color::Warning)
3012 .size(IconSize::Small),
3013 )
3014 .child(Label::new("Authentication Required").size(LabelSize::Small)),
3015 )
3016 .children(description.map(|desc| {
3017 div().text_ui(cx).child(self.render_markdown(
3018 desc.clone(),
3019 default_markdown_style(false, false, window, cx),
3020 ))
3021 }))
3022 .children(
3023 configuration_view
3024 .cloned()
3025 .map(|view| div().w_full().child(view)),
3026 )
3027 .when(
3028 show_description,
3029 |el| {
3030 el.child(
3031 Label::new(format!(
3032 "You are not currently authenticated with {}. Please choose one of the following options:",
3033 self.agent.name()
3034 ))
3035 .size(LabelSize::Small)
3036 .color(Color::Muted)
3037 .mb_1()
3038 .ml_5(),
3039 )
3040 },
3041 )
3042 .when_some(pending_auth_method, |el, _| {
3043 el.child(
3044 h_flex()
3045 .py_4()
3046 .w_full()
3047 .justify_center()
3048 .gap_1()
3049 .child(
3050 Icon::new(IconName::ArrowCircle)
3051 .size(IconSize::Small)
3052 .color(Color::Muted)
3053 .with_animation(
3054 "arrow-circle",
3055 Animation::new(Duration::from_secs(2)).repeat(),
3056 |icon, delta| {
3057 icon.transform(Transformation::rotate(percentage(
3058 delta,
3059 )))
3060 },
3061 )
3062 .into_any_element(),
3063 )
3064 .child(Label::new("Authenticating…").size(LabelSize::Small)),
3065 )
3066 })
3067 .when(!connection.auth_methods().is_empty(), |this| {
3068 this.child(
3069 h_flex()
3070 .justify_end()
3071 .flex_wrap()
3072 .gap_1()
3073 .when(!show_description, |this| {
3074 this.border_t_1()
3075 .mt_1()
3076 .pt_2()
3077 .border_color(cx.theme().colors().border.opacity(0.8))
3078 })
3079 .children(
3080 connection
3081 .auth_methods()
3082 .iter()
3083 .enumerate()
3084 .rev()
3085 .map(|(ix, method)| {
3086 Button::new(
3087 SharedString::from(method.id.0.clone()),
3088 method.name.clone(),
3089 )
3090 .when(ix == 0, |el| {
3091 el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
3092 })
3093 .label_size(LabelSize::Small)
3094 .on_click({
3095 let method_id = method.id.clone();
3096 cx.listener(move |this, _, window, cx| {
3097 telemetry::event!(
3098 "Authenticate Agent Started",
3099 agent = this.agent.telemetry_id(),
3100 method = method_id
3101 );
3102
3103 this.authenticate(method_id.clone(), window, cx)
3104 })
3105 })
3106 }),
3107 ),
3108 )
3109 })
3110
3111 )
3112 }
3113
3114 fn render_load_error(
3115 &self,
3116 e: &LoadError,
3117 window: &mut Window,
3118 cx: &mut Context<Self>,
3119 ) -> AnyElement {
3120 let (title, message, action_slot): (_, SharedString, _) = match e {
3121 LoadError::Unsupported {
3122 command: path,
3123 current_version,
3124 minimum_version,
3125 } => {
3126 return self.render_unsupported(path, current_version, minimum_version, window, cx);
3127 }
3128 LoadError::FailedToInstall(msg) => (
3129 "Failed to Install",
3130 msg.into(),
3131 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3132 ),
3133 LoadError::Exited { status } => (
3134 "Failed to Launch",
3135 format!("Server exited with status {status}").into(),
3136 None,
3137 ),
3138 LoadError::Other(msg) => (
3139 "Failed to Launch",
3140 msg.into(),
3141 Some(self.create_copy_button(msg.to_string()).into_any_element()),
3142 ),
3143 };
3144
3145 Callout::new()
3146 .severity(Severity::Error)
3147 .icon(IconName::XCircleFilled)
3148 .title(title)
3149 .description(message)
3150 .actions_slot(div().children(action_slot))
3151 .into_any_element()
3152 }
3153
3154 fn render_unsupported(
3155 &self,
3156 path: &SharedString,
3157 version: &SharedString,
3158 minimum_version: &SharedString,
3159 _window: &mut Window,
3160 cx: &mut Context<Self>,
3161 ) -> AnyElement {
3162 let (heading_label, description_label) = (
3163 format!("Upgrade {} to work with Zed", self.agent.name()),
3164 if version.is_empty() {
3165 format!(
3166 "Currently using {}, which does not report a valid --version",
3167 path,
3168 )
3169 } else {
3170 format!(
3171 "Currently using {}, which is only version {} (need at least {minimum_version})",
3172 path, version
3173 )
3174 },
3175 );
3176
3177 v_flex()
3178 .w_full()
3179 .p_3p5()
3180 .gap_2p5()
3181 .border_t_1()
3182 .border_color(cx.theme().colors().border)
3183 .bg(linear_gradient(
3184 180.,
3185 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
3186 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
3187 ))
3188 .child(
3189 v_flex().gap_0p5().child(Label::new(heading_label)).child(
3190 Label::new(description_label)
3191 .size(LabelSize::Small)
3192 .color(Color::Muted),
3193 ),
3194 )
3195 .into_any_element()
3196 }
3197
3198 fn render_activity_bar(
3199 &self,
3200 thread_entity: &Entity<AcpThread>,
3201 window: &mut Window,
3202 cx: &Context<Self>,
3203 ) -> Option<AnyElement> {
3204 let thread = thread_entity.read(cx);
3205 let action_log = thread.action_log();
3206 let changed_buffers = action_log.read(cx).changed_buffers(cx);
3207 let plan = thread.plan();
3208
3209 if changed_buffers.is_empty() && plan.is_empty() {
3210 return None;
3211 }
3212
3213 let editor_bg_color = cx.theme().colors().editor_background;
3214 let active_color = cx.theme().colors().element_selected;
3215 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
3216
3217 // Temporarily always enable ACP edit controls. This is temporary, to lessen the
3218 // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
3219 // be, which blocks you from being able to accept or reject edits. This switches the
3220 // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
3221 // block you from using the panel.
3222 let pending_edits = false;
3223
3224 v_flex()
3225 .mt_1()
3226 .mx_2()
3227 .bg(bg_edit_files_disclosure)
3228 .border_1()
3229 .border_b_0()
3230 .border_color(cx.theme().colors().border)
3231 .rounded_t_md()
3232 .shadow(vec![gpui::BoxShadow {
3233 color: gpui::black().opacity(0.15),
3234 offset: point(px(1.), px(-1.)),
3235 blur_radius: px(3.),
3236 spread_radius: px(0.),
3237 }])
3238 .when(!plan.is_empty(), |this| {
3239 this.child(self.render_plan_summary(plan, window, cx))
3240 .when(self.plan_expanded, |parent| {
3241 parent.child(self.render_plan_entries(plan, window, cx))
3242 })
3243 })
3244 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
3245 this.child(Divider::horizontal().color(DividerColor::Border))
3246 })
3247 .when(!changed_buffers.is_empty(), |this| {
3248 this.child(self.render_edits_summary(
3249 &changed_buffers,
3250 self.edits_expanded,
3251 pending_edits,
3252 window,
3253 cx,
3254 ))
3255 .when(self.edits_expanded, |parent| {
3256 parent.child(self.render_edited_files(
3257 action_log,
3258 &changed_buffers,
3259 pending_edits,
3260 cx,
3261 ))
3262 })
3263 })
3264 .into_any()
3265 .into()
3266 }
3267
3268 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3269 let stats = plan.stats();
3270
3271 let title = if let Some(entry) = stats.in_progress_entry
3272 && !self.plan_expanded
3273 {
3274 h_flex()
3275 .w_full()
3276 .cursor_default()
3277 .gap_1()
3278 .text_xs()
3279 .text_color(cx.theme().colors().text_muted)
3280 .justify_between()
3281 .child(
3282 h_flex()
3283 .gap_1()
3284 .child(
3285 Label::new("Current:")
3286 .size(LabelSize::Small)
3287 .color(Color::Muted),
3288 )
3289 .child(MarkdownElement::new(
3290 entry.content.clone(),
3291 plan_label_markdown_style(&entry.status, window, cx),
3292 )),
3293 )
3294 .when(stats.pending > 0, |this| {
3295 this.child(
3296 Label::new(format!("{} left", stats.pending))
3297 .size(LabelSize::Small)
3298 .color(Color::Muted)
3299 .mr_1(),
3300 )
3301 })
3302 } else {
3303 let status_label = if stats.pending == 0 {
3304 "All Done".to_string()
3305 } else if stats.completed == 0 {
3306 format!("{} Tasks", plan.entries.len())
3307 } else {
3308 format!("{}/{}", stats.completed, plan.entries.len())
3309 };
3310
3311 h_flex()
3312 .w_full()
3313 .gap_1()
3314 .justify_between()
3315 .child(
3316 Label::new("Plan")
3317 .size(LabelSize::Small)
3318 .color(Color::Muted),
3319 )
3320 .child(
3321 Label::new(status_label)
3322 .size(LabelSize::Small)
3323 .color(Color::Muted)
3324 .mr_1(),
3325 )
3326 };
3327
3328 h_flex()
3329 .p_1()
3330 .justify_between()
3331 .when(self.plan_expanded, |this| {
3332 this.border_b_1().border_color(cx.theme().colors().border)
3333 })
3334 .child(
3335 h_flex()
3336 .id("plan_summary")
3337 .w_full()
3338 .gap_1()
3339 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
3340 .child(title)
3341 .on_click(cx.listener(|this, _, _, cx| {
3342 this.plan_expanded = !this.plan_expanded;
3343 cx.notify();
3344 })),
3345 )
3346 }
3347
3348 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
3349 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
3350 let element = h_flex()
3351 .py_1()
3352 .px_2()
3353 .gap_2()
3354 .justify_between()
3355 .bg(cx.theme().colors().editor_background)
3356 .when(index < plan.entries.len() - 1, |parent| {
3357 parent.border_color(cx.theme().colors().border).border_b_1()
3358 })
3359 .child(
3360 h_flex()
3361 .id(("plan_entry", index))
3362 .gap_1p5()
3363 .max_w_full()
3364 .overflow_x_scroll()
3365 .text_xs()
3366 .text_color(cx.theme().colors().text_muted)
3367 .child(match entry.status {
3368 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
3369 .size(IconSize::Small)
3370 .color(Color::Muted)
3371 .into_any_element(),
3372 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
3373 .size(IconSize::Small)
3374 .color(Color::Accent)
3375 .with_animation(
3376 "running",
3377 Animation::new(Duration::from_secs(2)).repeat(),
3378 |icon, delta| {
3379 icon.transform(Transformation::rotate(percentage(delta)))
3380 },
3381 )
3382 .into_any_element(),
3383 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
3384 .size(IconSize::Small)
3385 .color(Color::Success)
3386 .into_any_element(),
3387 })
3388 .child(MarkdownElement::new(
3389 entry.content.clone(),
3390 plan_label_markdown_style(&entry.status, window, cx),
3391 )),
3392 );
3393
3394 Some(element)
3395 }))
3396 }
3397
3398 fn render_edits_summary(
3399 &self,
3400 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3401 expanded: bool,
3402 pending_edits: bool,
3403 window: &mut Window,
3404 cx: &Context<Self>,
3405 ) -> Div {
3406 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
3407
3408 let focus_handle = self.focus_handle(cx);
3409
3410 h_flex()
3411 .p_1()
3412 .justify_between()
3413 .flex_wrap()
3414 .when(expanded, |this| {
3415 this.border_b_1().border_color(cx.theme().colors().border)
3416 })
3417 .child(
3418 h_flex()
3419 .id("edits-container")
3420 .gap_1()
3421 .child(Disclosure::new("edits-disclosure", expanded))
3422 .map(|this| {
3423 if pending_edits {
3424 this.child(
3425 Label::new(format!(
3426 "Editing {} {}…",
3427 changed_buffers.len(),
3428 if changed_buffers.len() == 1 {
3429 "file"
3430 } else {
3431 "files"
3432 }
3433 ))
3434 .color(Color::Muted)
3435 .size(LabelSize::Small)
3436 .with_animation(
3437 "edit-label",
3438 Animation::new(Duration::from_secs(2))
3439 .repeat()
3440 .with_easing(pulsating_between(0.3, 0.7)),
3441 |label, delta| label.alpha(delta),
3442 ),
3443 )
3444 } else {
3445 this.child(
3446 Label::new("Edits")
3447 .size(LabelSize::Small)
3448 .color(Color::Muted),
3449 )
3450 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
3451 .child(
3452 Label::new(format!(
3453 "{} {}",
3454 changed_buffers.len(),
3455 if changed_buffers.len() == 1 {
3456 "file"
3457 } else {
3458 "files"
3459 }
3460 ))
3461 .size(LabelSize::Small)
3462 .color(Color::Muted),
3463 )
3464 }
3465 })
3466 .on_click(cx.listener(|this, _, _, cx| {
3467 this.edits_expanded = !this.edits_expanded;
3468 cx.notify();
3469 })),
3470 )
3471 .child(
3472 h_flex()
3473 .gap_1()
3474 .child(
3475 IconButton::new("review-changes", IconName::ListTodo)
3476 .icon_size(IconSize::Small)
3477 .tooltip({
3478 let focus_handle = focus_handle.clone();
3479 move |window, cx| {
3480 Tooltip::for_action_in(
3481 "Review Changes",
3482 &OpenAgentDiff,
3483 &focus_handle,
3484 window,
3485 cx,
3486 )
3487 }
3488 })
3489 .on_click(cx.listener(|_, _, window, cx| {
3490 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
3491 })),
3492 )
3493 .child(Divider::vertical().color(DividerColor::Border))
3494 .child(
3495 Button::new("reject-all-changes", "Reject All")
3496 .label_size(LabelSize::Small)
3497 .disabled(pending_edits)
3498 .when(pending_edits, |this| {
3499 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3500 })
3501 .key_binding(
3502 KeyBinding::for_action_in(
3503 &RejectAll,
3504 &focus_handle.clone(),
3505 window,
3506 cx,
3507 )
3508 .map(|kb| kb.size(rems_from_px(10.))),
3509 )
3510 .on_click(cx.listener(move |this, _, window, cx| {
3511 this.reject_all(&RejectAll, window, cx);
3512 })),
3513 )
3514 .child(
3515 Button::new("keep-all-changes", "Keep All")
3516 .label_size(LabelSize::Small)
3517 .disabled(pending_edits)
3518 .when(pending_edits, |this| {
3519 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
3520 })
3521 .key_binding(
3522 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
3523 .map(|kb| kb.size(rems_from_px(10.))),
3524 )
3525 .on_click(cx.listener(move |this, _, window, cx| {
3526 this.keep_all(&KeepAll, window, cx);
3527 })),
3528 ),
3529 )
3530 }
3531
3532 fn render_edited_files(
3533 &self,
3534 action_log: &Entity<ActionLog>,
3535 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
3536 pending_edits: bool,
3537 cx: &Context<Self>,
3538 ) -> Div {
3539 let editor_bg_color = cx.theme().colors().editor_background;
3540
3541 v_flex().children(changed_buffers.iter().enumerate().flat_map(
3542 |(index, (buffer, _diff))| {
3543 let file = buffer.read(cx).file()?;
3544 let path = file.path();
3545
3546 let file_path = path.parent().and_then(|parent| {
3547 let parent_str = parent.to_string_lossy();
3548
3549 if parent_str.is_empty() {
3550 None
3551 } else {
3552 Some(
3553 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
3554 .color(Color::Muted)
3555 .size(LabelSize::XSmall)
3556 .buffer_font(cx),
3557 )
3558 }
3559 });
3560
3561 let file_name = path.file_name().map(|name| {
3562 Label::new(name.to_string_lossy().to_string())
3563 .size(LabelSize::XSmall)
3564 .buffer_font(cx)
3565 });
3566
3567 let file_icon = FileIcons::get_icon(path, cx)
3568 .map(Icon::from_path)
3569 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
3570 .unwrap_or_else(|| {
3571 Icon::new(IconName::File)
3572 .color(Color::Muted)
3573 .size(IconSize::Small)
3574 });
3575
3576 let overlay_gradient = linear_gradient(
3577 90.,
3578 linear_color_stop(editor_bg_color, 1.),
3579 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
3580 );
3581
3582 let element = h_flex()
3583 .group("edited-code")
3584 .id(("file-container", index))
3585 .py_1()
3586 .pl_2()
3587 .pr_1()
3588 .gap_2()
3589 .justify_between()
3590 .bg(editor_bg_color)
3591 .when(index < changed_buffers.len() - 1, |parent| {
3592 parent.border_color(cx.theme().colors().border).border_b_1()
3593 })
3594 .child(
3595 h_flex()
3596 .relative()
3597 .id(("file-name", index))
3598 .pr_8()
3599 .gap_1p5()
3600 .max_w_full()
3601 .overflow_x_scroll()
3602 .child(file_icon)
3603 .child(h_flex().gap_0p5().children(file_name).children(file_path))
3604 .child(
3605 div()
3606 .absolute()
3607 .h_full()
3608 .w_12()
3609 .top_0()
3610 .bottom_0()
3611 .right_0()
3612 .bg(overlay_gradient),
3613 )
3614 .on_click({
3615 let buffer = buffer.clone();
3616 cx.listener(move |this, _, window, cx| {
3617 this.open_edited_buffer(&buffer, window, cx);
3618 })
3619 }),
3620 )
3621 .child(
3622 h_flex()
3623 .gap_1()
3624 .visible_on_hover("edited-code")
3625 .child(
3626 Button::new("review", "Review")
3627 .label_size(LabelSize::Small)
3628 .on_click({
3629 let buffer = buffer.clone();
3630 cx.listener(move |this, _, window, cx| {
3631 this.open_edited_buffer(&buffer, window, cx);
3632 })
3633 }),
3634 )
3635 .child(Divider::vertical().color(DividerColor::BorderVariant))
3636 .child(
3637 Button::new("reject-file", "Reject")
3638 .label_size(LabelSize::Small)
3639 .disabled(pending_edits)
3640 .on_click({
3641 let buffer = buffer.clone();
3642 let action_log = action_log.clone();
3643 move |_, _, cx| {
3644 action_log.update(cx, |action_log, cx| {
3645 action_log
3646 .reject_edits_in_ranges(
3647 buffer.clone(),
3648 vec![Anchor::MIN..Anchor::MAX],
3649 cx,
3650 )
3651 .detach_and_log_err(cx);
3652 })
3653 }
3654 }),
3655 )
3656 .child(
3657 Button::new("keep-file", "Keep")
3658 .label_size(LabelSize::Small)
3659 .disabled(pending_edits)
3660 .on_click({
3661 let buffer = buffer.clone();
3662 let action_log = action_log.clone();
3663 move |_, _, cx| {
3664 action_log.update(cx, |action_log, cx| {
3665 action_log.keep_edits_in_range(
3666 buffer.clone(),
3667 Anchor::MIN..Anchor::MAX,
3668 cx,
3669 );
3670 })
3671 }
3672 }),
3673 ),
3674 );
3675
3676 Some(element)
3677 },
3678 ))
3679 }
3680
3681 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
3682 let focus_handle = self.message_editor.focus_handle(cx);
3683 let editor_bg_color = cx.theme().colors().editor_background;
3684 let (expand_icon, expand_tooltip) = if self.editor_expanded {
3685 (IconName::Minimize, "Minimize Message Editor")
3686 } else {
3687 (IconName::Maximize, "Expand Message Editor")
3688 };
3689
3690 let backdrop = div()
3691 .size_full()
3692 .absolute()
3693 .inset_0()
3694 .bg(cx.theme().colors().panel_background)
3695 .opacity(0.8)
3696 .block_mouse_except_scroll();
3697
3698 let enable_editor = match self.thread_state {
3699 ThreadState::Loading { .. } | ThreadState::Ready { .. } => true,
3700 ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false,
3701 };
3702
3703 v_flex()
3704 .on_action(cx.listener(Self::expand_message_editor))
3705 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
3706 if let Some(profile_selector) = this.profile_selector.as_ref() {
3707 profile_selector.read(cx).menu_handle().toggle(window, cx);
3708 }
3709 }))
3710 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
3711 if let Some(model_selector) = this.model_selector.as_ref() {
3712 model_selector
3713 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
3714 }
3715 }))
3716 .p_2()
3717 .gap_2()
3718 .border_t_1()
3719 .border_color(cx.theme().colors().border)
3720 .bg(editor_bg_color)
3721 .when(self.editor_expanded, |this| {
3722 this.h(vh(0.8, window)).size_full().justify_between()
3723 })
3724 .child(
3725 v_flex()
3726 .relative()
3727 .size_full()
3728 .pt_1()
3729 .pr_2p5()
3730 .child(self.message_editor.clone())
3731 .child(
3732 h_flex()
3733 .absolute()
3734 .top_0()
3735 .right_0()
3736 .opacity(0.5)
3737 .hover(|this| this.opacity(1.0))
3738 .child(
3739 IconButton::new("toggle-height", expand_icon)
3740 .icon_size(IconSize::Small)
3741 .icon_color(Color::Muted)
3742 .tooltip({
3743 move |window, cx| {
3744 Tooltip::for_action_in(
3745 expand_tooltip,
3746 &ExpandMessageEditor,
3747 &focus_handle,
3748 window,
3749 cx,
3750 )
3751 }
3752 })
3753 .on_click(cx.listener(|_, _, window, cx| {
3754 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
3755 })),
3756 ),
3757 ),
3758 )
3759 .child(
3760 h_flex()
3761 .flex_none()
3762 .flex_wrap()
3763 .justify_between()
3764 .child(
3765 h_flex()
3766 .child(self.render_follow_toggle(cx))
3767 .children(self.render_burn_mode_toggle(cx)),
3768 )
3769 .child(
3770 h_flex()
3771 .gap_1()
3772 .children(self.render_token_usage(cx))
3773 .children(self.profile_selector.clone())
3774 .children(self.model_selector.clone())
3775 .child(self.render_send_button(cx)),
3776 ),
3777 )
3778 .when(!enable_editor, |this| this.child(backdrop))
3779 .into_any()
3780 }
3781
3782 pub(crate) fn as_native_connection(
3783 &self,
3784 cx: &App,
3785 ) -> Option<Rc<agent2::NativeAgentConnection>> {
3786 let acp_thread = self.thread()?.read(cx);
3787 acp_thread.connection().clone().downcast()
3788 }
3789
3790 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
3791 let acp_thread = self.thread()?.read(cx);
3792 self.as_native_connection(cx)?
3793 .thread(acp_thread.session_id(), cx)
3794 }
3795
3796 fn is_using_zed_ai_models(&self, cx: &App) -> bool {
3797 self.as_native_thread(cx)
3798 .and_then(|thread| thread.read(cx).model())
3799 .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID)
3800 }
3801
3802 fn render_token_usage(&self, cx: &mut Context<Self>) -> Option<Div> {
3803 let thread = self.thread()?.read(cx);
3804 let usage = thread.token_usage()?;
3805 let is_generating = thread.status() != ThreadStatus::Idle;
3806
3807 let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
3808 let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
3809
3810 Some(
3811 h_flex()
3812 .flex_shrink_0()
3813 .gap_0p5()
3814 .mr_1p5()
3815 .child(
3816 Label::new(used)
3817 .size(LabelSize::Small)
3818 .color(Color::Muted)
3819 .map(|label| {
3820 if is_generating {
3821 label
3822 .with_animation(
3823 "used-tokens-label",
3824 Animation::new(Duration::from_secs(2))
3825 .repeat()
3826 .with_easing(pulsating_between(0.3, 0.8)),
3827 |label, delta| label.alpha(delta),
3828 )
3829 .into_any()
3830 } else {
3831 label.into_any_element()
3832 }
3833 }),
3834 )
3835 .child(
3836 Label::new("/")
3837 .size(LabelSize::Small)
3838 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
3839 )
3840 .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
3841 )
3842 }
3843
3844 fn toggle_burn_mode(
3845 &mut self,
3846 _: &ToggleBurnMode,
3847 _window: &mut Window,
3848 cx: &mut Context<Self>,
3849 ) {
3850 let Some(thread) = self.as_native_thread(cx) else {
3851 return;
3852 };
3853
3854 thread.update(cx, |thread, cx| {
3855 let current_mode = thread.completion_mode();
3856 thread.set_completion_mode(
3857 match current_mode {
3858 CompletionMode::Burn => CompletionMode::Normal,
3859 CompletionMode::Normal => CompletionMode::Burn,
3860 },
3861 cx,
3862 );
3863 });
3864 }
3865
3866 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
3867 let Some(thread) = self.thread() else {
3868 return;
3869 };
3870 let action_log = thread.read(cx).action_log().clone();
3871 action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx));
3872 }
3873
3874 fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context<Self>) {
3875 let Some(thread) = self.thread() else {
3876 return;
3877 };
3878 let action_log = thread.read(cx).action_log().clone();
3879 action_log
3880 .update(cx, |action_log, cx| action_log.reject_all_edits(cx))
3881 .detach();
3882 }
3883
3884 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3885 let thread = self.as_native_thread(cx)?.read(cx);
3886
3887 if thread
3888 .model()
3889 .is_none_or(|model| !model.supports_burn_mode())
3890 {
3891 return None;
3892 }
3893
3894 let active_completion_mode = thread.completion_mode();
3895 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
3896 let icon = if burn_mode_enabled {
3897 IconName::ZedBurnModeOn
3898 } else {
3899 IconName::ZedBurnMode
3900 };
3901
3902 Some(
3903 IconButton::new("burn-mode", icon)
3904 .icon_size(IconSize::Small)
3905 .icon_color(Color::Muted)
3906 .toggle_state(burn_mode_enabled)
3907 .selected_icon_color(Color::Error)
3908 .on_click(cx.listener(|this, _event, window, cx| {
3909 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
3910 }))
3911 .tooltip(move |_window, cx| {
3912 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
3913 .into()
3914 })
3915 .into_any_element(),
3916 )
3917 }
3918
3919 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
3920 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
3921 let is_generating = self
3922 .thread()
3923 .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
3924
3925 if self.is_loading_contents {
3926 div()
3927 .id("loading-message-content")
3928 .px_1()
3929 .tooltip(Tooltip::text("Loading Added Context…"))
3930 .child(loading_contents_spinner(IconSize::default()))
3931 .into_any_element()
3932 } else if is_generating && is_editor_empty {
3933 IconButton::new("stop-generation", IconName::Stop)
3934 .icon_color(Color::Error)
3935 .style(ButtonStyle::Tinted(ui::TintColor::Error))
3936 .tooltip(move |window, cx| {
3937 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
3938 })
3939 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
3940 .into_any_element()
3941 } else {
3942 let send_btn_tooltip = if is_editor_empty && !is_generating {
3943 "Type to Send"
3944 } else if is_generating {
3945 "Stop and Send Message"
3946 } else {
3947 "Send"
3948 };
3949
3950 IconButton::new("send-message", IconName::Send)
3951 .style(ButtonStyle::Filled)
3952 .map(|this| {
3953 if is_editor_empty && !is_generating {
3954 this.disabled(true).icon_color(Color::Muted)
3955 } else {
3956 this.icon_color(Color::Accent)
3957 }
3958 })
3959 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
3960 .on_click(cx.listener(|this, _, window, cx| {
3961 this.send(window, cx);
3962 }))
3963 .into_any_element()
3964 }
3965 }
3966
3967 fn is_following(&self, cx: &App) -> bool {
3968 match self.thread().map(|thread| thread.read(cx).status()) {
3969 Some(ThreadStatus::Generating) => self
3970 .workspace
3971 .read_with(cx, |workspace, _| {
3972 workspace.is_being_followed(CollaboratorId::Agent)
3973 })
3974 .unwrap_or(false),
3975 _ => self.should_be_following,
3976 }
3977 }
3978
3979 fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3980 let following = self.is_following(cx);
3981
3982 self.should_be_following = !following;
3983 if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
3984 self.workspace
3985 .update(cx, |workspace, cx| {
3986 if following {
3987 workspace.unfollow(CollaboratorId::Agent, window, cx);
3988 } else {
3989 workspace.follow(CollaboratorId::Agent, window, cx);
3990 }
3991 })
3992 .ok();
3993 }
3994
3995 telemetry::event!("Follow Agent Selected", following = !following);
3996 }
3997
3998 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
3999 let following = self.is_following(cx);
4000
4001 let tooltip_label = if following {
4002 if self.agent.name() == "Zed Agent" {
4003 format!("Stop Following the {}", self.agent.name())
4004 } else {
4005 format!("Stop Following {}", self.agent.name())
4006 }
4007 } else {
4008 if self.agent.name() == "Zed Agent" {
4009 format!("Follow the {}", self.agent.name())
4010 } else {
4011 format!("Follow {}", self.agent.name())
4012 }
4013 };
4014
4015 IconButton::new("follow-agent", IconName::Crosshair)
4016 .icon_size(IconSize::Small)
4017 .icon_color(Color::Muted)
4018 .toggle_state(following)
4019 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
4020 .tooltip(move |window, cx| {
4021 if following {
4022 Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx)
4023 } else {
4024 Tooltip::with_meta(
4025 tooltip_label.clone(),
4026 Some(&Follow),
4027 "Track the agent's location as it reads and edits files.",
4028 window,
4029 cx,
4030 )
4031 }
4032 })
4033 .on_click(cx.listener(move |this, _, window, cx| {
4034 this.toggle_following(window, cx);
4035 }))
4036 }
4037
4038 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
4039 let workspace = self.workspace.clone();
4040 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
4041 Self::open_link(text, &workspace, window, cx);
4042 })
4043 }
4044
4045 fn open_link(
4046 url: SharedString,
4047 workspace: &WeakEntity<Workspace>,
4048 window: &mut Window,
4049 cx: &mut App,
4050 ) {
4051 let Some(workspace) = workspace.upgrade() else {
4052 cx.open_url(&url);
4053 return;
4054 };
4055
4056 if let Some(mention) = MentionUri::parse(&url).log_err() {
4057 workspace.update(cx, |workspace, cx| match mention {
4058 MentionUri::File { abs_path } => {
4059 let project = workspace.project();
4060 let Some(path) =
4061 project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
4062 else {
4063 return;
4064 };
4065
4066 workspace
4067 .open_path(path, None, true, window, cx)
4068 .detach_and_log_err(cx);
4069 }
4070 MentionUri::PastedImage => {}
4071 MentionUri::Directory { abs_path } => {
4072 let project = workspace.project();
4073 let Some(entry) = project.update(cx, |project, cx| {
4074 let path = project.find_project_path(abs_path, cx)?;
4075 project.entry_for_path(&path, cx)
4076 }) else {
4077 return;
4078 };
4079
4080 project.update(cx, |_, cx| {
4081 cx.emit(project::Event::RevealInProjectPanel(entry.id));
4082 });
4083 }
4084 MentionUri::Symbol {
4085 abs_path: path,
4086 line_range,
4087 ..
4088 }
4089 | MentionUri::Selection {
4090 abs_path: Some(path),
4091 line_range,
4092 } => {
4093 let project = workspace.project();
4094 let Some((path, _)) = project.update(cx, |project, cx| {
4095 let path = project.find_project_path(path, cx)?;
4096 let entry = project.entry_for_path(&path, cx)?;
4097 Some((path, entry))
4098 }) else {
4099 return;
4100 };
4101
4102 let item = workspace.open_path(path, None, true, window, cx);
4103 window
4104 .spawn(cx, async move |cx| {
4105 let Some(editor) = item.await?.downcast::<Editor>() else {
4106 return Ok(());
4107 };
4108 let range = Point::new(*line_range.start(), 0)
4109 ..Point::new(*line_range.start(), 0);
4110 editor
4111 .update_in(cx, |editor, window, cx| {
4112 editor.change_selections(
4113 SelectionEffects::scroll(Autoscroll::center()),
4114 window,
4115 cx,
4116 |s| s.select_ranges(vec![range]),
4117 );
4118 })
4119 .ok();
4120 anyhow::Ok(())
4121 })
4122 .detach_and_log_err(cx);
4123 }
4124 MentionUri::Selection { abs_path: None, .. } => {}
4125 MentionUri::Thread { id, name } => {
4126 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4127 panel.update(cx, |panel, cx| {
4128 panel.load_agent_thread(
4129 DbThreadMetadata {
4130 id,
4131 title: name.into(),
4132 updated_at: Default::default(),
4133 },
4134 window,
4135 cx,
4136 )
4137 });
4138 }
4139 }
4140 MentionUri::TextThread { path, .. } => {
4141 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4142 panel.update(cx, |panel, cx| {
4143 panel
4144 .open_saved_prompt_editor(path.as_path().into(), window, cx)
4145 .detach_and_log_err(cx);
4146 });
4147 }
4148 }
4149 MentionUri::Rule { id, .. } => {
4150 let PromptId::User { uuid } = id else {
4151 return;
4152 };
4153 window.dispatch_action(
4154 Box::new(OpenRulesLibrary {
4155 prompt_to_select: Some(uuid.0),
4156 }),
4157 cx,
4158 )
4159 }
4160 MentionUri::Fetch { url } => {
4161 cx.open_url(url.as_str());
4162 }
4163 })
4164 } else {
4165 cx.open_url(&url);
4166 }
4167 }
4168
4169 fn open_tool_call_location(
4170 &self,
4171 entry_ix: usize,
4172 location_ix: usize,
4173 window: &mut Window,
4174 cx: &mut Context<Self>,
4175 ) -> Option<()> {
4176 let (tool_call_location, agent_location) = self
4177 .thread()?
4178 .read(cx)
4179 .entries()
4180 .get(entry_ix)?
4181 .location(location_ix)?;
4182
4183 let project_path = self
4184 .project
4185 .read(cx)
4186 .find_project_path(&tool_call_location.path, cx)?;
4187
4188 let open_task = self
4189 .workspace
4190 .update(cx, |workspace, cx| {
4191 workspace.open_path(project_path, None, true, window, cx)
4192 })
4193 .log_err()?;
4194 window
4195 .spawn(cx, async move |cx| {
4196 let item = open_task.await?;
4197
4198 let Some(active_editor) = item.downcast::<Editor>() else {
4199 return anyhow::Ok(());
4200 };
4201
4202 active_editor.update_in(cx, |editor, window, cx| {
4203 let multibuffer = editor.buffer().read(cx);
4204 let buffer = multibuffer.as_singleton();
4205 if agent_location.buffer.upgrade() == buffer {
4206 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
4207 let anchor = editor::Anchor::in_buffer(
4208 excerpt_id.unwrap(),
4209 buffer.unwrap().read(cx).remote_id(),
4210 agent_location.position,
4211 );
4212 editor.change_selections(Default::default(), window, cx, |selections| {
4213 selections.select_anchor_ranges([anchor..anchor]);
4214 })
4215 } else {
4216 let row = tool_call_location.line.unwrap_or_default();
4217 editor.change_selections(Default::default(), window, cx, |selections| {
4218 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
4219 })
4220 }
4221 })?;
4222
4223 anyhow::Ok(())
4224 })
4225 .detach_and_log_err(cx);
4226
4227 None
4228 }
4229
4230 pub fn open_thread_as_markdown(
4231 &self,
4232 workspace: Entity<Workspace>,
4233 window: &mut Window,
4234 cx: &mut App,
4235 ) -> Task<Result<()>> {
4236 let markdown_language_task = workspace
4237 .read(cx)
4238 .app_state()
4239 .languages
4240 .language_for_name("Markdown");
4241
4242 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
4243 let thread = thread.read(cx);
4244 (thread.title().to_string(), thread.to_markdown(cx))
4245 } else {
4246 return Task::ready(Ok(()));
4247 };
4248
4249 window.spawn(cx, async move |cx| {
4250 let markdown_language = markdown_language_task.await?;
4251
4252 workspace.update_in(cx, |workspace, window, cx| {
4253 let project = workspace.project().clone();
4254
4255 if !project.read(cx).is_local() {
4256 bail!("failed to open active thread as markdown in remote project");
4257 }
4258
4259 let buffer = project.update(cx, |project, cx| {
4260 project.create_local_buffer(&markdown, Some(markdown_language), cx)
4261 });
4262 let buffer = cx.new(|cx| {
4263 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
4264 });
4265
4266 workspace.add_item_to_active_pane(
4267 Box::new(cx.new(|cx| {
4268 let mut editor =
4269 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4270 editor.set_breadcrumb_header(thread_summary);
4271 editor
4272 })),
4273 None,
4274 true,
4275 window,
4276 cx,
4277 );
4278
4279 anyhow::Ok(())
4280 })??;
4281 anyhow::Ok(())
4282 })
4283 }
4284
4285 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
4286 self.list_state.scroll_to(ListOffset::default());
4287 cx.notify();
4288 }
4289
4290 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
4291 if let Some(thread) = self.thread() {
4292 let entry_count = thread.read(cx).entries().len();
4293 self.list_state.reset(entry_count);
4294 cx.notify();
4295 }
4296 }
4297
4298 fn notify_with_sound(
4299 &mut self,
4300 caption: impl Into<SharedString>,
4301 icon: IconName,
4302 window: &mut Window,
4303 cx: &mut Context<Self>,
4304 ) {
4305 self.play_notification_sound(window, cx);
4306 self.show_notification(caption, icon, window, cx);
4307 }
4308
4309 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
4310 let settings = AgentSettings::get_global(cx);
4311 if settings.play_sound_when_agent_done && !window.is_window_active() {
4312 Audio::play_sound(Sound::AgentDone, cx);
4313 }
4314 }
4315
4316 fn show_notification(
4317 &mut self,
4318 caption: impl Into<SharedString>,
4319 icon: IconName,
4320 window: &mut Window,
4321 cx: &mut Context<Self>,
4322 ) {
4323 if window.is_window_active() || !self.notifications.is_empty() {
4324 return;
4325 }
4326
4327 // TODO: Change this once we have title summarization for external agents.
4328 let title = self.agent.name();
4329
4330 match AgentSettings::get_global(cx).notify_when_agent_waiting {
4331 NotifyWhenAgentWaiting::PrimaryScreen => {
4332 if let Some(primary) = cx.primary_display() {
4333 self.pop_up(icon, caption.into(), title, window, primary, cx);
4334 }
4335 }
4336 NotifyWhenAgentWaiting::AllScreens => {
4337 let caption = caption.into();
4338 for screen in cx.displays() {
4339 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
4340 }
4341 }
4342 NotifyWhenAgentWaiting::Never => {
4343 // Don't show anything
4344 }
4345 }
4346 }
4347
4348 fn pop_up(
4349 &mut self,
4350 icon: IconName,
4351 caption: SharedString,
4352 title: SharedString,
4353 window: &mut Window,
4354 screen: Rc<dyn PlatformDisplay>,
4355 cx: &mut Context<Self>,
4356 ) {
4357 let options = AgentNotification::window_options(screen, cx);
4358
4359 let project_name = self.workspace.upgrade().and_then(|workspace| {
4360 workspace
4361 .read(cx)
4362 .project()
4363 .read(cx)
4364 .visible_worktrees(cx)
4365 .next()
4366 .map(|worktree| worktree.read(cx).root_name().to_string())
4367 });
4368
4369 if let Some(screen_window) = cx
4370 .open_window(options, |_, cx| {
4371 cx.new(|_| {
4372 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
4373 })
4374 })
4375 .log_err()
4376 && let Some(pop_up) = screen_window.entity(cx).log_err()
4377 {
4378 self.notification_subscriptions
4379 .entry(screen_window)
4380 .or_insert_with(Vec::new)
4381 .push(cx.subscribe_in(&pop_up, window, {
4382 |this, _, event, window, cx| match event {
4383 AgentNotificationEvent::Accepted => {
4384 let handle = window.window_handle();
4385 cx.activate(true);
4386
4387 let workspace_handle = this.workspace.clone();
4388
4389 // If there are multiple Zed windows, activate the correct one.
4390 cx.defer(move |cx| {
4391 handle
4392 .update(cx, |_view, window, _cx| {
4393 window.activate_window();
4394
4395 if let Some(workspace) = workspace_handle.upgrade() {
4396 workspace.update(_cx, |workspace, cx| {
4397 workspace.focus_panel::<AgentPanel>(window, cx);
4398 });
4399 }
4400 })
4401 .log_err();
4402 });
4403
4404 this.dismiss_notifications(cx);
4405 }
4406 AgentNotificationEvent::Dismissed => {
4407 this.dismiss_notifications(cx);
4408 }
4409 }
4410 }));
4411
4412 self.notifications.push(screen_window);
4413
4414 // If the user manually refocuses the original window, dismiss the popup.
4415 self.notification_subscriptions
4416 .entry(screen_window)
4417 .or_insert_with(Vec::new)
4418 .push({
4419 let pop_up_weak = pop_up.downgrade();
4420
4421 cx.observe_window_activation(window, move |_, window, cx| {
4422 if window.is_window_active()
4423 && let Some(pop_up) = pop_up_weak.upgrade()
4424 {
4425 pop_up.update(cx, |_, cx| {
4426 cx.emit(AgentNotificationEvent::Dismissed);
4427 });
4428 }
4429 })
4430 });
4431 }
4432 }
4433
4434 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
4435 for window in self.notifications.drain(..) {
4436 window
4437 .update(cx, |_, window, _| {
4438 window.remove_window();
4439 })
4440 .ok();
4441
4442 self.notification_subscriptions.remove(&window);
4443 }
4444 }
4445
4446 fn render_thread_controls(
4447 &self,
4448 thread: &Entity<AcpThread>,
4449 cx: &Context<Self>,
4450 ) -> impl IntoElement {
4451 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
4452 if is_generating {
4453 return h_flex().id("thread-controls-container").child(
4454 div()
4455 .py_2()
4456 .px(rems_from_px(22.))
4457 .child(SpinnerLabel::new().size(LabelSize::Small)),
4458 );
4459 }
4460
4461 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
4462 .shape(ui::IconButtonShape::Square)
4463 .icon_size(IconSize::Small)
4464 .icon_color(Color::Ignored)
4465 .tooltip(Tooltip::text("Open Thread as Markdown"))
4466 .on_click(cx.listener(move |this, _, window, cx| {
4467 if let Some(workspace) = this.workspace.upgrade() {
4468 this.open_thread_as_markdown(workspace, window, cx)
4469 .detach_and_log_err(cx);
4470 }
4471 }));
4472
4473 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
4474 .shape(ui::IconButtonShape::Square)
4475 .icon_size(IconSize::Small)
4476 .icon_color(Color::Ignored)
4477 .tooltip(Tooltip::text("Scroll To Top"))
4478 .on_click(cx.listener(move |this, _, _, cx| {
4479 this.scroll_to_top(cx);
4480 }));
4481
4482 let mut container = h_flex()
4483 .id("thread-controls-container")
4484 .group("thread-controls-container")
4485 .w_full()
4486 .py_2()
4487 .px_5()
4488 .gap_px()
4489 .opacity(0.6)
4490 .hover(|style| style.opacity(1.))
4491 .flex_wrap()
4492 .justify_end();
4493
4494 if AgentSettings::get_global(cx).enable_feedback
4495 && self
4496 .thread()
4497 .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
4498 {
4499 let feedback = self.thread_feedback.feedback;
4500
4501 container = container
4502 .child(
4503 div().visible_on_hover("thread-controls-container").child(
4504 Label::new(match feedback {
4505 Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
4506 Some(ThreadFeedback::Negative) => {
4507 "We appreciate your feedback and will use it to improve."
4508 }
4509 None => {
4510 "Rating the thread sends all of your current conversation to the Zed team."
4511 }
4512 })
4513 .color(Color::Muted)
4514 .size(LabelSize::XSmall)
4515 .truncate(),
4516 ),
4517 )
4518 .child(
4519 IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
4520 .shape(ui::IconButtonShape::Square)
4521 .icon_size(IconSize::Small)
4522 .icon_color(match feedback {
4523 Some(ThreadFeedback::Positive) => Color::Accent,
4524 _ => Color::Ignored,
4525 })
4526 .tooltip(Tooltip::text("Helpful Response"))
4527 .on_click(cx.listener(move |this, _, window, cx| {
4528 this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
4529 })),
4530 )
4531 .child(
4532 IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
4533 .shape(ui::IconButtonShape::Square)
4534 .icon_size(IconSize::Small)
4535 .icon_color(match feedback {
4536 Some(ThreadFeedback::Negative) => Color::Accent,
4537 _ => Color::Ignored,
4538 })
4539 .tooltip(Tooltip::text("Not Helpful"))
4540 .on_click(cx.listener(move |this, _, window, cx| {
4541 this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
4542 })),
4543 );
4544 }
4545
4546 container.child(open_as_markdown).child(scroll_to_top)
4547 }
4548
4549 fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {
4550 h_flex()
4551 .key_context("AgentFeedbackMessageEditor")
4552 .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| {
4553 this.thread_feedback.dismiss_comments();
4554 cx.notify();
4555 }))
4556 .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| {
4557 this.submit_feedback_message(cx);
4558 }))
4559 .p_2()
4560 .mb_2()
4561 .mx_5()
4562 .gap_1()
4563 .rounded_md()
4564 .border_1()
4565 .border_color(cx.theme().colors().border)
4566 .bg(cx.theme().colors().editor_background)
4567 .child(div().w_full().child(editor))
4568 .child(
4569 h_flex()
4570 .child(
4571 IconButton::new("dismiss-feedback-message", IconName::Close)
4572 .icon_color(Color::Error)
4573 .icon_size(IconSize::XSmall)
4574 .shape(ui::IconButtonShape::Square)
4575 .on_click(cx.listener(move |this, _, _window, cx| {
4576 this.thread_feedback.dismiss_comments();
4577 cx.notify();
4578 })),
4579 )
4580 .child(
4581 IconButton::new("submit-feedback-message", IconName::Return)
4582 .icon_size(IconSize::XSmall)
4583 .shape(ui::IconButtonShape::Square)
4584 .on_click(cx.listener(move |this, _, _window, cx| {
4585 this.submit_feedback_message(cx);
4586 })),
4587 ),
4588 )
4589 }
4590
4591 fn handle_feedback_click(
4592 &mut self,
4593 feedback: ThreadFeedback,
4594 window: &mut Window,
4595 cx: &mut Context<Self>,
4596 ) {
4597 let Some(thread) = self.thread().cloned() else {
4598 return;
4599 };
4600
4601 self.thread_feedback.submit(thread, feedback, window, cx);
4602 cx.notify();
4603 }
4604
4605 fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
4606 let Some(thread) = self.thread().cloned() else {
4607 return;
4608 };
4609
4610 self.thread_feedback.submit_comments(thread, cx);
4611 cx.notify();
4612 }
4613
4614 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
4615 div()
4616 .id("acp-thread-scrollbar")
4617 .occlude()
4618 .on_mouse_move(cx.listener(|_, _, _, cx| {
4619 cx.notify();
4620 cx.stop_propagation()
4621 }))
4622 .on_hover(|_, _, cx| {
4623 cx.stop_propagation();
4624 })
4625 .on_any_mouse_down(|_, _, cx| {
4626 cx.stop_propagation();
4627 })
4628 .on_mouse_up(
4629 MouseButton::Left,
4630 cx.listener(|_, _, _, cx| {
4631 cx.stop_propagation();
4632 }),
4633 )
4634 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4635 cx.notify();
4636 }))
4637 .h_full()
4638 .absolute()
4639 .right_1()
4640 .top_1()
4641 .bottom_0()
4642 .w(px(12.))
4643 .cursor_default()
4644 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
4645 }
4646
4647 fn render_token_limit_callout(
4648 &self,
4649 line_height: Pixels,
4650 cx: &mut Context<Self>,
4651 ) -> Option<Callout> {
4652 let token_usage = self.thread()?.read(cx).token_usage()?;
4653 let ratio = token_usage.ratio();
4654
4655 let (severity, title) = match ratio {
4656 acp_thread::TokenUsageRatio::Normal => return None,
4657 acp_thread::TokenUsageRatio::Warning => {
4658 (Severity::Warning, "Thread reaching the token limit soon")
4659 }
4660 acp_thread::TokenUsageRatio::Exceeded => {
4661 (Severity::Error, "Thread reached the token limit")
4662 }
4663 };
4664
4665 let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
4666 thread.read(cx).completion_mode() == CompletionMode::Normal
4667 && thread
4668 .read(cx)
4669 .model()
4670 .is_some_and(|model| model.supports_burn_mode())
4671 });
4672
4673 let description = if burn_mode_available {
4674 "To continue, start a new thread from a summary or turn Burn Mode on."
4675 } else {
4676 "To continue, start a new thread from a summary."
4677 };
4678
4679 Some(
4680 Callout::new()
4681 .severity(severity)
4682 .line_height(line_height)
4683 .title(title)
4684 .description(description)
4685 .actions_slot(
4686 h_flex()
4687 .gap_0p5()
4688 .child(
4689 Button::new("start-new-thread", "Start New Thread")
4690 .label_size(LabelSize::Small)
4691 .on_click(cx.listener(|this, _, window, cx| {
4692 let Some(thread) = this.thread() else {
4693 return;
4694 };
4695 let session_id = thread.read(cx).session_id().clone();
4696 window.dispatch_action(
4697 crate::NewNativeAgentThreadFromSummary {
4698 from_session_id: session_id,
4699 }
4700 .boxed_clone(),
4701 cx,
4702 );
4703 })),
4704 )
4705 .when(burn_mode_available, |this| {
4706 this.child(
4707 IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
4708 .icon_size(IconSize::XSmall)
4709 .on_click(cx.listener(|this, _event, window, cx| {
4710 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4711 })),
4712 )
4713 }),
4714 ),
4715 )
4716 }
4717
4718 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
4719 if !self.is_using_zed_ai_models(cx) {
4720 return None;
4721 }
4722
4723 let user_store = self.project.read(cx).user_store().read(cx);
4724 if user_store.is_usage_based_billing_enabled() {
4725 return None;
4726 }
4727
4728 let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
4729
4730 let usage = user_store.model_request_usage()?;
4731
4732 Some(
4733 div()
4734 .child(UsageCallout::new(plan, usage))
4735 .line_height(line_height),
4736 )
4737 }
4738
4739 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4740 self.entry_view_state.update(cx, |entry_view_state, cx| {
4741 entry_view_state.settings_changed(cx);
4742 });
4743 }
4744
4745 pub(crate) fn insert_dragged_files(
4746 &self,
4747 paths: Vec<project::ProjectPath>,
4748 added_worktrees: Vec<Entity<project::Worktree>>,
4749 window: &mut Window,
4750 cx: &mut Context<Self>,
4751 ) {
4752 self.message_editor.update(cx, |message_editor, cx| {
4753 message_editor.insert_dragged_files(paths, added_worktrees, window, cx);
4754 })
4755 }
4756
4757 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
4758 self.message_editor.update(cx, |message_editor, cx| {
4759 message_editor.insert_selections(window, cx);
4760 })
4761 }
4762
4763 fn render_thread_retry_status_callout(
4764 &self,
4765 _window: &mut Window,
4766 _cx: &mut Context<Self>,
4767 ) -> Option<Callout> {
4768 let state = self.thread_retry_status.as_ref()?;
4769
4770 let next_attempt_in = state
4771 .duration
4772 .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
4773 if next_attempt_in.is_zero() {
4774 return None;
4775 }
4776
4777 let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
4778
4779 let retry_message = if state.max_attempts == 1 {
4780 if next_attempt_in_secs == 1 {
4781 "Retrying. Next attempt in 1 second.".to_string()
4782 } else {
4783 format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
4784 }
4785 } else if next_attempt_in_secs == 1 {
4786 format!(
4787 "Retrying. Next attempt in 1 second (Attempt {} of {}).",
4788 state.attempt, state.max_attempts,
4789 )
4790 } else {
4791 format!(
4792 "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
4793 state.attempt, state.max_attempts,
4794 )
4795 };
4796
4797 Some(
4798 Callout::new()
4799 .severity(Severity::Warning)
4800 .title(state.last_error.clone())
4801 .description(retry_message),
4802 )
4803 }
4804
4805 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
4806 let content = match self.thread_error.as_ref()? {
4807 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
4808 ThreadError::Refusal => self.render_refusal_error(cx),
4809 ThreadError::AuthenticationRequired(error) => {
4810 self.render_authentication_required_error(error.clone(), cx)
4811 }
4812 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
4813 ThreadError::ModelRequestLimitReached(plan) => {
4814 self.render_model_request_limit_reached_error(*plan, cx)
4815 }
4816 ThreadError::ToolUseLimitReached => {
4817 self.render_tool_use_limit_reached_error(window, cx)?
4818 }
4819 };
4820
4821 Some(div().child(content))
4822 }
4823
4824 fn get_current_model_name(&self, cx: &App) -> SharedString {
4825 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
4826 // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
4827 // This provides better clarity about what refused the request
4828 if self
4829 .agent
4830 .clone()
4831 .downcast::<agent2::NativeAgentServer>()
4832 .is_some()
4833 {
4834 // Native agent - use the model name
4835 self.model_selector
4836 .as_ref()
4837 .and_then(|selector| selector.read(cx).active_model_name(cx))
4838 .unwrap_or_else(|| SharedString::from("The model"))
4839 } else {
4840 // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
4841 self.agent.name()
4842 }
4843 }
4844
4845 fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
4846 let model_or_agent_name = self.get_current_model_name(cx);
4847 let refusal_message = format!(
4848 "{} 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.",
4849 model_or_agent_name
4850 );
4851
4852 Callout::new()
4853 .severity(Severity::Error)
4854 .title("Request Refused")
4855 .icon(IconName::XCircle)
4856 .description(refusal_message.clone())
4857 .actions_slot(self.create_copy_button(&refusal_message))
4858 .dismiss_action(self.dismiss_error_button(cx))
4859 }
4860
4861 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
4862 let can_resume = self
4863 .thread()
4864 .map_or(false, |thread| thread.read(cx).can_resume(cx));
4865
4866 let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| {
4867 let thread = thread.read(cx);
4868 let supports_burn_mode = thread
4869 .model()
4870 .map_or(false, |model| model.supports_burn_mode());
4871 supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
4872 });
4873
4874 Callout::new()
4875 .severity(Severity::Error)
4876 .title("Error")
4877 .icon(IconName::XCircle)
4878 .description(error.clone())
4879 .actions_slot(
4880 h_flex()
4881 .gap_0p5()
4882 .when(can_resume && can_enable_burn_mode, |this| {
4883 this.child(
4884 Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry")
4885 .icon(IconName::ZedBurnMode)
4886 .icon_position(IconPosition::Start)
4887 .icon_size(IconSize::Small)
4888 .label_size(LabelSize::Small)
4889 .on_click(cx.listener(|this, _, window, cx| {
4890 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
4891 this.resume_chat(cx);
4892 })),
4893 )
4894 })
4895 .when(can_resume, |this| {
4896 this.child(
4897 Button::new("retry", "Retry")
4898 .icon(IconName::RotateCw)
4899 .icon_position(IconPosition::Start)
4900 .icon_size(IconSize::Small)
4901 .label_size(LabelSize::Small)
4902 .on_click(cx.listener(|this, _, _window, cx| {
4903 this.resume_chat(cx);
4904 })),
4905 )
4906 })
4907 .child(self.create_copy_button(error.to_string())),
4908 )
4909 .dismiss_action(self.dismiss_error_button(cx))
4910 }
4911
4912 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
4913 const ERROR_MESSAGE: &str =
4914 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
4915
4916 Callout::new()
4917 .severity(Severity::Error)
4918 .icon(IconName::XCircle)
4919 .title("Free Usage Exceeded")
4920 .description(ERROR_MESSAGE)
4921 .actions_slot(
4922 h_flex()
4923 .gap_0p5()
4924 .child(self.upgrade_button(cx))
4925 .child(self.create_copy_button(ERROR_MESSAGE)),
4926 )
4927 .dismiss_action(self.dismiss_error_button(cx))
4928 }
4929
4930 fn render_authentication_required_error(
4931 &self,
4932 error: SharedString,
4933 cx: &mut Context<Self>,
4934 ) -> Callout {
4935 Callout::new()
4936 .severity(Severity::Error)
4937 .title("Authentication Required")
4938 .icon(IconName::XCircle)
4939 .description(error.clone())
4940 .actions_slot(
4941 h_flex()
4942 .gap_0p5()
4943 .child(self.authenticate_button(cx))
4944 .child(self.create_copy_button(error)),
4945 )
4946 .dismiss_action(self.dismiss_error_button(cx))
4947 }
4948
4949 fn render_model_request_limit_reached_error(
4950 &self,
4951 plan: cloud_llm_client::Plan,
4952 cx: &mut Context<Self>,
4953 ) -> Callout {
4954 let error_message = match plan {
4955 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
4956 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
4957 "Upgrade to Zed Pro for more prompts."
4958 }
4959 };
4960
4961 Callout::new()
4962 .severity(Severity::Error)
4963 .title("Model Prompt Limit Reached")
4964 .icon(IconName::XCircle)
4965 .description(error_message)
4966 .actions_slot(
4967 h_flex()
4968 .gap_0p5()
4969 .child(self.upgrade_button(cx))
4970 .child(self.create_copy_button(error_message)),
4971 )
4972 .dismiss_action(self.dismiss_error_button(cx))
4973 }
4974
4975 fn render_tool_use_limit_reached_error(
4976 &self,
4977 window: &mut Window,
4978 cx: &mut Context<Self>,
4979 ) -> Option<Callout> {
4980 let thread = self.as_native_thread(cx)?;
4981 let supports_burn_mode = thread
4982 .read(cx)
4983 .model()
4984 .is_some_and(|model| model.supports_burn_mode());
4985
4986 let focus_handle = self.focus_handle(cx);
4987
4988 Some(
4989 Callout::new()
4990 .icon(IconName::Info)
4991 .title("Consecutive tool use limit reached.")
4992 .actions_slot(
4993 h_flex()
4994 .gap_0p5()
4995 .when(supports_burn_mode, |this| {
4996 this.child(
4997 Button::new("continue-burn-mode", "Continue with Burn Mode")
4998 .style(ButtonStyle::Filled)
4999 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
5000 .layer(ElevationIndex::ModalSurface)
5001 .label_size(LabelSize::Small)
5002 .key_binding(
5003 KeyBinding::for_action_in(
5004 &ContinueWithBurnMode,
5005 &focus_handle,
5006 window,
5007 cx,
5008 )
5009 .map(|kb| kb.size(rems_from_px(10.))),
5010 )
5011 .tooltip(Tooltip::text(
5012 "Enable Burn Mode for unlimited tool use.",
5013 ))
5014 .on_click({
5015 cx.listener(move |this, _, _window, cx| {
5016 thread.update(cx, |thread, cx| {
5017 thread
5018 .set_completion_mode(CompletionMode::Burn, cx);
5019 });
5020 this.resume_chat(cx);
5021 })
5022 }),
5023 )
5024 })
5025 .child(
5026 Button::new("continue-conversation", "Continue")
5027 .layer(ElevationIndex::ModalSurface)
5028 .label_size(LabelSize::Small)
5029 .key_binding(
5030 KeyBinding::for_action_in(
5031 &ContinueThread,
5032 &focus_handle,
5033 window,
5034 cx,
5035 )
5036 .map(|kb| kb.size(rems_from_px(10.))),
5037 )
5038 .on_click(cx.listener(|this, _, _window, cx| {
5039 this.resume_chat(cx);
5040 })),
5041 ),
5042 ),
5043 )
5044 }
5045
5046 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
5047 let message = message.into();
5048
5049 IconButton::new("copy", IconName::Copy)
5050 .icon_size(IconSize::Small)
5051 .icon_color(Color::Muted)
5052 .tooltip(Tooltip::text("Copy Error Message"))
5053 .on_click(move |_, _, cx| {
5054 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
5055 })
5056 }
5057
5058 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5059 IconButton::new("dismiss", IconName::Close)
5060 .icon_size(IconSize::Small)
5061 .icon_color(Color::Muted)
5062 .tooltip(Tooltip::text("Dismiss Error"))
5063 .on_click(cx.listener({
5064 move |this, _, _, cx| {
5065 this.clear_thread_error(cx);
5066 cx.notify();
5067 }
5068 }))
5069 }
5070
5071 fn authenticate_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5072 Button::new("authenticate", "Authenticate")
5073 .label_size(LabelSize::Small)
5074 .style(ButtonStyle::Filled)
5075 .on_click(cx.listener({
5076 move |this, _, window, cx| {
5077 let agent = this.agent.clone();
5078 let ThreadState::Ready { thread, .. } = &this.thread_state else {
5079 return;
5080 };
5081
5082 let connection = thread.read(cx).connection().clone();
5083 let err = AuthRequired {
5084 description: None,
5085 provider_id: None,
5086 };
5087 this.clear_thread_error(cx);
5088 let this = cx.weak_entity();
5089 window.defer(cx, |window, cx| {
5090 Self::handle_auth_required(this, err, agent, connection, window, cx);
5091 })
5092 }
5093 }))
5094 }
5095
5096 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5097 let agent = self.agent.clone();
5098 let ThreadState::Ready { thread, .. } = &self.thread_state else {
5099 return;
5100 };
5101
5102 let connection = thread.read(cx).connection().clone();
5103 let err = AuthRequired {
5104 description: None,
5105 provider_id: None,
5106 };
5107 self.clear_thread_error(cx);
5108 let this = cx.weak_entity();
5109 window.defer(cx, |window, cx| {
5110 Self::handle_auth_required(this, err, agent, connection, window, cx);
5111 })
5112 }
5113
5114 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5115 Button::new("upgrade", "Upgrade")
5116 .label_size(LabelSize::Small)
5117 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
5118 .on_click(cx.listener({
5119 move |this, _, _, cx| {
5120 this.clear_thread_error(cx);
5121 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
5122 }
5123 }))
5124 }
5125
5126 pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
5127 let task = match entry {
5128 HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
5129 history.delete_thread(thread.id.clone(), cx)
5130 }),
5131 HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
5132 history.delete_text_thread(context.path.clone(), cx)
5133 }),
5134 };
5135 task.detach_and_log_err(cx);
5136 }
5137}
5138
5139fn loading_contents_spinner(size: IconSize) -> AnyElement {
5140 Icon::new(IconName::LoadCircle)
5141 .size(size)
5142 .color(Color::Accent)
5143 .with_animation(
5144 "load_context_circle",
5145 Animation::new(Duration::from_secs(3)).repeat(),
5146 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
5147 )
5148 .into_any_element()
5149}
5150
5151impl Focusable for AcpThreadView {
5152 fn focus_handle(&self, cx: &App) -> FocusHandle {
5153 match self.thread_state {
5154 ThreadState::Loading { .. } | ThreadState::Ready { .. } => {
5155 self.message_editor.focus_handle(cx)
5156 }
5157 ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => {
5158 self.focus_handle.clone()
5159 }
5160 }
5161 }
5162}
5163
5164impl Render for AcpThreadView {
5165 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5166 let has_messages = self.list_state.item_count() > 0;
5167 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
5168
5169 v_flex()
5170 .size_full()
5171 .key_context("AcpThread")
5172 .on_action(cx.listener(Self::open_agent_diff))
5173 .on_action(cx.listener(Self::toggle_burn_mode))
5174 .on_action(cx.listener(Self::keep_all))
5175 .on_action(cx.listener(Self::reject_all))
5176 .track_focus(&self.focus_handle)
5177 .bg(cx.theme().colors().panel_background)
5178 .child(match &self.thread_state {
5179 ThreadState::Unauthenticated {
5180 connection,
5181 description,
5182 configuration_view,
5183 pending_auth_method,
5184 ..
5185 } => self.render_auth_required_state(
5186 connection,
5187 description.as_ref(),
5188 configuration_view.as_ref(),
5189 pending_auth_method.as_ref(),
5190 window,
5191 cx,
5192 ),
5193 ThreadState::Loading { .. } => v_flex()
5194 .flex_1()
5195 .child(self.render_recent_history(window, cx)),
5196 ThreadState::LoadError(e) => v_flex()
5197 .flex_1()
5198 .size_full()
5199 .items_center()
5200 .justify_end()
5201 .child(self.render_load_error(e, window, cx)),
5202 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
5203 if has_messages {
5204 this.child(
5205 list(
5206 self.list_state.clone(),
5207 cx.processor(|this, index: usize, window, cx| {
5208 let Some((entry, len)) = this.thread().and_then(|thread| {
5209 let entries = &thread.read(cx).entries();
5210 Some((entries.get(index)?, entries.len()))
5211 }) else {
5212 return Empty.into_any();
5213 };
5214 this.render_entry(index, len, entry, window, cx)
5215 }),
5216 )
5217 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
5218 .flex_grow()
5219 .into_any(),
5220 )
5221 .child(self.render_vertical_scrollbar(cx))
5222 } else {
5223 this.child(self.render_recent_history(window, cx))
5224 }
5225 }),
5226 })
5227 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
5228 // above so that the scrollbar doesn't render behind it. The current setup allows
5229 // the scrollbar to stop exactly at the activity bar start.
5230 .when(has_messages, |this| match &self.thread_state {
5231 ThreadState::Ready { thread, .. } => {
5232 this.children(self.render_activity_bar(thread, window, cx))
5233 }
5234 _ => this,
5235 })
5236 .children(self.render_thread_retry_status_callout(window, cx))
5237 .children(self.render_thread_error(window, cx))
5238 .children(
5239 if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
5240 Some(usage_callout.into_any_element())
5241 } else {
5242 self.render_token_limit_callout(line_height, cx)
5243 .map(|token_limit_callout| token_limit_callout.into_any_element())
5244 },
5245 )
5246 .child(self.render_message_editor(window, cx))
5247 }
5248}
5249
5250fn default_markdown_style(
5251 buffer_font: bool,
5252 muted_text: bool,
5253 window: &Window,
5254 cx: &App,
5255) -> MarkdownStyle {
5256 let theme_settings = ThemeSettings::get_global(cx);
5257 let colors = cx.theme().colors();
5258
5259 let buffer_font_size = TextSize::Small.rems(cx);
5260
5261 let mut text_style = window.text_style();
5262 let line_height = buffer_font_size * 1.75;
5263
5264 let font_family = if buffer_font {
5265 theme_settings.buffer_font.family.clone()
5266 } else {
5267 theme_settings.ui_font.family.clone()
5268 };
5269
5270 let font_size = if buffer_font {
5271 TextSize::Small.rems(cx)
5272 } else {
5273 TextSize::Default.rems(cx)
5274 };
5275
5276 let text_color = if muted_text {
5277 colors.text_muted
5278 } else {
5279 colors.text
5280 };
5281
5282 text_style.refine(&TextStyleRefinement {
5283 font_family: Some(font_family),
5284 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
5285 font_features: Some(theme_settings.ui_font.features.clone()),
5286 font_size: Some(font_size.into()),
5287 line_height: Some(line_height.into()),
5288 color: Some(text_color),
5289 ..Default::default()
5290 });
5291
5292 MarkdownStyle {
5293 base_text_style: text_style.clone(),
5294 syntax: cx.theme().syntax().clone(),
5295 selection_background_color: colors.element_selection_background,
5296 code_block_overflow_x_scroll: true,
5297 table_overflow_x_scroll: true,
5298 heading_level_styles: Some(HeadingLevelStyles {
5299 h1: Some(TextStyleRefinement {
5300 font_size: Some(rems(1.15).into()),
5301 ..Default::default()
5302 }),
5303 h2: Some(TextStyleRefinement {
5304 font_size: Some(rems(1.1).into()),
5305 ..Default::default()
5306 }),
5307 h3: Some(TextStyleRefinement {
5308 font_size: Some(rems(1.05).into()),
5309 ..Default::default()
5310 }),
5311 h4: Some(TextStyleRefinement {
5312 font_size: Some(rems(1.).into()),
5313 ..Default::default()
5314 }),
5315 h5: Some(TextStyleRefinement {
5316 font_size: Some(rems(0.95).into()),
5317 ..Default::default()
5318 }),
5319 h6: Some(TextStyleRefinement {
5320 font_size: Some(rems(0.875).into()),
5321 ..Default::default()
5322 }),
5323 }),
5324 code_block: StyleRefinement {
5325 padding: EdgesRefinement {
5326 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5327 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5328 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5329 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
5330 },
5331 margin: EdgesRefinement {
5332 top: Some(Length::Definite(Pixels(8.).into())),
5333 left: Some(Length::Definite(Pixels(0.).into())),
5334 right: Some(Length::Definite(Pixels(0.).into())),
5335 bottom: Some(Length::Definite(Pixels(12.).into())),
5336 },
5337 border_style: Some(BorderStyle::Solid),
5338 border_widths: EdgesRefinement {
5339 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
5340 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
5341 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
5342 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
5343 },
5344 border_color: Some(colors.border_variant),
5345 background: Some(colors.editor_background.into()),
5346 text: Some(TextStyleRefinement {
5347 font_family: Some(theme_settings.buffer_font.family.clone()),
5348 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5349 font_features: Some(theme_settings.buffer_font.features.clone()),
5350 font_size: Some(buffer_font_size.into()),
5351 ..Default::default()
5352 }),
5353 ..Default::default()
5354 },
5355 inline_code: TextStyleRefinement {
5356 font_family: Some(theme_settings.buffer_font.family.clone()),
5357 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
5358 font_features: Some(theme_settings.buffer_font.features.clone()),
5359 font_size: Some(buffer_font_size.into()),
5360 background_color: Some(colors.editor_foreground.opacity(0.08)),
5361 ..Default::default()
5362 },
5363 link: TextStyleRefinement {
5364 background_color: Some(colors.editor_foreground.opacity(0.025)),
5365 underline: Some(UnderlineStyle {
5366 color: Some(colors.text_accent.opacity(0.5)),
5367 thickness: px(1.),
5368 ..Default::default()
5369 }),
5370 ..Default::default()
5371 },
5372 ..Default::default()
5373 }
5374}
5375
5376fn plan_label_markdown_style(
5377 status: &acp::PlanEntryStatus,
5378 window: &Window,
5379 cx: &App,
5380) -> MarkdownStyle {
5381 let default_md_style = default_markdown_style(false, false, window, cx);
5382
5383 MarkdownStyle {
5384 base_text_style: TextStyle {
5385 color: cx.theme().colors().text_muted,
5386 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
5387 Some(gpui::StrikethroughStyle {
5388 thickness: px(1.),
5389 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
5390 })
5391 } else {
5392 None
5393 },
5394 ..default_md_style.base_text_style
5395 },
5396 ..default_md_style
5397 }
5398}
5399
5400fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
5401 let default_md_style = default_markdown_style(true, false, window, cx);
5402
5403 MarkdownStyle {
5404 base_text_style: TextStyle {
5405 ..default_md_style.base_text_style
5406 },
5407 selection_background_color: cx.theme().colors().element_selection_background,
5408 ..Default::default()
5409 }
5410}
5411
5412#[cfg(test)]
5413pub(crate) mod tests {
5414 use acp_thread::StubAgentConnection;
5415 use agent_client_protocol::SessionId;
5416 use assistant_context::ContextStore;
5417 use editor::EditorSettings;
5418 use fs::FakeFs;
5419 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
5420 use project::Project;
5421 use serde_json::json;
5422 use settings::SettingsStore;
5423 use std::any::Any;
5424 use std::path::Path;
5425 use workspace::Item;
5426
5427 use super::*;
5428
5429 #[gpui::test]
5430 async fn test_drop(cx: &mut TestAppContext) {
5431 init_test(cx);
5432
5433 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5434 let weak_view = thread_view.downgrade();
5435 drop(thread_view);
5436 assert!(!weak_view.is_upgradable());
5437 }
5438
5439 #[gpui::test]
5440 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
5441 init_test(cx);
5442
5443 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
5444
5445 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5446 message_editor.update_in(cx, |editor, window, cx| {
5447 editor.set_text("Hello", window, cx);
5448 });
5449
5450 cx.deactivate_window();
5451
5452 thread_view.update_in(cx, |thread_view, window, cx| {
5453 thread_view.send(window, cx);
5454 });
5455
5456 cx.run_until_parked();
5457
5458 assert!(
5459 cx.windows()
5460 .iter()
5461 .any(|window| window.downcast::<AgentNotification>().is_some())
5462 );
5463 }
5464
5465 #[gpui::test]
5466 async fn test_notification_for_error(cx: &mut TestAppContext) {
5467 init_test(cx);
5468
5469 let (thread_view, cx) =
5470 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
5471
5472 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5473 message_editor.update_in(cx, |editor, window, cx| {
5474 editor.set_text("Hello", window, cx);
5475 });
5476
5477 cx.deactivate_window();
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 assert!(
5486 cx.windows()
5487 .iter()
5488 .any(|window| window.downcast::<AgentNotification>().is_some())
5489 );
5490 }
5491
5492 #[gpui::test]
5493 async fn test_refusal_handling(cx: &mut TestAppContext) {
5494 init_test(cx);
5495
5496 let (thread_view, cx) =
5497 setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
5498
5499 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5500 message_editor.update_in(cx, |editor, window, cx| {
5501 editor.set_text("Do something harmful", window, cx);
5502 });
5503
5504 thread_view.update_in(cx, |thread_view, window, cx| {
5505 thread_view.send(window, cx);
5506 });
5507
5508 cx.run_until_parked();
5509
5510 // Check that the refusal error is set
5511 thread_view.read_with(cx, |thread_view, _cx| {
5512 assert!(
5513 matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
5514 "Expected refusal error to be set"
5515 );
5516 });
5517 }
5518
5519 #[gpui::test]
5520 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
5521 init_test(cx);
5522
5523 let tool_call_id = acp::ToolCallId("1".into());
5524 let tool_call = acp::ToolCall {
5525 id: tool_call_id.clone(),
5526 title: "Label".into(),
5527 kind: acp::ToolKind::Edit,
5528 status: acp::ToolCallStatus::Pending,
5529 content: vec!["hi".into()],
5530 locations: vec![],
5531 raw_input: None,
5532 raw_output: None,
5533 };
5534 let connection =
5535 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5536 tool_call_id,
5537 vec![acp::PermissionOption {
5538 id: acp::PermissionOptionId("1".into()),
5539 name: "Allow".into(),
5540 kind: acp::PermissionOptionKind::AllowOnce,
5541 }],
5542 )]));
5543
5544 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5545
5546 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5547
5548 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
5549 message_editor.update_in(cx, |editor, window, cx| {
5550 editor.set_text("Hello", window, cx);
5551 });
5552
5553 cx.deactivate_window();
5554
5555 thread_view.update_in(cx, |thread_view, window, cx| {
5556 thread_view.send(window, cx);
5557 });
5558
5559 cx.run_until_parked();
5560
5561 assert!(
5562 cx.windows()
5563 .iter()
5564 .any(|window| window.downcast::<AgentNotification>().is_some())
5565 );
5566 }
5567
5568 async fn setup_thread_view(
5569 agent: impl AgentServer + 'static,
5570 cx: &mut TestAppContext,
5571 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
5572 let fs = FakeFs::new(cx.executor());
5573 let project = Project::test(fs, [], cx).await;
5574 let (workspace, cx) =
5575 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5576
5577 let context_store =
5578 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5579 let history_store =
5580 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5581
5582 let thread_view = cx.update(|window, cx| {
5583 cx.new(|cx| {
5584 AcpThreadView::new(
5585 Rc::new(agent),
5586 None,
5587 None,
5588 workspace.downgrade(),
5589 project,
5590 history_store,
5591 None,
5592 window,
5593 cx,
5594 )
5595 })
5596 });
5597 cx.run_until_parked();
5598 (thread_view, cx)
5599 }
5600
5601 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
5602 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
5603
5604 workspace
5605 .update_in(cx, |workspace, window, cx| {
5606 workspace.add_item_to_active_pane(
5607 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
5608 None,
5609 true,
5610 window,
5611 cx,
5612 );
5613 })
5614 .unwrap();
5615 }
5616
5617 struct ThreadViewItem(Entity<AcpThreadView>);
5618
5619 impl Item for ThreadViewItem {
5620 type Event = ();
5621
5622 fn include_in_nav_history() -> bool {
5623 false
5624 }
5625
5626 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
5627 "Test".into()
5628 }
5629 }
5630
5631 impl EventEmitter<()> for ThreadViewItem {}
5632
5633 impl Focusable for ThreadViewItem {
5634 fn focus_handle(&self, cx: &App) -> FocusHandle {
5635 self.0.read(cx).focus_handle(cx)
5636 }
5637 }
5638
5639 impl Render for ThreadViewItem {
5640 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5641 self.0.clone().into_any_element()
5642 }
5643 }
5644
5645 struct StubAgentServer<C> {
5646 connection: C,
5647 }
5648
5649 impl<C> StubAgentServer<C> {
5650 fn new(connection: C) -> Self {
5651 Self { connection }
5652 }
5653 }
5654
5655 impl StubAgentServer<StubAgentConnection> {
5656 fn default_response() -> Self {
5657 let conn = StubAgentConnection::new();
5658 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
5659 content: "Default response".into(),
5660 }]);
5661 Self::new(conn)
5662 }
5663 }
5664
5665 impl<C> AgentServer for StubAgentServer<C>
5666 where
5667 C: 'static + AgentConnection + Send + Clone,
5668 {
5669 fn telemetry_id(&self) -> &'static str {
5670 "test"
5671 }
5672
5673 fn logo(&self) -> ui::IconName {
5674 ui::IconName::Ai
5675 }
5676
5677 fn name(&self) -> SharedString {
5678 "Test".into()
5679 }
5680
5681 fn connect(
5682 &self,
5683 _root_dir: &Path,
5684 _delegate: AgentServerDelegate,
5685 _cx: &mut App,
5686 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5687 Task::ready(Ok(Rc::new(self.connection.clone())))
5688 }
5689
5690 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5691 self
5692 }
5693 }
5694
5695 #[derive(Clone)]
5696 struct SaboteurAgentConnection;
5697
5698 impl AgentConnection for SaboteurAgentConnection {
5699 fn new_thread(
5700 self: Rc<Self>,
5701 project: Entity<Project>,
5702 _cwd: &Path,
5703 cx: &mut gpui::App,
5704 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5705 Task::ready(Ok(cx.new(|cx| {
5706 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5707 AcpThread::new(
5708 "SaboteurAgentConnection",
5709 self,
5710 project,
5711 action_log,
5712 SessionId("test".into()),
5713 watch::Receiver::constant(acp::PromptCapabilities {
5714 image: true,
5715 audio: true,
5716 embedded_context: true,
5717 }),
5718 vec![],
5719 cx,
5720 )
5721 })))
5722 }
5723
5724 fn auth_methods(&self) -> &[acp::AuthMethod] {
5725 &[]
5726 }
5727
5728 fn authenticate(
5729 &self,
5730 _method_id: acp::AuthMethodId,
5731 _cx: &mut App,
5732 ) -> Task<gpui::Result<()>> {
5733 unimplemented!()
5734 }
5735
5736 fn prompt(
5737 &self,
5738 _id: Option<acp_thread::UserMessageId>,
5739 _params: acp::PromptRequest,
5740 _cx: &mut App,
5741 ) -> Task<gpui::Result<acp::PromptResponse>> {
5742 Task::ready(Err(anyhow::anyhow!("Error prompting")))
5743 }
5744
5745 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5746 unimplemented!()
5747 }
5748
5749 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5750 self
5751 }
5752 }
5753
5754 /// Simulates a model which always returns a refusal response
5755 #[derive(Clone)]
5756 struct RefusalAgentConnection;
5757
5758 impl AgentConnection for RefusalAgentConnection {
5759 fn new_thread(
5760 self: Rc<Self>,
5761 project: Entity<Project>,
5762 _cwd: &Path,
5763 cx: &mut gpui::App,
5764 ) -> Task<gpui::Result<Entity<AcpThread>>> {
5765 Task::ready(Ok(cx.new(|cx| {
5766 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5767 AcpThread::new(
5768 "RefusalAgentConnection",
5769 self,
5770 project,
5771 action_log,
5772 SessionId("test".into()),
5773 watch::Receiver::constant(acp::PromptCapabilities {
5774 image: true,
5775 audio: true,
5776 embedded_context: true,
5777 }),
5778 Vec::new(),
5779 cx,
5780 )
5781 })))
5782 }
5783
5784 fn auth_methods(&self) -> &[acp::AuthMethod] {
5785 &[]
5786 }
5787
5788 fn authenticate(
5789 &self,
5790 _method_id: acp::AuthMethodId,
5791 _cx: &mut App,
5792 ) -> Task<gpui::Result<()>> {
5793 unimplemented!()
5794 }
5795
5796 fn prompt(
5797 &self,
5798 _id: Option<acp_thread::UserMessageId>,
5799 _params: acp::PromptRequest,
5800 _cx: &mut App,
5801 ) -> Task<gpui::Result<acp::PromptResponse>> {
5802 Task::ready(Ok(acp::PromptResponse {
5803 stop_reason: acp::StopReason::Refusal,
5804 }))
5805 }
5806
5807 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
5808 unimplemented!()
5809 }
5810
5811 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5812 self
5813 }
5814 }
5815
5816 pub(crate) fn init_test(cx: &mut TestAppContext) {
5817 cx.update(|cx| {
5818 let settings_store = SettingsStore::test(cx);
5819 cx.set_global(settings_store);
5820 language::init(cx);
5821 Project::init_settings(cx);
5822 AgentSettings::register(cx);
5823 workspace::init_settings(cx);
5824 ThemeSettings::register(cx);
5825 release_channel::init(SemanticVersion::default(), cx);
5826 EditorSettings::register(cx);
5827 prompt_store::init(cx)
5828 });
5829 }
5830
5831 #[gpui::test]
5832 async fn test_rewind_views(cx: &mut TestAppContext) {
5833 init_test(cx);
5834
5835 let fs = FakeFs::new(cx.executor());
5836 fs.insert_tree(
5837 "/project",
5838 json!({
5839 "test1.txt": "old content 1",
5840 "test2.txt": "old content 2"
5841 }),
5842 )
5843 .await;
5844 let project = Project::test(fs, [Path::new("/project")], cx).await;
5845 let (workspace, cx) =
5846 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
5847
5848 let context_store =
5849 cx.update(|_window, cx| cx.new(|cx| ContextStore::fake(project.clone(), cx)));
5850 let history_store =
5851 cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(context_store, cx)));
5852
5853 let connection = Rc::new(StubAgentConnection::new());
5854 let thread_view = cx.update(|window, cx| {
5855 cx.new(|cx| {
5856 AcpThreadView::new(
5857 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
5858 None,
5859 None,
5860 workspace.downgrade(),
5861 project.clone(),
5862 history_store.clone(),
5863 None,
5864 window,
5865 cx,
5866 )
5867 })
5868 });
5869
5870 cx.run_until_parked();
5871
5872 let thread = thread_view
5873 .read_with(cx, |view, _| view.thread().cloned())
5874 .unwrap();
5875
5876 // First user message
5877 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5878 id: acp::ToolCallId("tool1".into()),
5879 title: "Edit file 1".into(),
5880 kind: acp::ToolKind::Edit,
5881 status: acp::ToolCallStatus::Completed,
5882 content: vec![acp::ToolCallContent::Diff {
5883 diff: acp::Diff {
5884 path: "/project/test1.txt".into(),
5885 old_text: Some("old content 1".into()),
5886 new_text: "new content 1".into(),
5887 },
5888 }],
5889 locations: vec![],
5890 raw_input: None,
5891 raw_output: None,
5892 })]);
5893
5894 thread
5895 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
5896 .await
5897 .unwrap();
5898 cx.run_until_parked();
5899
5900 thread.read_with(cx, |thread, _| {
5901 assert_eq!(thread.entries().len(), 2);
5902 });
5903
5904 thread_view.read_with(cx, |view, cx| {
5905 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5906 assert!(
5907 entry_view_state
5908 .entry(0)
5909 .unwrap()
5910 .message_editor()
5911 .is_some()
5912 );
5913 assert!(entry_view_state.entry(1).unwrap().has_content());
5914 });
5915 });
5916
5917 // Second user message
5918 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
5919 id: acp::ToolCallId("tool2".into()),
5920 title: "Edit file 2".into(),
5921 kind: acp::ToolKind::Edit,
5922 status: acp::ToolCallStatus::Completed,
5923 content: vec![acp::ToolCallContent::Diff {
5924 diff: acp::Diff {
5925 path: "/project/test2.txt".into(),
5926 old_text: Some("old content 2".into()),
5927 new_text: "new content 2".into(),
5928 },
5929 }],
5930 locations: vec![],
5931 raw_input: None,
5932 raw_output: None,
5933 })]);
5934
5935 thread
5936 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5937 .await
5938 .unwrap();
5939 cx.run_until_parked();
5940
5941 let second_user_message_id = thread.read_with(cx, |thread, _| {
5942 assert_eq!(thread.entries().len(), 4);
5943 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5944 panic!();
5945 };
5946 user_message.id.clone().unwrap()
5947 });
5948
5949 thread_view.read_with(cx, |view, cx| {
5950 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5951 assert!(
5952 entry_view_state
5953 .entry(0)
5954 .unwrap()
5955 .message_editor()
5956 .is_some()
5957 );
5958 assert!(entry_view_state.entry(1).unwrap().has_content());
5959 assert!(
5960 entry_view_state
5961 .entry(2)
5962 .unwrap()
5963 .message_editor()
5964 .is_some()
5965 );
5966 assert!(entry_view_state.entry(3).unwrap().has_content());
5967 });
5968 });
5969
5970 // Rewind to first message
5971 thread
5972 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5973 .await
5974 .unwrap();
5975
5976 cx.run_until_parked();
5977
5978 thread.read_with(cx, |thread, _| {
5979 assert_eq!(thread.entries().len(), 2);
5980 });
5981
5982 thread_view.read_with(cx, |view, cx| {
5983 view.entry_view_state.read_with(cx, |entry_view_state, _| {
5984 assert!(
5985 entry_view_state
5986 .entry(0)
5987 .unwrap()
5988 .message_editor()
5989 .is_some()
5990 );
5991 assert!(entry_view_state.entry(1).unwrap().has_content());
5992
5993 // Old views should be dropped
5994 assert!(entry_view_state.entry(2).is_none());
5995 assert!(entry_view_state.entry(3).is_none());
5996 });
5997 });
5998 }
5999
6000 #[gpui::test]
6001 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
6002 init_test(cx);
6003
6004 let connection = StubAgentConnection::new();
6005
6006 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
6007 content: acp::ContentBlock::Text(acp::TextContent {
6008 text: "Response".into(),
6009 annotations: None,
6010 }),
6011 }]);
6012
6013 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
6014 add_to_workspace(thread_view.clone(), cx);
6015
6016 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6017 message_editor.update_in(cx, |editor, window, cx| {
6018 editor.set_text("Original message to edit", window, cx);
6019 });
6020 thread_view.update_in(cx, |thread_view, window, cx| {
6021 thread_view.send(window, cx);
6022 });
6023
6024 cx.run_until_parked();
6025
6026 let user_message_editor = thread_view.read_with(cx, |view, cx| {
6027 assert_eq!(view.editing_message, None);
6028
6029 view.entry_view_state
6030 .read(cx)
6031 .entry(0)
6032 .unwrap()
6033 .message_editor()
6034 .unwrap()
6035 .clone()
6036 });
6037
6038 // Focus
6039 cx.focus(&user_message_editor);
6040 thread_view.read_with(cx, |view, _cx| {
6041 assert_eq!(view.editing_message, Some(0));
6042 });
6043
6044 // Edit
6045 user_message_editor.update_in(cx, |editor, window, cx| {
6046 editor.set_text("Edited message content", window, cx);
6047 });
6048
6049 // Cancel
6050 user_message_editor.update_in(cx, |_editor, window, cx| {
6051 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
6052 });
6053
6054 thread_view.read_with(cx, |view, _cx| {
6055 assert_eq!(view.editing_message, None);
6056 });
6057
6058 user_message_editor.read_with(cx, |editor, cx| {
6059 assert_eq!(editor.text(cx), "Original message to edit");
6060 });
6061 }
6062
6063 #[gpui::test]
6064 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
6065 init_test(cx);
6066
6067 let connection = StubAgentConnection::new();
6068
6069 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
6070 add_to_workspace(thread_view.clone(), cx);
6071
6072 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6073 let mut events = cx.events(&message_editor);
6074 message_editor.update_in(cx, |editor, window, cx| {
6075 editor.set_text("", window, cx);
6076 });
6077
6078 message_editor.update_in(cx, |_editor, window, cx| {
6079 window.dispatch_action(Box::new(Chat), cx);
6080 });
6081 cx.run_until_parked();
6082 // We shouldn't have received any messages
6083 assert!(matches!(
6084 events.try_next(),
6085 Err(futures::channel::mpsc::TryRecvError { .. })
6086 ));
6087 }
6088
6089 #[gpui::test]
6090 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
6091 init_test(cx);
6092
6093 let connection = StubAgentConnection::new();
6094
6095 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
6096 content: acp::ContentBlock::Text(acp::TextContent {
6097 text: "Response".into(),
6098 annotations: None,
6099 }),
6100 }]);
6101
6102 let (thread_view, cx) =
6103 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6104 add_to_workspace(thread_view.clone(), cx);
6105
6106 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6107 message_editor.update_in(cx, |editor, window, cx| {
6108 editor.set_text("Original message to edit", window, cx);
6109 });
6110 thread_view.update_in(cx, |thread_view, window, cx| {
6111 thread_view.send(window, cx);
6112 });
6113
6114 cx.run_until_parked();
6115
6116 let user_message_editor = thread_view.read_with(cx, |view, cx| {
6117 assert_eq!(view.editing_message, None);
6118 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
6119
6120 view.entry_view_state
6121 .read(cx)
6122 .entry(0)
6123 .unwrap()
6124 .message_editor()
6125 .unwrap()
6126 .clone()
6127 });
6128
6129 // Focus
6130 cx.focus(&user_message_editor);
6131
6132 // Edit
6133 user_message_editor.update_in(cx, |editor, window, cx| {
6134 editor.set_text("Edited message content", window, cx);
6135 });
6136
6137 // Send
6138 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
6139 content: acp::ContentBlock::Text(acp::TextContent {
6140 text: "New Response".into(),
6141 annotations: None,
6142 }),
6143 }]);
6144
6145 user_message_editor.update_in(cx, |_editor, window, cx| {
6146 window.dispatch_action(Box::new(Chat), cx);
6147 });
6148
6149 cx.run_until_parked();
6150
6151 thread_view.read_with(cx, |view, cx| {
6152 assert_eq!(view.editing_message, None);
6153
6154 let entries = view.thread().unwrap().read(cx).entries();
6155 assert_eq!(entries.len(), 2);
6156 assert_eq!(
6157 entries[0].to_markdown(cx),
6158 "## User\n\nEdited message content\n\n"
6159 );
6160 assert_eq!(
6161 entries[1].to_markdown(cx),
6162 "## Assistant\n\nNew Response\n\n"
6163 );
6164
6165 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
6166 assert!(!state.entry(1).unwrap().has_content());
6167 state.entry(0).unwrap().message_editor().unwrap().clone()
6168 });
6169
6170 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
6171 })
6172 }
6173
6174 #[gpui::test]
6175 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
6176 init_test(cx);
6177
6178 let connection = StubAgentConnection::new();
6179
6180 let (thread_view, cx) =
6181 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6182 add_to_workspace(thread_view.clone(), cx);
6183
6184 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6185 message_editor.update_in(cx, |editor, window, cx| {
6186 editor.set_text("Original message to edit", window, cx);
6187 });
6188 thread_view.update_in(cx, |thread_view, window, cx| {
6189 thread_view.send(window, cx);
6190 });
6191
6192 cx.run_until_parked();
6193
6194 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
6195 let thread = view.thread().unwrap().read(cx);
6196 assert_eq!(thread.entries().len(), 1);
6197
6198 let editor = view
6199 .entry_view_state
6200 .read(cx)
6201 .entry(0)
6202 .unwrap()
6203 .message_editor()
6204 .unwrap()
6205 .clone();
6206
6207 (editor, thread.session_id().clone())
6208 });
6209
6210 // Focus
6211 cx.focus(&user_message_editor);
6212
6213 thread_view.read_with(cx, |view, _cx| {
6214 assert_eq!(view.editing_message, Some(0));
6215 });
6216
6217 // Edit
6218 user_message_editor.update_in(cx, |editor, window, cx| {
6219 editor.set_text("Edited message content", window, cx);
6220 });
6221
6222 thread_view.read_with(cx, |view, _cx| {
6223 assert_eq!(view.editing_message, Some(0));
6224 });
6225
6226 // Finish streaming response
6227 cx.update(|_, cx| {
6228 connection.send_update(
6229 session_id.clone(),
6230 acp::SessionUpdate::AgentMessageChunk {
6231 content: acp::ContentBlock::Text(acp::TextContent {
6232 text: "Response".into(),
6233 annotations: None,
6234 }),
6235 },
6236 cx,
6237 );
6238 connection.end_turn(session_id, acp::StopReason::EndTurn);
6239 });
6240
6241 thread_view.read_with(cx, |view, _cx| {
6242 assert_eq!(view.editing_message, Some(0));
6243 });
6244
6245 cx.run_until_parked();
6246
6247 // Should still be editing
6248 cx.update(|window, cx| {
6249 assert!(user_message_editor.focus_handle(cx).is_focused(window));
6250 assert_eq!(thread_view.read(cx).editing_message, Some(0));
6251 assert_eq!(
6252 user_message_editor.read(cx).text(cx),
6253 "Edited message content"
6254 );
6255 });
6256 }
6257
6258 #[gpui::test]
6259 async fn test_interrupt(cx: &mut TestAppContext) {
6260 init_test(cx);
6261
6262 let connection = StubAgentConnection::new();
6263
6264 let (thread_view, cx) =
6265 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6266 add_to_workspace(thread_view.clone(), cx);
6267
6268 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
6269 message_editor.update_in(cx, |editor, window, cx| {
6270 editor.set_text("Message 1", window, cx);
6271 });
6272 thread_view.update_in(cx, |thread_view, window, cx| {
6273 thread_view.send(window, cx);
6274 });
6275
6276 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
6277 let thread = view.thread().unwrap();
6278
6279 (thread.clone(), thread.read(cx).session_id().clone())
6280 });
6281
6282 cx.run_until_parked();
6283
6284 cx.update(|_, cx| {
6285 connection.send_update(
6286 session_id.clone(),
6287 acp::SessionUpdate::AgentMessageChunk {
6288 content: "Message 1 resp".into(),
6289 },
6290 cx,
6291 );
6292 });
6293
6294 cx.run_until_parked();
6295
6296 thread.read_with(cx, |thread, cx| {
6297 assert_eq!(
6298 thread.to_markdown(cx),
6299 indoc::indoc! {"
6300 ## User
6301
6302 Message 1
6303
6304 ## Assistant
6305
6306 Message 1 resp
6307
6308 "}
6309 )
6310 });
6311
6312 message_editor.update_in(cx, |editor, window, cx| {
6313 editor.set_text("Message 2", window, cx);
6314 });
6315 thread_view.update_in(cx, |thread_view, window, cx| {
6316 thread_view.send(window, cx);
6317 });
6318
6319 cx.update(|_, cx| {
6320 // Simulate a response sent after beginning to cancel
6321 connection.send_update(
6322 session_id.clone(),
6323 acp::SessionUpdate::AgentMessageChunk {
6324 content: "onse".into(),
6325 },
6326 cx,
6327 );
6328 });
6329
6330 cx.run_until_parked();
6331
6332 // Last Message 1 response should appear before Message 2
6333 thread.read_with(cx, |thread, cx| {
6334 assert_eq!(
6335 thread.to_markdown(cx),
6336 indoc::indoc! {"
6337 ## User
6338
6339 Message 1
6340
6341 ## Assistant
6342
6343 Message 1 response
6344
6345 ## User
6346
6347 Message 2
6348
6349 "}
6350 )
6351 });
6352
6353 cx.update(|_, cx| {
6354 connection.send_update(
6355 session_id.clone(),
6356 acp::SessionUpdate::AgentMessageChunk {
6357 content: "Message 2 response".into(),
6358 },
6359 cx,
6360 );
6361 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
6362 });
6363
6364 cx.run_until_parked();
6365
6366 thread.read_with(cx, |thread, cx| {
6367 assert_eq!(
6368 thread.to_markdown(cx),
6369 indoc::indoc! {"
6370 ## User
6371
6372 Message 1
6373
6374 ## Assistant
6375
6376 Message 1 response
6377
6378 ## User
6379
6380 Message 2
6381
6382 ## Assistant
6383
6384 Message 2 response
6385
6386 "}
6387 )
6388 });
6389 }
6390}