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