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