1use acp_thread::{
2 AcpThread, AcpThreadEvent, AcpThreadMetadata, AgentThreadEntry, AssistantMessage,
3 AssistantMessageChunk, LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent,
4 ToolCallStatus, UserMessageId,
5};
6use acp_thread::{AgentConnection, Plan};
7use action_log::ActionLog;
8use agent::{TextThreadStore, ThreadStore};
9use agent_client_protocol::{self as acp};
10use agent_servers::AgentServer;
11use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
12use 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, EditorMode, MultiBuffer, PathKey, SelectionEffects};
19use file_icons::FileIcons;
20use fs::Fs;
21use futures::StreamExt;
22use gpui::{
23 Action, Animation, AnimationExt, App, BorderStyle, ClickEvent, ClipboardItem, EdgesRefinement,
24 Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton,
25 PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle,
26 TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
27 linear_color_stop, linear_gradient, list, percentage, point, prelude::*, pulsating_between,
28};
29use language::Buffer;
30use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
31use project::Project;
32use prompt_store::PromptId;
33use rope::Point;
34use settings::{Settings as _, SettingsStore};
35use std::sync::Arc;
36use std::{collections::BTreeMap, process::ExitStatus, rc::Rc, time::Duration};
37use text::Anchor;
38use theme::ThemeSettings;
39use ui::{
40 Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
41 Scrollbar, ScrollbarState, Tooltip, prelude::*,
42};
43use util::{ResultExt, size::format_file_size, time::duration_alt_display};
44use workspace::{CollaboratorId, Workspace};
45use zed_actions::agent::{Chat, ToggleModelSelector};
46use zed_actions::assistant::OpenRulesLibrary;
47
48use super::entry_view_state::EntryViewState;
49use crate::acp::AcpModelSelectorPopover;
50use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
51use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
52use crate::agent_diff::AgentDiff;
53use crate::profile_selector::{ProfileProvider, ProfileSelector};
54use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip};
55use crate::{
56 AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
57 KeepAll, OpenAgentDiff, RejectAll, ToggleBurnMode, ToggleProfileSelector,
58};
59
60const RESPONSE_PADDING_X: Pixels = px(19.);
61pub const MIN_EDITOR_LINES: usize = 4;
62pub const MAX_EDITOR_LINES: usize = 8;
63
64enum ThreadError {
65 PaymentRequired,
66 ModelRequestLimitReached(cloud_llm_client::Plan),
67 ToolUseLimitReached,
68 Other(SharedString),
69}
70
71impl ThreadError {
72 fn from_err(error: anyhow::Error) -> Self {
73 if error.is::<language_model::PaymentRequiredError>() {
74 Self::PaymentRequired
75 } else if error.is::<language_model::ToolUseLimitReachedError>() {
76 Self::ToolUseLimitReached
77 } else if let Some(error) =
78 error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
79 {
80 Self::ModelRequestLimitReached(error.plan)
81 } else {
82 Self::Other(error.to_string().into())
83 }
84 }
85}
86
87impl ProfileProvider for Entity<agent2::Thread> {
88 fn profile_id(&self, cx: &App) -> AgentProfileId {
89 self.read(cx).profile().clone()
90 }
91
92 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
93 self.update(cx, |thread, _cx| {
94 thread.set_profile(profile_id);
95 });
96 }
97
98 fn profiles_supported(&self, cx: &App) -> bool {
99 self.read(cx)
100 .model()
101 .map_or(false, |model| model.supports_tools())
102 }
103}
104
105pub struct AcpThreadView {
106 agent: Rc<dyn AgentServer>,
107 workspace: WeakEntity<Workspace>,
108 project: Entity<Project>,
109 thread_state: ThreadState,
110 entry_view_state: Entity<EntryViewState>,
111 message_editor: Entity<MessageEditor>,
112 model_selector: Option<Entity<AcpModelSelectorPopover>>,
113 profile_selector: Option<Entity<ProfileSelector>>,
114 notifications: Vec<WindowHandle<AgentNotification>>,
115 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
116 thread_error: Option<ThreadError>,
117 list_state: ListState,
118 scrollbar_state: ScrollbarState,
119 auth_task: Option<Task<()>>,
120 expanded_tool_calls: HashSet<acp::ToolCallId>,
121 expanded_thinking_blocks: HashSet<(usize, usize)>,
122 edits_expanded: bool,
123 plan_expanded: bool,
124 editor_expanded: bool,
125 terminal_expanded: bool,
126 editing_message: Option<usize>,
127 history_store: Entity<agent2::HistoryStore>,
128 _cancel_task: Option<Task<()>>,
129 _subscriptions: [Subscription; 3],
130}
131
132enum ThreadState {
133 Loading {
134 _task: Task<()>,
135 },
136 Ready {
137 thread: Entity<AcpThread>,
138 _subscription: [Subscription; 2],
139 _history_task: Option<Task<()>>,
140 },
141 LoadError(LoadError),
142 Unauthenticated {
143 connection: Rc<dyn AgentConnection>,
144 },
145 ServerExited {
146 status: ExitStatus,
147 },
148}
149
150impl AcpThreadView {
151 pub fn new(
152 agent: Rc<dyn AgentServer>,
153 workspace: WeakEntity<Workspace>,
154 project: Entity<Project>,
155 history_store: Entity<agent2::HistoryStore>,
156 thread_store: Entity<ThreadStore>,
157 text_thread_store: Entity<TextThreadStore>,
158 restore_thread: Option<AcpThreadMetadata>,
159 window: &mut Window,
160 cx: &mut Context<Self>,
161 ) -> Self {
162 let message_editor = cx.new(|cx| {
163 MessageEditor::new(
164 workspace.clone(),
165 project.clone(),
166 thread_store.clone(),
167 text_thread_store.clone(),
168 "Message the agent - @ to include context",
169 editor::EditorMode::AutoHeight {
170 min_lines: MIN_EDITOR_LINES,
171 max_lines: Some(MAX_EDITOR_LINES),
172 },
173 window,
174 cx,
175 )
176 });
177
178 let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
179
180 let entry_view_state = cx.new(|_| {
181 EntryViewState::new(
182 workspace.clone(),
183 project.clone(),
184 thread_store.clone(),
185 text_thread_store.clone(),
186 )
187 });
188
189 let subscriptions = [
190 cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
191 cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
192 cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
193 ];
194
195 Self {
196 agent: agent.clone(),
197 workspace: workspace.clone(),
198 project: project.clone(),
199 entry_view_state,
200 thread_state: Self::initial_state(
201 agent,
202 restore_thread,
203 history_store.clone(),
204 workspace,
205 project,
206 window,
207 cx,
208 ),
209 history_store,
210 message_editor,
211 model_selector: None,
212 profile_selector: None,
213 notifications: Vec::new(),
214 notification_subscriptions: HashMap::default(),
215 list_state: list_state.clone(),
216 scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
217 thread_error: None,
218 auth_task: None,
219 expanded_tool_calls: HashSet::default(),
220 expanded_thinking_blocks: HashSet::default(),
221 editing_message: None,
222 edits_expanded: false,
223 plan_expanded: false,
224 editor_expanded: false,
225 terminal_expanded: true,
226 _subscriptions: subscriptions,
227 _cancel_task: None,
228 }
229 }
230
231 fn initial_state(
232 agent: Rc<dyn AgentServer>,
233 restore_thread: Option<AcpThreadMetadata>,
234 history_store: Entity<agent2::HistoryStore>,
235 workspace: WeakEntity<Workspace>,
236 project: Entity<Project>,
237 window: &mut Window,
238 cx: &mut Context<Self>,
239 ) -> ThreadState {
240 let root_dir = project
241 .read(cx)
242 .visible_worktrees(cx)
243 .next()
244 .map(|worktree| worktree.read(cx).abs_path())
245 .unwrap_or_else(|| paths::home_dir().as_path().into());
246
247 let connect_task = agent.connect(&root_dir, &project, cx);
248 let load_task = cx.spawn_in(window, async move |this, cx| {
249 let connection = match connect_task.await {
250 Ok(connection) => connection,
251 Err(err) => {
252 this.update(cx, |this, cx| {
253 this.handle_load_error(err, cx);
254 cx.notify();
255 })
256 .log_err();
257 return;
258 }
259 };
260
261 let mut history_task = None;
262 let history = connection.clone().history();
263 if let Some(history) = history.clone() {
264 if let Some(mut history) = cx.update(|_, cx| history.observe_history(cx)).ok() {
265 history_task = Some(cx.spawn(async move |cx| {
266 while let Some(update) = history.next().await {
267 if !history_store
268 .update(cx, |history_store, cx| {
269 history_store.update_history(update, cx)
270 })
271 .is_ok()
272 {
273 break;
274 }
275 }
276 }));
277 }
278 }
279
280 // this.update_in(cx, |_this, _window, cx| {
281 // let status = connection.exit_status(cx);
282 // cx.spawn(async move |this, cx| {
283 // let status = status.await.ok();
284 // this.update(cx, |this, cx| {
285 // this.thread_state = ThreadState::ServerExited { status };
286 // cx.notify();
287 // })
288 // .ok();
289 // })
290 // .detach();
291 // })
292 // .ok();
293 let history = connection.clone().history();
294 let task = cx.update(|_, cx| {
295 if let Some(restore_thread) = restore_thread
296 && let Some(history) = history
297 {
298 history.load_thread(project.clone(), &root_dir, restore_thread.id, cx)
299 } else {
300 connection
301 .clone()
302 .new_thread(project.clone(), &root_dir, cx)
303 }
304 });
305
306 let Ok(task) = task else {
307 return;
308 };
309
310 let result = match task.await {
311 Err(e) => {
312 let mut cx = cx.clone();
313 if e.is::<acp_thread::AuthRequired>() {
314 this.update(&mut cx, |this, cx| {
315 this.thread_state = ThreadState::Unauthenticated { connection };
316 cx.notify();
317 })
318 .ok();
319 return;
320 } else {
321 Err(e)
322 }
323 }
324 Ok(thread) => Ok(thread),
325 };
326
327 this.update_in(cx, |this, window, cx| {
328 match result {
329 Ok(thread) => {
330 let thread_subscription =
331 cx.subscribe_in(&thread, window, Self::handle_thread_event);
332
333 let action_log = thread.read(cx).action_log().clone();
334 let action_log_subscription =
335 cx.observe(&action_log, |_, _, cx| cx.notify());
336
337 let count = thread.read(cx).entries().len();
338 this.list_state.splice(0..0, count);
339 this.entry_view_state.update(cx, |view_state, cx| {
340 for ix in 0..count {
341 view_state.sync_entry(ix, &thread, window, cx);
342 }
343 });
344
345 AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
346
347 this.model_selector =
348 thread
349 .read(cx)
350 .connection()
351 .model_selector()
352 .map(|selector| {
353 cx.new(|cx| {
354 AcpModelSelectorPopover::new(
355 thread.read(cx).session_id().clone(),
356 selector,
357 PopoverMenuHandle::default(),
358 this.focus_handle(cx),
359 window,
360 cx,
361 )
362 })
363 });
364
365 this.thread_state = ThreadState::Ready {
366 thread,
367 _subscription: [thread_subscription, action_log_subscription],
368 _history_task: history_task,
369 };
370
371 this.profile_selector = this.as_native_thread(cx).map(|thread| {
372 cx.new(|cx| {
373 ProfileSelector::new(
374 <dyn Fs>::global(cx),
375 Arc::new(thread.clone()),
376 this.focus_handle(cx),
377 cx,
378 )
379 })
380 });
381
382 cx.notify();
383 }
384 Err(err) => {
385 this.handle_load_error(err, cx);
386 }
387 };
388 })
389 .log_err();
390 });
391
392 ThreadState::Loading { _task: load_task }
393 }
394
395 fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context<Self>) {
396 if let Some(load_err) = err.downcast_ref::<LoadError>() {
397 self.thread_state = ThreadState::LoadError(load_err.clone());
398 } else {
399 self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into()))
400 }
401 cx.notify();
402 }
403
404 pub fn thread(&self) -> Option<&Entity<AcpThread>> {
405 match &self.thread_state {
406 ThreadState::Ready { thread, .. } => Some(thread),
407 ThreadState::Unauthenticated { .. }
408 | ThreadState::Loading { .. }
409 | ThreadState::LoadError(..)
410 | ThreadState::ServerExited { .. } => None,
411 }
412 }
413
414 pub fn title(&self, cx: &App) -> SharedString {
415 match &self.thread_state {
416 ThreadState::Ready { thread, .. } => thread.read(cx).title(),
417 ThreadState::Loading { .. } => "Loading…".into(),
418 ThreadState::LoadError(_) => "Failed to load".into(),
419 ThreadState::Unauthenticated { .. } => "Not authenticated".into(),
420 ThreadState::ServerExited { .. } => "Server exited unexpectedly".into(),
421 }
422 }
423
424 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
425 self.thread_error.take();
426
427 if let Some(thread) = self.thread() {
428 self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
429 }
430 }
431
432 pub fn expand_message_editor(
433 &mut self,
434 _: &ExpandMessageEditor,
435 _window: &mut Window,
436 cx: &mut Context<Self>,
437 ) {
438 self.set_editor_is_expanded(!self.editor_expanded, cx);
439 cx.notify();
440 }
441
442 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
443 self.editor_expanded = is_expanded;
444 self.message_editor.update(cx, |editor, cx| {
445 if is_expanded {
446 editor.set_mode(
447 EditorMode::Full {
448 scale_ui_elements_with_buffer_font_size: false,
449 show_active_line_background: false,
450 sized_by_content: false,
451 },
452 cx,
453 )
454 } else {
455 editor.set_mode(
456 EditorMode::AutoHeight {
457 min_lines: MIN_EDITOR_LINES,
458 max_lines: Some(MAX_EDITOR_LINES),
459 },
460 cx,
461 )
462 }
463 });
464 cx.notify();
465 }
466
467 pub fn handle_message_editor_event(
468 &mut self,
469 _: &Entity<MessageEditor>,
470 event: &MessageEditorEvent,
471 window: &mut Window,
472 cx: &mut Context<Self>,
473 ) {
474 match event {
475 MessageEditorEvent::Send => self.send(window, cx),
476 MessageEditorEvent::Cancel => self.cancel_generation(cx),
477 MessageEditorEvent::Focus => {
478 self.cancel_editing(&Default::default(), window, cx);
479 }
480 }
481 }
482
483 pub fn handle_entry_view_event(
484 &mut self,
485 _: &Entity<EntryViewState>,
486 event: &EntryViewEvent,
487 window: &mut Window,
488 cx: &mut Context<Self>,
489 ) {
490 match &event.view_event {
491 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
492 self.editing_message = Some(event.entry_index);
493 cx.notify();
494 }
495 ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
496 self.regenerate(event.entry_index, editor, window, cx);
497 }
498 ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
499 self.cancel_editing(&Default::default(), window, cx);
500 }
501 }
502 }
503
504 fn resume_chat(&mut self, cx: &mut Context<Self>) {
505 self.thread_error.take();
506 let Some(thread) = self.thread() else {
507 return;
508 };
509
510 let task = thread.update(cx, |thread, cx| thread.resume(cx));
511 cx.spawn(async move |this, cx| {
512 let result = task.await;
513
514 this.update(cx, |this, cx| {
515 if let Err(err) = result {
516 this.handle_thread_error(err, cx);
517 }
518 })
519 })
520 .detach();
521 }
522
523 fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
524 if let Some(thread) = self.thread() {
525 if thread.read(cx).status() != ThreadStatus::Idle {
526 self.stop_current_and_send_new_message(window, cx);
527 return;
528 }
529 }
530
531 let contents = self
532 .message_editor
533 .update(cx, |message_editor, cx| message_editor.contents(window, cx));
534 self.send_impl(contents, window, cx)
535 }
536
537 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
538 let Some(thread) = self.thread().cloned() else {
539 return;
540 };
541
542 let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
543
544 let contents = self
545 .message_editor
546 .update(cx, |message_editor, cx| message_editor.contents(window, cx));
547
548 cx.spawn_in(window, async move |this, cx| {
549 cancelled.await;
550
551 this.update_in(cx, |this, window, cx| {
552 this.send_impl(contents, window, cx);
553 })
554 .ok();
555 })
556 .detach();
557 }
558
559 fn send_impl(
560 &mut self,
561 contents: Task<anyhow::Result<Vec<acp::ContentBlock>>>,
562 window: &mut Window,
563 cx: &mut Context<Self>,
564 ) {
565 self.thread_error.take();
566 self.editing_message.take();
567
568 let Some(thread) = self.thread().cloned() else {
569 return;
570 };
571 let task = cx.spawn_in(window, async move |this, cx| {
572 let contents = contents.await?;
573
574 if contents.is_empty() {
575 return Ok(());
576 }
577
578 this.update_in(cx, |this, window, cx| {
579 this.set_editor_is_expanded(false, cx);
580 this.scroll_to_bottom(cx);
581 this.message_editor.update(cx, |message_editor, cx| {
582 message_editor.clear(window, cx);
583 });
584 })?;
585 let send = thread.update(cx, |thread, cx| thread.send(contents, cx))?;
586 send.await
587 });
588
589 cx.spawn(async move |this, cx| {
590 if let Err(err) = task.await {
591 this.update(cx, |this, cx| {
592 this.handle_thread_error(err, cx);
593 })
594 .ok();
595 }
596 })
597 .detach();
598 }
599
600 fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
601 let Some(thread) = self.thread().cloned() else {
602 return;
603 };
604
605 if let Some(index) = self.editing_message.take() {
606 if let Some(editor) = self
607 .entry_view_state
608 .read(cx)
609 .entry(index)
610 .and_then(|e| e.message_editor())
611 .cloned()
612 {
613 editor.update(cx, |editor, cx| {
614 if let Some(user_message) = thread
615 .read(cx)
616 .entries()
617 .get(index)
618 .and_then(|e| e.user_message())
619 {
620 editor.set_message(user_message.chunks.clone(), window, cx);
621 }
622 })
623 }
624 };
625 self.focus_handle(cx).focus(window);
626 cx.notify();
627 }
628
629 fn regenerate(
630 &mut self,
631 entry_ix: usize,
632 message_editor: &Entity<MessageEditor>,
633 window: &mut Window,
634 cx: &mut Context<Self>,
635 ) {
636 let Some(thread) = self.thread().cloned() else {
637 return;
638 };
639
640 let Some(rewind) = thread.update(cx, |thread, cx| {
641 let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?;
642 Some(thread.rewind(user_message_id, cx))
643 }) else {
644 return;
645 };
646
647 let contents =
648 message_editor.update(cx, |message_editor, cx| message_editor.contents(window, cx));
649
650 let task = cx.foreground_executor().spawn(async move {
651 rewind.await?;
652 contents.await
653 });
654 self.send_impl(task, window, cx);
655 }
656
657 fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
658 if let Some(thread) = self.thread() {
659 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
660 }
661 }
662
663 fn open_edited_buffer(
664 &mut self,
665 buffer: &Entity<Buffer>,
666 window: &mut Window,
667 cx: &mut Context<Self>,
668 ) {
669 let Some(thread) = self.thread() else {
670 return;
671 };
672
673 let Some(diff) =
674 AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
675 else {
676 return;
677 };
678
679 diff.update(cx, |diff, cx| {
680 diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx)
681 })
682 }
683
684 fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context<Self>) {
685 self.thread_error = Some(ThreadError::from_err(error));
686 cx.notify();
687 }
688
689 fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
690 self.thread_error = None;
691 cx.notify();
692 }
693
694 fn handle_thread_event(
695 &mut self,
696 thread: &Entity<AcpThread>,
697 event: &AcpThreadEvent,
698 window: &mut Window,
699 cx: &mut Context<Self>,
700 ) {
701 match event {
702 AcpThreadEvent::NewEntry => {
703 let len = thread.read(cx).entries().len();
704 let index = len - 1;
705 self.entry_view_state.update(cx, |view_state, cx| {
706 view_state.sync_entry(index, &thread, window, cx)
707 });
708 self.list_state.splice(index..index, 1);
709 }
710 AcpThreadEvent::EntryUpdated(index) => {
711 self.entry_view_state.update(cx, |view_state, cx| {
712 view_state.sync_entry(*index, &thread, window, cx)
713 });
714 self.list_state.splice(*index..index + 1, 1);
715 }
716 AcpThreadEvent::EntriesRemoved(range) => {
717 self.entry_view_state
718 .update(cx, |view_state, _cx| view_state.remove(range.clone()));
719 self.list_state.splice(range.clone(), 0);
720 }
721 AcpThreadEvent::ToolAuthorizationRequired => {
722 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
723 }
724 AcpThreadEvent::Stopped => {
725 let used_tools = thread.read(cx).used_tools_since_last_user_message();
726 self.notify_with_sound(
727 if used_tools {
728 "Finished running tools"
729 } else {
730 "New message"
731 },
732 IconName::ZedAssistant,
733 window,
734 cx,
735 );
736 }
737 AcpThreadEvent::Error => {
738 self.notify_with_sound(
739 "Agent stopped due to an error",
740 IconName::Warning,
741 window,
742 cx,
743 );
744 }
745 AcpThreadEvent::ServerExited(status) => {
746 self.thread_state = ThreadState::ServerExited { status: *status };
747 }
748 AcpThreadEvent::TitleUpdated => {}
749 }
750 cx.notify();
751 }
752
753 fn authenticate(
754 &mut self,
755 method: acp::AuthMethodId,
756 window: &mut Window,
757 cx: &mut Context<Self>,
758 ) {
759 let ThreadState::Unauthenticated { ref connection } = self.thread_state else {
760 return;
761 };
762
763 self.thread_error.take();
764 let authenticate = connection.authenticate(method, cx);
765 self.auth_task = Some(cx.spawn_in(window, {
766 let project = self.project.clone();
767 let agent = self.agent.clone();
768 async move |this, cx| {
769 let result = authenticate.await;
770
771 this.update_in(cx, |this, window, cx| {
772 if let Err(err) = result {
773 this.handle_thread_error(err, cx);
774 } else {
775 this.thread_state = Self::initial_state(
776 agent,
777 None, // todo!()
778 this.history_store.clone(),
779 this.workspace.clone(),
780 project.clone(),
781 window,
782 cx,
783 )
784 }
785 this.auth_task.take()
786 })
787 .ok();
788 }
789 }));
790 }
791
792 fn authorize_tool_call(
793 &mut self,
794 tool_call_id: acp::ToolCallId,
795 option_id: acp::PermissionOptionId,
796 option_kind: acp::PermissionOptionKind,
797 cx: &mut Context<Self>,
798 ) {
799 let Some(thread) = self.thread() else {
800 return;
801 };
802 thread.update(cx, |thread, cx| {
803 thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
804 });
805 cx.notify();
806 }
807
808 fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
809 let Some(thread) = self.thread() else {
810 return;
811 };
812 thread
813 .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
814 .detach_and_log_err(cx);
815 cx.notify();
816 }
817
818 fn render_entry(
819 &self,
820 entry_ix: usize,
821 total_entries: usize,
822 entry: &AgentThreadEntry,
823 window: &mut Window,
824 cx: &Context<Self>,
825 ) -> AnyElement {
826 let primary = match &entry {
827 AgentThreadEntry::UserMessage(message) => {
828 let Some(editor) = self
829 .entry_view_state
830 .read(cx)
831 .entry(entry_ix)
832 .and_then(|entry| entry.message_editor())
833 .cloned()
834 else {
835 return Empty.into_any_element();
836 };
837
838 let editing = self.editing_message == Some(entry_ix);
839 let editor_focus = editor.focus_handle(cx).is_focused(window);
840 let focus_border = cx.theme().colors().border_focused;
841
842 div()
843 .id(("user_message", entry_ix))
844 .py_4()
845 .px_2()
846 .children(message.id.clone().and_then(|message_id| {
847 message.checkpoint.as_ref()?.show.then(|| {
848 Button::new("restore-checkpoint", "Restore Checkpoint")
849 .icon(IconName::Undo)
850 .icon_size(IconSize::XSmall)
851 .icon_position(IconPosition::Start)
852 .label_size(LabelSize::XSmall)
853 .on_click(cx.listener(move |this, _, _window, cx| {
854 this.rewind(&message_id, cx);
855 }))
856 })
857 }))
858 .child(
859 div()
860 .relative()
861 .child(
862 div()
863 .p_3()
864 .rounded_lg()
865 .shadow_md()
866 .bg(cx.theme().colors().editor_background)
867 .border_1()
868 .when(editing && !editor_focus, |this| this.border_dashed())
869 .border_color(cx.theme().colors().border)
870 .map(|this|{
871 if editor_focus {
872 this.border_color(focus_border)
873 } else {
874 this.hover(|s| s.border_color(focus_border.opacity(0.8)))
875 }
876 })
877 .text_xs()
878 .child(editor.clone().into_any_element()),
879 )
880 .when(editor_focus, |this|
881 this.child(
882 h_flex()
883 .absolute()
884 .top_neg_3p5()
885 .right_3()
886 .gap_1()
887 .rounded_sm()
888 .border_1()
889 .border_color(cx.theme().colors().border)
890 .bg(cx.theme().colors().editor_background)
891 .overflow_hidden()
892 .child(
893 IconButton::new("cancel", IconName::Close)
894 .icon_color(Color::Error)
895 .icon_size(IconSize::XSmall)
896 .on_click(cx.listener(Self::cancel_editing))
897 )
898 .child(
899 IconButton::new("regenerate", IconName::Return)
900 .icon_color(Color::Muted)
901 .icon_size(IconSize::XSmall)
902 .tooltip(Tooltip::text(
903 "Editing will restart the thread from this point."
904 ))
905 .on_click(cx.listener({
906 let editor = editor.clone();
907 move |this, _, window, cx| {
908 this.regenerate(
909 entry_ix, &editor, window, cx,
910 );
911 }
912 })),
913 )
914 )
915 ),
916 )
917 .into_any()
918 }
919 AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
920 let style = default_markdown_style(false, window, cx);
921 let message_body = v_flex()
922 .w_full()
923 .gap_2p5()
924 .children(chunks.iter().enumerate().filter_map(
925 |(chunk_ix, chunk)| match chunk {
926 AssistantMessageChunk::Message { block } => {
927 block.markdown().map(|md| {
928 self.render_markdown(md.clone(), style.clone())
929 .into_any_element()
930 })
931 }
932 AssistantMessageChunk::Thought { block } => {
933 block.markdown().map(|md| {
934 self.render_thinking_block(
935 entry_ix,
936 chunk_ix,
937 md.clone(),
938 window,
939 cx,
940 )
941 .into_any_element()
942 })
943 }
944 },
945 ))
946 .into_any();
947
948 v_flex()
949 .px_5()
950 .py_1()
951 .when(entry_ix + 1 == total_entries, |this| this.pb_4())
952 .w_full()
953 .text_ui(cx)
954 .child(message_body)
955 .into_any()
956 }
957 AgentThreadEntry::ToolCall(tool_call) => {
958 let has_terminals = tool_call.terminals().next().is_some();
959
960 div().w_full().py_1p5().px_5().map(|this| {
961 if has_terminals {
962 this.children(tool_call.terminals().map(|terminal| {
963 self.render_terminal_tool_call(
964 entry_ix, terminal, tool_call, window, cx,
965 )
966 }))
967 } else {
968 this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
969 }
970 })
971 }
972 .into_any(),
973 };
974
975 let Some(thread) = self.thread() else {
976 return primary;
977 };
978
979 let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
980 let primary = if entry_ix == total_entries - 1 && !is_generating {
981 v_flex()
982 .w_full()
983 .child(primary)
984 .child(self.render_thread_controls(cx))
985 .into_any_element()
986 } else {
987 primary
988 };
989
990 if let Some(editing_index) = self.editing_message.as_ref()
991 && *editing_index < entry_ix
992 {
993 div()
994 .child(primary)
995 .opacity(0.2)
996 .block_mouse_except_scroll()
997 .id("overlay")
998 .on_click(cx.listener(Self::cancel_editing))
999 .into_any_element()
1000 } else {
1001 primary
1002 }
1003 }
1004
1005 fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
1006 cx.theme()
1007 .colors()
1008 .element_background
1009 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
1010 }
1011
1012 fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
1013 cx.theme().colors().border.opacity(0.6)
1014 }
1015
1016 fn tool_name_font_size(&self) -> Rems {
1017 rems_from_px(13.)
1018 }
1019
1020 fn render_thinking_block(
1021 &self,
1022 entry_ix: usize,
1023 chunk_ix: usize,
1024 chunk: Entity<Markdown>,
1025 window: &Window,
1026 cx: &Context<Self>,
1027 ) -> AnyElement {
1028 let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
1029 let card_header_id = SharedString::from("inner-card-header");
1030 let key = (entry_ix, chunk_ix);
1031 let is_open = self.expanded_thinking_blocks.contains(&key);
1032
1033 v_flex()
1034 .child(
1035 h_flex()
1036 .id(header_id)
1037 .group(&card_header_id)
1038 .relative()
1039 .w_full()
1040 .gap_1p5()
1041 .opacity(0.8)
1042 .hover(|style| style.opacity(1.))
1043 .child(
1044 h_flex()
1045 .size_4()
1046 .justify_center()
1047 .child(
1048 div()
1049 .group_hover(&card_header_id, |s| s.invisible().w_0())
1050 .child(
1051 Icon::new(IconName::ToolThink)
1052 .size(IconSize::Small)
1053 .color(Color::Muted),
1054 ),
1055 )
1056 .child(
1057 h_flex()
1058 .absolute()
1059 .inset_0()
1060 .invisible()
1061 .justify_center()
1062 .group_hover(&card_header_id, |s| s.visible())
1063 .child(
1064 Disclosure::new(("expand", entry_ix), is_open)
1065 .opened_icon(IconName::ChevronUp)
1066 .closed_icon(IconName::ChevronRight)
1067 .on_click(cx.listener({
1068 move |this, _event, _window, cx| {
1069 if is_open {
1070 this.expanded_thinking_blocks.remove(&key);
1071 } else {
1072 this.expanded_thinking_blocks.insert(key);
1073 }
1074 cx.notify();
1075 }
1076 })),
1077 ),
1078 ),
1079 )
1080 .child(
1081 div()
1082 .text_size(self.tool_name_font_size())
1083 .child("Thinking"),
1084 )
1085 .on_click(cx.listener({
1086 move |this, _event, _window, cx| {
1087 if is_open {
1088 this.expanded_thinking_blocks.remove(&key);
1089 } else {
1090 this.expanded_thinking_blocks.insert(key);
1091 }
1092 cx.notify();
1093 }
1094 })),
1095 )
1096 .when(is_open, |this| {
1097 this.child(
1098 div()
1099 .relative()
1100 .mt_1p5()
1101 .ml(px(7.))
1102 .pl_4()
1103 .border_l_1()
1104 .border_color(self.tool_card_border_color(cx))
1105 .text_ui_sm(cx)
1106 .child(
1107 self.render_markdown(chunk, default_markdown_style(false, window, cx)),
1108 ),
1109 )
1110 })
1111 .into_any_element()
1112 }
1113
1114 fn render_tool_call_icon(
1115 &self,
1116 group_name: SharedString,
1117 entry_ix: usize,
1118 is_collapsible: bool,
1119 is_open: bool,
1120 tool_call: &ToolCall,
1121 cx: &Context<Self>,
1122 ) -> Div {
1123 let tool_icon = Icon::new(match tool_call.kind {
1124 acp::ToolKind::Read => IconName::ToolRead,
1125 acp::ToolKind::Edit => IconName::ToolPencil,
1126 acp::ToolKind::Delete => IconName::ToolDeleteFile,
1127 acp::ToolKind::Move => IconName::ArrowRightLeft,
1128 acp::ToolKind::Search => IconName::ToolSearch,
1129 acp::ToolKind::Execute => IconName::ToolTerminal,
1130 acp::ToolKind::Think => IconName::ToolThink,
1131 acp::ToolKind::Fetch => IconName::ToolWeb,
1132 acp::ToolKind::Other => IconName::ToolHammer,
1133 })
1134 .size(IconSize::Small)
1135 .color(Color::Muted);
1136
1137 let base_container = h_flex().size_4().justify_center();
1138
1139 if is_collapsible {
1140 base_container
1141 .child(
1142 div()
1143 .group_hover(&group_name, |s| s.invisible().w_0())
1144 .child(tool_icon),
1145 )
1146 .child(
1147 h_flex()
1148 .absolute()
1149 .inset_0()
1150 .invisible()
1151 .justify_center()
1152 .group_hover(&group_name, |s| s.visible())
1153 .child(
1154 Disclosure::new(("expand", entry_ix), is_open)
1155 .opened_icon(IconName::ChevronUp)
1156 .closed_icon(IconName::ChevronRight)
1157 .on_click(cx.listener({
1158 let id = tool_call.id.clone();
1159 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1160 if is_open {
1161 this.expanded_tool_calls.remove(&id);
1162 } else {
1163 this.expanded_tool_calls.insert(id.clone());
1164 }
1165 cx.notify();
1166 }
1167 })),
1168 ),
1169 )
1170 } else {
1171 base_container.child(tool_icon)
1172 }
1173 }
1174
1175 fn render_tool_call(
1176 &self,
1177 entry_ix: usize,
1178 tool_call: &ToolCall,
1179 window: &Window,
1180 cx: &Context<Self>,
1181 ) -> Div {
1182 let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
1183 let card_header_id = SharedString::from("inner-tool-call-header");
1184
1185 let status_icon = match &tool_call.status {
1186 ToolCallStatus::Pending
1187 | ToolCallStatus::WaitingForConfirmation { .. }
1188 | ToolCallStatus::Completed => None,
1189 ToolCallStatus::InProgress => Some(
1190 Icon::new(IconName::ArrowCircle)
1191 .color(Color::Accent)
1192 .size(IconSize::Small)
1193 .with_animation(
1194 "running",
1195 Animation::new(Duration::from_secs(2)).repeat(),
1196 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1197 )
1198 .into_any(),
1199 ),
1200 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => Some(
1201 Icon::new(IconName::Close)
1202 .color(Color::Error)
1203 .size(IconSize::Small)
1204 .into_any_element(),
1205 ),
1206 };
1207
1208 let needs_confirmation = matches!(
1209 tool_call.status,
1210 ToolCallStatus::WaitingForConfirmation { .. }
1211 );
1212 let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit);
1213 let has_diff = tool_call
1214 .content
1215 .iter()
1216 .any(|content| matches!(content, ToolCallContent::Diff { .. }));
1217 let has_nonempty_diff = tool_call.content.iter().any(|content| match content {
1218 ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx),
1219 _ => false,
1220 });
1221 let use_card_layout = needs_confirmation || is_edit || has_diff;
1222
1223 let is_collapsible = !tool_call.content.is_empty() && !use_card_layout;
1224
1225 let is_open = tool_call.content.is_empty()
1226 || needs_confirmation
1227 || has_nonempty_diff
1228 || self.expanded_tool_calls.contains(&tool_call.id);
1229
1230 let gradient_overlay = |color: Hsla| {
1231 div()
1232 .absolute()
1233 .top_0()
1234 .right_0()
1235 .w_12()
1236 .h_full()
1237 .bg(linear_gradient(
1238 90.,
1239 linear_color_stop(color, 1.),
1240 linear_color_stop(color.opacity(0.2), 0.),
1241 ))
1242 };
1243 let gradient_color = if use_card_layout {
1244 self.tool_card_header_bg(cx)
1245 } else {
1246 cx.theme().colors().panel_background
1247 };
1248
1249 let tool_output_display = match &tool_call.status {
1250 ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
1251 .w_full()
1252 .children(tool_call.content.iter().map(|content| {
1253 div()
1254 .child(
1255 self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
1256 )
1257 .into_any_element()
1258 }))
1259 .child(self.render_permission_buttons(
1260 options,
1261 entry_ix,
1262 tool_call.id.clone(),
1263 tool_call.content.is_empty(),
1264 cx,
1265 )),
1266 ToolCallStatus::Pending
1267 | ToolCallStatus::InProgress
1268 | ToolCallStatus::Completed
1269 | ToolCallStatus::Failed
1270 | ToolCallStatus::Canceled => {
1271 v_flex()
1272 .w_full()
1273 .children(tool_call.content.iter().map(|content| {
1274 div()
1275 .child(
1276 self.render_tool_call_content(
1277 entry_ix, content, tool_call, window, cx,
1278 ),
1279 )
1280 .into_any_element()
1281 }))
1282 }
1283 ToolCallStatus::Rejected => v_flex().size_0(),
1284 };
1285
1286 v_flex()
1287 .when(use_card_layout, |this| {
1288 this.rounded_lg()
1289 .border_1()
1290 .border_color(self.tool_card_border_color(cx))
1291 .bg(cx.theme().colors().editor_background)
1292 .overflow_hidden()
1293 })
1294 .child(
1295 h_flex()
1296 .id(header_id)
1297 .w_full()
1298 .gap_1()
1299 .justify_between()
1300 .map(|this| {
1301 if use_card_layout {
1302 this.pl_2()
1303 .pr_1()
1304 .py_1()
1305 .rounded_t_md()
1306 .bg(self.tool_card_header_bg(cx))
1307 } else {
1308 this.opacity(0.8).hover(|style| style.opacity(1.))
1309 }
1310 })
1311 .child(
1312 h_flex()
1313 .group(&card_header_id)
1314 .relative()
1315 .w_full()
1316 .text_size(self.tool_name_font_size())
1317 .child(self.render_tool_call_icon(
1318 card_header_id,
1319 entry_ix,
1320 is_collapsible,
1321 is_open,
1322 tool_call,
1323 cx,
1324 ))
1325 .child(if tool_call.locations.len() == 1 {
1326 let name = tool_call.locations[0]
1327 .path
1328 .file_name()
1329 .unwrap_or_default()
1330 .display()
1331 .to_string();
1332
1333 h_flex()
1334 .id(("open-tool-call-location", entry_ix))
1335 .w_full()
1336 .max_w_full()
1337 .px_1p5()
1338 .rounded_sm()
1339 .overflow_x_scroll()
1340 .opacity(0.8)
1341 .hover(|label| {
1342 label.opacity(1.).bg(cx
1343 .theme()
1344 .colors()
1345 .element_hover
1346 .opacity(0.5))
1347 })
1348 .child(name)
1349 .tooltip(Tooltip::text("Jump to File"))
1350 .on_click(cx.listener(move |this, _, window, cx| {
1351 this.open_tool_call_location(entry_ix, 0, window, cx);
1352 }))
1353 .into_any_element()
1354 } else {
1355 h_flex()
1356 .id("non-card-label-container")
1357 .w_full()
1358 .relative()
1359 .ml_1p5()
1360 .overflow_hidden()
1361 .child(
1362 h_flex()
1363 .id("non-card-label")
1364 .pr_8()
1365 .w_full()
1366 .overflow_x_scroll()
1367 .child(self.render_markdown(
1368 tool_call.label.clone(),
1369 default_markdown_style(
1370 needs_confirmation || is_edit || has_diff,
1371 window,
1372 cx,
1373 ),
1374 )),
1375 )
1376 .child(gradient_overlay(gradient_color))
1377 .on_click(cx.listener({
1378 let id = tool_call.id.clone();
1379 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1380 if is_open {
1381 this.expanded_tool_calls.remove(&id);
1382 } else {
1383 this.expanded_tool_calls.insert(id.clone());
1384 }
1385 cx.notify();
1386 }
1387 }))
1388 .into_any()
1389 }),
1390 )
1391 .children(status_icon),
1392 )
1393 .when(is_open, |this| this.child(tool_output_display))
1394 }
1395
1396 fn render_tool_call_content(
1397 &self,
1398 entry_ix: usize,
1399 content: &ToolCallContent,
1400 tool_call: &ToolCall,
1401 window: &Window,
1402 cx: &Context<Self>,
1403 ) -> AnyElement {
1404 match content {
1405 ToolCallContent::ContentBlock(content) => {
1406 if let Some(resource_link) = content.resource_link() {
1407 self.render_resource_link(resource_link, cx)
1408 } else if let Some(markdown) = content.markdown() {
1409 self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
1410 } else {
1411 Empty.into_any_element()
1412 }
1413 }
1414 ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, &diff, cx),
1415 ToolCallContent::Terminal(terminal) => {
1416 self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
1417 }
1418 }
1419 }
1420
1421 fn render_markdown_output(
1422 &self,
1423 markdown: Entity<Markdown>,
1424 tool_call_id: acp::ToolCallId,
1425 window: &Window,
1426 cx: &Context<Self>,
1427 ) -> AnyElement {
1428 let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id.clone()));
1429
1430 v_flex()
1431 .mt_1p5()
1432 .ml(px(7.))
1433 .px_3p5()
1434 .gap_2()
1435 .border_l_1()
1436 .border_color(self.tool_card_border_color(cx))
1437 .text_sm()
1438 .text_color(cx.theme().colors().text_muted)
1439 .child(self.render_markdown(markdown, default_markdown_style(false, window, cx)))
1440 .child(
1441 Button::new(button_id, "Collapse Output")
1442 .full_width()
1443 .style(ButtonStyle::Outlined)
1444 .label_size(LabelSize::Small)
1445 .icon(IconName::ChevronUp)
1446 .icon_color(Color::Muted)
1447 .icon_position(IconPosition::Start)
1448 .on_click(cx.listener({
1449 let id = tool_call_id.clone();
1450 move |this: &mut Self, _, _, cx: &mut Context<Self>| {
1451 this.expanded_tool_calls.remove(&id);
1452 cx.notify();
1453 }
1454 })),
1455 )
1456 .into_any_element()
1457 }
1458
1459 fn render_resource_link(
1460 &self,
1461 resource_link: &acp::ResourceLink,
1462 cx: &Context<Self>,
1463 ) -> AnyElement {
1464 let uri: SharedString = resource_link.uri.clone().into();
1465
1466 let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") {
1467 path.to_string().into()
1468 } else {
1469 uri.clone()
1470 };
1471
1472 let button_id = SharedString::from(format!("item-{}", uri.clone()));
1473
1474 div()
1475 .ml(px(7.))
1476 .pl_2p5()
1477 .border_l_1()
1478 .border_color(self.tool_card_border_color(cx))
1479 .overflow_hidden()
1480 .child(
1481 Button::new(button_id, label)
1482 .label_size(LabelSize::Small)
1483 .color(Color::Muted)
1484 .icon(IconName::ArrowUpRight)
1485 .icon_size(IconSize::XSmall)
1486 .icon_color(Color::Muted)
1487 .truncate(true)
1488 .on_click(cx.listener({
1489 let workspace = self.workspace.clone();
1490 move |_, _, window, cx: &mut Context<Self>| {
1491 Self::open_link(uri.clone(), &workspace, window, cx);
1492 }
1493 })),
1494 )
1495 .into_any_element()
1496 }
1497
1498 fn render_permission_buttons(
1499 &self,
1500 options: &[acp::PermissionOption],
1501 entry_ix: usize,
1502 tool_call_id: acp::ToolCallId,
1503 empty_content: bool,
1504 cx: &Context<Self>,
1505 ) -> Div {
1506 h_flex()
1507 .py_1()
1508 .pl_2()
1509 .pr_1()
1510 .gap_1()
1511 .justify_between()
1512 .flex_wrap()
1513 .when(!empty_content, |this| {
1514 this.border_t_1()
1515 .border_color(self.tool_card_border_color(cx))
1516 })
1517 .child(
1518 div()
1519 .min_w(rems_from_px(145.))
1520 .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
1521 )
1522 .child(h_flex().gap_0p5().children(options.iter().map(|option| {
1523 let option_id = SharedString::from(option.id.0.clone());
1524 Button::new((option_id, entry_ix), option.name.clone())
1525 .map(|this| match option.kind {
1526 acp::PermissionOptionKind::AllowOnce => {
1527 this.icon(IconName::Check).icon_color(Color::Success)
1528 }
1529 acp::PermissionOptionKind::AllowAlways => {
1530 this.icon(IconName::CheckDouble).icon_color(Color::Success)
1531 }
1532 acp::PermissionOptionKind::RejectOnce => {
1533 this.icon(IconName::Close).icon_color(Color::Error)
1534 }
1535 acp::PermissionOptionKind::RejectAlways => {
1536 this.icon(IconName::Close).icon_color(Color::Error)
1537 }
1538 })
1539 .icon_position(IconPosition::Start)
1540 .icon_size(IconSize::XSmall)
1541 .label_size(LabelSize::Small)
1542 .on_click(cx.listener({
1543 let tool_call_id = tool_call_id.clone();
1544 let option_id = option.id.clone();
1545 let option_kind = option.kind;
1546 move |this, _, _, cx| {
1547 this.authorize_tool_call(
1548 tool_call_id.clone(),
1549 option_id.clone(),
1550 option_kind,
1551 cx,
1552 );
1553 }
1554 }))
1555 })))
1556 }
1557
1558 fn render_diff_editor(
1559 &self,
1560 entry_ix: usize,
1561 diff: &Entity<acp_thread::Diff>,
1562 cx: &Context<Self>,
1563 ) -> AnyElement {
1564 v_flex()
1565 .h_full()
1566 .border_t_1()
1567 .border_color(self.tool_card_border_color(cx))
1568 .child(
1569 if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
1570 && let Some(editor) = entry.editor_for_diff(&diff)
1571 {
1572 editor.clone().into_any_element()
1573 } else {
1574 Empty.into_any()
1575 },
1576 )
1577 .into_any()
1578 }
1579
1580 fn render_terminal_tool_call(
1581 &self,
1582 entry_ix: usize,
1583 terminal: &Entity<acp_thread::Terminal>,
1584 tool_call: &ToolCall,
1585 window: &Window,
1586 cx: &Context<Self>,
1587 ) -> AnyElement {
1588 let terminal_data = terminal.read(cx);
1589 let working_dir = terminal_data.working_dir();
1590 let command = terminal_data.command();
1591 let started_at = terminal_data.started_at();
1592
1593 let tool_failed = matches!(
1594 &tool_call.status,
1595 ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
1596 );
1597
1598 let output = terminal_data.output();
1599 let command_finished = output.is_some();
1600 let truncated_output = output.is_some_and(|output| output.was_content_truncated);
1601 let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
1602
1603 let command_failed = command_finished
1604 && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
1605
1606 let time_elapsed = if let Some(output) = output {
1607 output.ended_at.duration_since(started_at)
1608 } else {
1609 started_at.elapsed()
1610 };
1611
1612 let header_bg = cx
1613 .theme()
1614 .colors()
1615 .element_background
1616 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
1617 let border_color = cx.theme().colors().border.opacity(0.6);
1618
1619 let working_dir = working_dir
1620 .as_ref()
1621 .map(|path| format!("{}", path.display()))
1622 .unwrap_or_else(|| "current directory".to_string());
1623
1624 let header = h_flex()
1625 .id(SharedString::from(format!(
1626 "terminal-tool-header-{}",
1627 terminal.entity_id()
1628 )))
1629 .flex_none()
1630 .gap_1()
1631 .justify_between()
1632 .rounded_t_md()
1633 .child(
1634 div()
1635 .id(("command-target-path", terminal.entity_id()))
1636 .w_full()
1637 .max_w_full()
1638 .overflow_x_scroll()
1639 .child(
1640 Label::new(working_dir)
1641 .buffer_font(cx)
1642 .size(LabelSize::XSmall)
1643 .color(Color::Muted),
1644 ),
1645 )
1646 .when(!command_finished, |header| {
1647 header
1648 .gap_1p5()
1649 .child(
1650 Button::new(
1651 SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
1652 "Stop",
1653 )
1654 .icon(IconName::Stop)
1655 .icon_position(IconPosition::Start)
1656 .icon_size(IconSize::Small)
1657 .icon_color(Color::Error)
1658 .label_size(LabelSize::Small)
1659 .tooltip(move |window, cx| {
1660 Tooltip::with_meta(
1661 "Stop This Command",
1662 None,
1663 "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
1664 window,
1665 cx,
1666 )
1667 })
1668 .on_click({
1669 let terminal = terminal.clone();
1670 cx.listener(move |_this, _event, _window, cx| {
1671 let inner_terminal = terminal.read(cx).inner().clone();
1672 inner_terminal.update(cx, |inner_terminal, _cx| {
1673 inner_terminal.kill_active_task();
1674 });
1675 })
1676 }),
1677 )
1678 .child(Divider::vertical())
1679 .child(
1680 Icon::new(IconName::ArrowCircle)
1681 .size(IconSize::XSmall)
1682 .color(Color::Info)
1683 .with_animation(
1684 "arrow-circle",
1685 Animation::new(Duration::from_secs(2)).repeat(),
1686 |icon, delta| {
1687 icon.transform(Transformation::rotate(percentage(delta)))
1688 },
1689 ),
1690 )
1691 })
1692 .when(tool_failed || command_failed, |header| {
1693 header.child(
1694 div()
1695 .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
1696 .child(
1697 Icon::new(IconName::Close)
1698 .size(IconSize::Small)
1699 .color(Color::Error),
1700 )
1701 .when_some(output.and_then(|o| o.exit_status), |this, status| {
1702 this.tooltip(Tooltip::text(format!(
1703 "Exited with code {}",
1704 status.code().unwrap_or(-1),
1705 )))
1706 }),
1707 )
1708 })
1709 .when(truncated_output, |header| {
1710 let tooltip = if let Some(output) = output {
1711 if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
1712 "Output exceeded terminal max lines and was \
1713 truncated, the model received the first 16 KB."
1714 .to_string()
1715 } else {
1716 format!(
1717 "Output is {} long—to avoid unexpected token usage, \
1718 only 16 KB was sent back to the model.",
1719 format_file_size(output.original_content_len as u64, true),
1720 )
1721 }
1722 } else {
1723 "Output was truncated".to_string()
1724 };
1725
1726 header.child(
1727 h_flex()
1728 .id(("terminal-tool-truncated-label", terminal.entity_id()))
1729 .gap_1()
1730 .child(
1731 Icon::new(IconName::Info)
1732 .size(IconSize::XSmall)
1733 .color(Color::Ignored),
1734 )
1735 .child(
1736 Label::new("Truncated")
1737 .color(Color::Muted)
1738 .size(LabelSize::XSmall),
1739 )
1740 .tooltip(Tooltip::text(tooltip)),
1741 )
1742 })
1743 .when(time_elapsed > Duration::from_secs(10), |header| {
1744 header.child(
1745 Label::new(format!("({})", duration_alt_display(time_elapsed)))
1746 .buffer_font(cx)
1747 .color(Color::Muted)
1748 .size(LabelSize::XSmall),
1749 )
1750 })
1751 .child(
1752 Disclosure::new(
1753 SharedString::from(format!(
1754 "terminal-tool-disclosure-{}",
1755 terminal.entity_id()
1756 )),
1757 self.terminal_expanded,
1758 )
1759 .opened_icon(IconName::ChevronUp)
1760 .closed_icon(IconName::ChevronDown)
1761 .on_click(cx.listener(move |this, _event, _window, _cx| {
1762 this.terminal_expanded = !this.terminal_expanded;
1763 })),
1764 );
1765
1766 let terminal_view = self
1767 .entry_view_state
1768 .read(cx)
1769 .entry(entry_ix)
1770 .and_then(|entry| entry.terminal(&terminal));
1771 let show_output = self.terminal_expanded && terminal_view.is_some();
1772
1773 v_flex()
1774 .mb_2()
1775 .border_1()
1776 .when(tool_failed || command_failed, |card| card.border_dashed())
1777 .border_color(border_color)
1778 .rounded_lg()
1779 .overflow_hidden()
1780 .child(
1781 v_flex()
1782 .py_1p5()
1783 .pl_2()
1784 .pr_1p5()
1785 .gap_0p5()
1786 .bg(header_bg)
1787 .text_xs()
1788 .child(header)
1789 .child(
1790 MarkdownElement::new(
1791 command.clone(),
1792 terminal_command_markdown_style(window, cx),
1793 )
1794 .code_block_renderer(
1795 markdown::CodeBlockRenderer::Default {
1796 copy_button: false,
1797 copy_button_on_hover: true,
1798 border: false,
1799 },
1800 ),
1801 ),
1802 )
1803 .when(show_output, |this| {
1804 this.child(
1805 div()
1806 .pt_2()
1807 .border_t_1()
1808 .when(tool_failed || command_failed, |card| card.border_dashed())
1809 .border_color(border_color)
1810 .bg(cx.theme().colors().editor_background)
1811 .rounded_b_md()
1812 .text_ui_sm(cx)
1813 .children(terminal_view.clone()),
1814 )
1815 })
1816 .into_any()
1817 }
1818
1819 fn render_agent_logo(&self) -> AnyElement {
1820 Icon::new(self.agent.logo())
1821 .color(Color::Muted)
1822 .size(IconSize::XLarge)
1823 .into_any_element()
1824 }
1825
1826 fn render_error_agent_logo(&self) -> AnyElement {
1827 let logo = Icon::new(self.agent.logo())
1828 .color(Color::Muted)
1829 .size(IconSize::XLarge)
1830 .into_any_element();
1831
1832 h_flex()
1833 .relative()
1834 .justify_center()
1835 .child(div().opacity(0.3).child(logo))
1836 .child(
1837 h_flex().absolute().right_1().bottom_0().child(
1838 Icon::new(IconName::XCircle)
1839 .color(Color::Error)
1840 .size(IconSize::Small),
1841 ),
1842 )
1843 .into_any_element()
1844 }
1845
1846 fn render_empty_state(&self, cx: &App) -> AnyElement {
1847 let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
1848
1849 v_flex()
1850 .size_full()
1851 .items_center()
1852 .justify_center()
1853 .child(if loading {
1854 h_flex()
1855 .justify_center()
1856 .child(self.render_agent_logo())
1857 .with_animation(
1858 "pulsating_icon",
1859 Animation::new(Duration::from_secs(2))
1860 .repeat()
1861 .with_easing(pulsating_between(0.4, 1.0)),
1862 |icon, delta| icon.opacity(delta),
1863 )
1864 .into_any()
1865 } else {
1866 self.render_agent_logo().into_any_element()
1867 })
1868 .child(h_flex().mt_4().mb_1().justify_center().child(if loading {
1869 div()
1870 .child(LoadingLabel::new("").size(LabelSize::Large))
1871 .into_any_element()
1872 } else {
1873 Headline::new(self.agent.empty_state_headline())
1874 .size(HeadlineSize::Medium)
1875 .into_any_element()
1876 }))
1877 .child(
1878 div()
1879 .max_w_1_2()
1880 .text_sm()
1881 .text_center()
1882 .map(|this| {
1883 if loading {
1884 this.invisible()
1885 } else {
1886 this.text_color(cx.theme().colors().text_muted)
1887 }
1888 })
1889 .child(self.agent.empty_state_message()),
1890 )
1891 .into_any()
1892 }
1893
1894 fn render_pending_auth_state(&self) -> AnyElement {
1895 v_flex()
1896 .items_center()
1897 .justify_center()
1898 .child(self.render_error_agent_logo())
1899 .child(
1900 h_flex()
1901 .mt_4()
1902 .mb_1()
1903 .justify_center()
1904 .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)),
1905 )
1906 .into_any()
1907 }
1908
1909 fn render_server_exited(&self, status: ExitStatus, _cx: &Context<Self>) -> AnyElement {
1910 v_flex()
1911 .items_center()
1912 .justify_center()
1913 .child(self.render_error_agent_logo())
1914 .child(
1915 v_flex()
1916 .mt_4()
1917 .mb_2()
1918 .gap_0p5()
1919 .text_center()
1920 .items_center()
1921 .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium))
1922 .child(
1923 Label::new(format!("Exit status: {}", status.code().unwrap_or(-127)))
1924 .size(LabelSize::Small)
1925 .color(Color::Muted),
1926 ),
1927 )
1928 .into_any_element()
1929 }
1930
1931 fn render_load_error(&self, e: &LoadError, cx: &Context<Self>) -> AnyElement {
1932 let mut container = v_flex()
1933 .items_center()
1934 .justify_center()
1935 .child(self.render_error_agent_logo())
1936 .child(
1937 v_flex()
1938 .mt_4()
1939 .mb_2()
1940 .gap_0p5()
1941 .text_center()
1942 .items_center()
1943 .child(Headline::new("Failed to launch").size(HeadlineSize::Medium))
1944 .child(
1945 Label::new(e.to_string())
1946 .size(LabelSize::Small)
1947 .color(Color::Muted),
1948 ),
1949 );
1950
1951 if let LoadError::Unsupported {
1952 upgrade_message,
1953 upgrade_command,
1954 ..
1955 } = &e
1956 {
1957 let upgrade_message = upgrade_message.clone();
1958 let upgrade_command = upgrade_command.clone();
1959 container = container.child(Button::new("upgrade", upgrade_message).on_click(
1960 cx.listener(move |this, _, window, cx| {
1961 this.workspace
1962 .update(cx, |workspace, cx| {
1963 let project = workspace.project().read(cx);
1964 let cwd = project.first_project_directory(cx);
1965 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1966 let spawn_in_terminal = task::SpawnInTerminal {
1967 id: task::TaskId("install".to_string()),
1968 full_label: upgrade_command.clone(),
1969 label: upgrade_command.clone(),
1970 command: Some(upgrade_command.clone()),
1971 args: Vec::new(),
1972 command_label: upgrade_command.clone(),
1973 cwd,
1974 env: Default::default(),
1975 use_new_terminal: true,
1976 allow_concurrent_runs: true,
1977 reveal: Default::default(),
1978 reveal_target: Default::default(),
1979 hide: Default::default(),
1980 shell,
1981 show_summary: true,
1982 show_command: true,
1983 show_rerun: false,
1984 };
1985 workspace
1986 .spawn_in_terminal(spawn_in_terminal, window, cx)
1987 .detach();
1988 })
1989 .ok();
1990 }),
1991 ));
1992 }
1993
1994 container.into_any()
1995 }
1996
1997 fn render_activity_bar(
1998 &self,
1999 thread_entity: &Entity<AcpThread>,
2000 window: &mut Window,
2001 cx: &Context<Self>,
2002 ) -> Option<AnyElement> {
2003 let thread = thread_entity.read(cx);
2004 let action_log = thread.action_log();
2005 let changed_buffers = action_log.read(cx).changed_buffers(cx);
2006 let plan = thread.plan();
2007
2008 if changed_buffers.is_empty() && plan.is_empty() {
2009 return None;
2010 }
2011
2012 let editor_bg_color = cx.theme().colors().editor_background;
2013 let active_color = cx.theme().colors().element_selected;
2014 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
2015
2016 let pending_edits = thread.has_pending_edit_tool_calls();
2017
2018 v_flex()
2019 .mt_1()
2020 .mx_2()
2021 .bg(bg_edit_files_disclosure)
2022 .border_1()
2023 .border_b_0()
2024 .border_color(cx.theme().colors().border)
2025 .rounded_t_md()
2026 .shadow(vec![gpui::BoxShadow {
2027 color: gpui::black().opacity(0.15),
2028 offset: point(px(1.), px(-1.)),
2029 blur_radius: px(3.),
2030 spread_radius: px(0.),
2031 }])
2032 .when(!plan.is_empty(), |this| {
2033 this.child(self.render_plan_summary(plan, window, cx))
2034 .when(self.plan_expanded, |parent| {
2035 parent.child(self.render_plan_entries(plan, window, cx))
2036 })
2037 })
2038 .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| {
2039 this.child(Divider::horizontal().color(DividerColor::Border))
2040 })
2041 .when(!changed_buffers.is_empty(), |this| {
2042 this.child(self.render_edits_summary(
2043 action_log,
2044 &changed_buffers,
2045 self.edits_expanded,
2046 pending_edits,
2047 window,
2048 cx,
2049 ))
2050 .when(self.edits_expanded, |parent| {
2051 parent.child(self.render_edited_files(
2052 action_log,
2053 &changed_buffers,
2054 pending_edits,
2055 cx,
2056 ))
2057 })
2058 })
2059 .into_any()
2060 .into()
2061 }
2062
2063 fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2064 let stats = plan.stats();
2065
2066 let title = if let Some(entry) = stats.in_progress_entry
2067 && !self.plan_expanded
2068 {
2069 h_flex()
2070 .w_full()
2071 .cursor_default()
2072 .gap_1()
2073 .text_xs()
2074 .text_color(cx.theme().colors().text_muted)
2075 .justify_between()
2076 .child(
2077 h_flex()
2078 .gap_1()
2079 .child(
2080 Label::new("Current:")
2081 .size(LabelSize::Small)
2082 .color(Color::Muted),
2083 )
2084 .child(MarkdownElement::new(
2085 entry.content.clone(),
2086 plan_label_markdown_style(&entry.status, window, cx),
2087 )),
2088 )
2089 .when(stats.pending > 0, |this| {
2090 this.child(
2091 Label::new(format!("{} left", stats.pending))
2092 .size(LabelSize::Small)
2093 .color(Color::Muted)
2094 .mr_1(),
2095 )
2096 })
2097 } else {
2098 let status_label = if stats.pending == 0 {
2099 "All Done".to_string()
2100 } else if stats.completed == 0 {
2101 format!("{} Tasks", plan.entries.len())
2102 } else {
2103 format!("{}/{}", stats.completed, plan.entries.len())
2104 };
2105
2106 h_flex()
2107 .w_full()
2108 .gap_1()
2109 .justify_between()
2110 .child(
2111 Label::new("Plan")
2112 .size(LabelSize::Small)
2113 .color(Color::Muted),
2114 )
2115 .child(
2116 Label::new(status_label)
2117 .size(LabelSize::Small)
2118 .color(Color::Muted)
2119 .mr_1(),
2120 )
2121 };
2122
2123 h_flex()
2124 .p_1()
2125 .justify_between()
2126 .when(self.plan_expanded, |this| {
2127 this.border_b_1().border_color(cx.theme().colors().border)
2128 })
2129 .child(
2130 h_flex()
2131 .id("plan_summary")
2132 .w_full()
2133 .gap_1()
2134 .child(Disclosure::new("plan_disclosure", self.plan_expanded))
2135 .child(title)
2136 .on_click(cx.listener(|this, _, _, cx| {
2137 this.plan_expanded = !this.plan_expanded;
2138 cx.notify();
2139 })),
2140 )
2141 }
2142
2143 fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context<Self>) -> Div {
2144 v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
2145 let element = h_flex()
2146 .py_1()
2147 .px_2()
2148 .gap_2()
2149 .justify_between()
2150 .bg(cx.theme().colors().editor_background)
2151 .when(index < plan.entries.len() - 1, |parent| {
2152 parent.border_color(cx.theme().colors().border).border_b_1()
2153 })
2154 .child(
2155 h_flex()
2156 .id(("plan_entry", index))
2157 .gap_1p5()
2158 .max_w_full()
2159 .overflow_x_scroll()
2160 .text_xs()
2161 .text_color(cx.theme().colors().text_muted)
2162 .child(match entry.status {
2163 acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
2164 .size(IconSize::Small)
2165 .color(Color::Muted)
2166 .into_any_element(),
2167 acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
2168 .size(IconSize::Small)
2169 .color(Color::Accent)
2170 .with_animation(
2171 "running",
2172 Animation::new(Duration::from_secs(2)).repeat(),
2173 |icon, delta| {
2174 icon.transform(Transformation::rotate(percentage(delta)))
2175 },
2176 )
2177 .into_any_element(),
2178 acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
2179 .size(IconSize::Small)
2180 .color(Color::Success)
2181 .into_any_element(),
2182 })
2183 .child(MarkdownElement::new(
2184 entry.content.clone(),
2185 plan_label_markdown_style(&entry.status, window, cx),
2186 )),
2187 );
2188
2189 Some(element)
2190 }))
2191 }
2192
2193 fn render_edits_summary(
2194 &self,
2195 action_log: &Entity<ActionLog>,
2196 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2197 expanded: bool,
2198 pending_edits: bool,
2199 window: &mut Window,
2200 cx: &Context<Self>,
2201 ) -> Div {
2202 const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
2203
2204 let focus_handle = self.focus_handle(cx);
2205
2206 h_flex()
2207 .p_1()
2208 .justify_between()
2209 .when(expanded, |this| {
2210 this.border_b_1().border_color(cx.theme().colors().border)
2211 })
2212 .child(
2213 h_flex()
2214 .id("edits-container")
2215 .w_full()
2216 .gap_1()
2217 .child(Disclosure::new("edits-disclosure", expanded))
2218 .map(|this| {
2219 if pending_edits {
2220 this.child(
2221 Label::new(format!(
2222 "Editing {} {}…",
2223 changed_buffers.len(),
2224 if changed_buffers.len() == 1 {
2225 "file"
2226 } else {
2227 "files"
2228 }
2229 ))
2230 .color(Color::Muted)
2231 .size(LabelSize::Small)
2232 .with_animation(
2233 "edit-label",
2234 Animation::new(Duration::from_secs(2))
2235 .repeat()
2236 .with_easing(pulsating_between(0.3, 0.7)),
2237 |label, delta| label.alpha(delta),
2238 ),
2239 )
2240 } else {
2241 this.child(
2242 Label::new("Edits")
2243 .size(LabelSize::Small)
2244 .color(Color::Muted),
2245 )
2246 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
2247 .child(
2248 Label::new(format!(
2249 "{} {}",
2250 changed_buffers.len(),
2251 if changed_buffers.len() == 1 {
2252 "file"
2253 } else {
2254 "files"
2255 }
2256 ))
2257 .size(LabelSize::Small)
2258 .color(Color::Muted),
2259 )
2260 }
2261 })
2262 .on_click(cx.listener(|this, _, _, cx| {
2263 this.edits_expanded = !this.edits_expanded;
2264 cx.notify();
2265 })),
2266 )
2267 .child(
2268 h_flex()
2269 .gap_1()
2270 .child(
2271 IconButton::new("review-changes", IconName::ListTodo)
2272 .icon_size(IconSize::Small)
2273 .tooltip({
2274 let focus_handle = focus_handle.clone();
2275 move |window, cx| {
2276 Tooltip::for_action_in(
2277 "Review Changes",
2278 &OpenAgentDiff,
2279 &focus_handle,
2280 window,
2281 cx,
2282 )
2283 }
2284 })
2285 .on_click(cx.listener(|_, _, window, cx| {
2286 window.dispatch_action(OpenAgentDiff.boxed_clone(), cx);
2287 })),
2288 )
2289 .child(Divider::vertical().color(DividerColor::Border))
2290 .child(
2291 Button::new("reject-all-changes", "Reject All")
2292 .label_size(LabelSize::Small)
2293 .disabled(pending_edits)
2294 .when(pending_edits, |this| {
2295 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2296 })
2297 .key_binding(
2298 KeyBinding::for_action_in(
2299 &RejectAll,
2300 &focus_handle.clone(),
2301 window,
2302 cx,
2303 )
2304 .map(|kb| kb.size(rems_from_px(10.))),
2305 )
2306 .on_click({
2307 let action_log = action_log.clone();
2308 cx.listener(move |_, _, _, cx| {
2309 action_log.update(cx, |action_log, cx| {
2310 action_log.reject_all_edits(cx).detach();
2311 })
2312 })
2313 }),
2314 )
2315 .child(
2316 Button::new("keep-all-changes", "Keep All")
2317 .label_size(LabelSize::Small)
2318 .disabled(pending_edits)
2319 .when(pending_edits, |this| {
2320 this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
2321 })
2322 .key_binding(
2323 KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx)
2324 .map(|kb| kb.size(rems_from_px(10.))),
2325 )
2326 .on_click({
2327 let action_log = action_log.clone();
2328 cx.listener(move |_, _, _, cx| {
2329 action_log.update(cx, |action_log, cx| {
2330 action_log.keep_all_edits(cx);
2331 })
2332 })
2333 }),
2334 ),
2335 )
2336 }
2337
2338 fn render_edited_files(
2339 &self,
2340 action_log: &Entity<ActionLog>,
2341 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
2342 pending_edits: bool,
2343 cx: &Context<Self>,
2344 ) -> Div {
2345 let editor_bg_color = cx.theme().colors().editor_background;
2346
2347 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
2348 |(index, (buffer, _diff))| {
2349 let file = buffer.read(cx).file()?;
2350 let path = file.path();
2351
2352 let file_path = path.parent().and_then(|parent| {
2353 let parent_str = parent.to_string_lossy();
2354
2355 if parent_str.is_empty() {
2356 None
2357 } else {
2358 Some(
2359 Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR))
2360 .color(Color::Muted)
2361 .size(LabelSize::XSmall)
2362 .buffer_font(cx),
2363 )
2364 }
2365 });
2366
2367 let file_name = path.file_name().map(|name| {
2368 Label::new(name.to_string_lossy().to_string())
2369 .size(LabelSize::XSmall)
2370 .buffer_font(cx)
2371 });
2372
2373 let file_icon = FileIcons::get_icon(&path, cx)
2374 .map(Icon::from_path)
2375 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
2376 .unwrap_or_else(|| {
2377 Icon::new(IconName::File)
2378 .color(Color::Muted)
2379 .size(IconSize::Small)
2380 });
2381
2382 let overlay_gradient = linear_gradient(
2383 90.,
2384 linear_color_stop(editor_bg_color, 1.),
2385 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
2386 );
2387
2388 let element = h_flex()
2389 .group("edited-code")
2390 .id(("file-container", index))
2391 .relative()
2392 .py_1()
2393 .pl_2()
2394 .pr_1()
2395 .gap_2()
2396 .justify_between()
2397 .bg(editor_bg_color)
2398 .when(index < changed_buffers.len() - 1, |parent| {
2399 parent.border_color(cx.theme().colors().border).border_b_1()
2400 })
2401 .child(
2402 h_flex()
2403 .id(("file-name", index))
2404 .pr_8()
2405 .gap_1p5()
2406 .max_w_full()
2407 .overflow_x_scroll()
2408 .child(file_icon)
2409 .child(h_flex().gap_0p5().children(file_name).children(file_path))
2410 .on_click({
2411 let buffer = buffer.clone();
2412 cx.listener(move |this, _, window, cx| {
2413 this.open_edited_buffer(&buffer, window, cx);
2414 })
2415 }),
2416 )
2417 .child(
2418 h_flex()
2419 .gap_1()
2420 .visible_on_hover("edited-code")
2421 .child(
2422 Button::new("review", "Review")
2423 .label_size(LabelSize::Small)
2424 .on_click({
2425 let buffer = buffer.clone();
2426 cx.listener(move |this, _, window, cx| {
2427 this.open_edited_buffer(&buffer, window, cx);
2428 })
2429 }),
2430 )
2431 .child(Divider::vertical().color(DividerColor::BorderVariant))
2432 .child(
2433 Button::new("reject-file", "Reject")
2434 .label_size(LabelSize::Small)
2435 .disabled(pending_edits)
2436 .on_click({
2437 let buffer = buffer.clone();
2438 let action_log = action_log.clone();
2439 move |_, _, cx| {
2440 action_log.update(cx, |action_log, cx| {
2441 action_log
2442 .reject_edits_in_ranges(
2443 buffer.clone(),
2444 vec![Anchor::MIN..Anchor::MAX],
2445 cx,
2446 )
2447 .detach_and_log_err(cx);
2448 })
2449 }
2450 }),
2451 )
2452 .child(
2453 Button::new("keep-file", "Keep")
2454 .label_size(LabelSize::Small)
2455 .disabled(pending_edits)
2456 .on_click({
2457 let buffer = buffer.clone();
2458 let action_log = action_log.clone();
2459 move |_, _, cx| {
2460 action_log.update(cx, |action_log, cx| {
2461 action_log.keep_edits_in_range(
2462 buffer.clone(),
2463 Anchor::MIN..Anchor::MAX,
2464 cx,
2465 );
2466 })
2467 }
2468 }),
2469 ),
2470 )
2471 .child(
2472 div()
2473 .id("gradient-overlay")
2474 .absolute()
2475 .h_full()
2476 .w_12()
2477 .top_0()
2478 .bottom_0()
2479 .right(px(152.))
2480 .bg(overlay_gradient),
2481 );
2482
2483 Some(element)
2484 },
2485 ))
2486 }
2487
2488 fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
2489 let focus_handle = self.message_editor.focus_handle(cx);
2490 let editor_bg_color = cx.theme().colors().editor_background;
2491 let (expand_icon, expand_tooltip) = if self.editor_expanded {
2492 (IconName::Minimize, "Minimize Message Editor")
2493 } else {
2494 (IconName::Maximize, "Expand Message Editor")
2495 };
2496
2497 v_flex()
2498 .on_action(cx.listener(Self::expand_message_editor))
2499 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
2500 if let Some(profile_selector) = this.profile_selector.as_ref() {
2501 profile_selector.read(cx).menu_handle().toggle(window, cx);
2502 }
2503 }))
2504 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
2505 if let Some(model_selector) = this.model_selector.as_ref() {
2506 model_selector
2507 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
2508 }
2509 }))
2510 .p_2()
2511 .gap_2()
2512 .border_t_1()
2513 .border_color(cx.theme().colors().border)
2514 .bg(editor_bg_color)
2515 .when(self.editor_expanded, |this| {
2516 this.h(vh(0.8, window)).size_full().justify_between()
2517 })
2518 .child(
2519 v_flex()
2520 .relative()
2521 .size_full()
2522 .pt_1()
2523 .pr_2p5()
2524 .child(self.message_editor.clone())
2525 .child(
2526 h_flex()
2527 .absolute()
2528 .top_0()
2529 .right_0()
2530 .opacity(0.5)
2531 .hover(|this| this.opacity(1.0))
2532 .child(
2533 IconButton::new("toggle-height", expand_icon)
2534 .icon_size(IconSize::Small)
2535 .icon_color(Color::Muted)
2536 .tooltip({
2537 let focus_handle = focus_handle.clone();
2538 move |window, cx| {
2539 Tooltip::for_action_in(
2540 expand_tooltip,
2541 &ExpandMessageEditor,
2542 &focus_handle,
2543 window,
2544 cx,
2545 )
2546 }
2547 })
2548 .on_click(cx.listener(|_, _, window, cx| {
2549 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
2550 })),
2551 ),
2552 ),
2553 )
2554 .child(
2555 h_flex()
2556 .flex_none()
2557 .justify_between()
2558 .child(
2559 h_flex()
2560 .gap_1()
2561 .child(self.render_follow_toggle(cx))
2562 .children(self.render_burn_mode_toggle(cx)),
2563 )
2564 .child(
2565 h_flex()
2566 .gap_1()
2567 .children(self.profile_selector.clone())
2568 .children(self.model_selector.clone())
2569 .child(self.render_send_button(cx)),
2570 ),
2571 )
2572 .into_any()
2573 }
2574
2575 pub(crate) fn as_native_connection(
2576 &self,
2577 cx: &App,
2578 ) -> Option<Rc<agent2::NativeAgentConnection>> {
2579 let acp_thread = self.thread()?.read(cx);
2580 acp_thread.connection().clone().downcast()
2581 }
2582
2583 pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent2::Thread>> {
2584 let acp_thread = self.thread()?.read(cx);
2585 self.as_native_connection(cx)?
2586 .thread(acp_thread.session_id(), cx)
2587 }
2588
2589 fn toggle_burn_mode(
2590 &mut self,
2591 _: &ToggleBurnMode,
2592 _window: &mut Window,
2593 cx: &mut Context<Self>,
2594 ) {
2595 let Some(thread) = self.as_native_thread(cx) else {
2596 return;
2597 };
2598
2599 thread.update(cx, |thread, cx| {
2600 let current_mode = thread.completion_mode();
2601 thread.set_completion_mode(
2602 match current_mode {
2603 CompletionMode::Burn => CompletionMode::Normal,
2604 CompletionMode::Normal => CompletionMode::Burn,
2605 },
2606 cx,
2607 );
2608 });
2609 }
2610
2611 fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
2612 let thread = self.as_native_thread(cx)?.read(cx);
2613
2614 if thread
2615 .model()
2616 .map_or(true, |model| !model.supports_burn_mode())
2617 {
2618 return None;
2619 }
2620
2621 let active_completion_mode = thread.completion_mode();
2622 let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
2623 let icon = if burn_mode_enabled {
2624 IconName::ZedBurnModeOn
2625 } else {
2626 IconName::ZedBurnMode
2627 };
2628
2629 Some(
2630 IconButton::new("burn-mode", icon)
2631 .icon_size(IconSize::Small)
2632 .icon_color(Color::Muted)
2633 .toggle_state(burn_mode_enabled)
2634 .selected_icon_color(Color::Error)
2635 .on_click(cx.listener(|this, _event, window, cx| {
2636 this.toggle_burn_mode(&ToggleBurnMode, window, cx);
2637 }))
2638 .tooltip(move |_window, cx| {
2639 cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
2640 .into()
2641 })
2642 .into_any_element(),
2643 )
2644 }
2645
2646 fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
2647 let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
2648 let is_generating = self.thread().map_or(false, |thread| {
2649 thread.read(cx).status() != ThreadStatus::Idle
2650 });
2651
2652 if is_generating && is_editor_empty {
2653 IconButton::new("stop-generation", IconName::Stop)
2654 .icon_color(Color::Error)
2655 .style(ButtonStyle::Tinted(ui::TintColor::Error))
2656 .tooltip(move |window, cx| {
2657 Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx)
2658 })
2659 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
2660 .into_any_element()
2661 } else {
2662 let send_btn_tooltip = if is_editor_empty && !is_generating {
2663 "Type to Send"
2664 } else if is_generating {
2665 "Stop and Send Message"
2666 } else {
2667 "Send"
2668 };
2669
2670 IconButton::new("send-message", IconName::Send)
2671 .style(ButtonStyle::Filled)
2672 .map(|this| {
2673 if is_editor_empty && !is_generating {
2674 this.disabled(true).icon_color(Color::Muted)
2675 } else {
2676 this.icon_color(Color::Accent)
2677 }
2678 })
2679 .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx))
2680 .on_click(cx.listener(|this, _, window, cx| {
2681 this.send(window, cx);
2682 }))
2683 .into_any_element()
2684 }
2685 }
2686
2687 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2688 let following = self
2689 .workspace
2690 .read_with(cx, |workspace, _| {
2691 workspace.is_being_followed(CollaboratorId::Agent)
2692 })
2693 .unwrap_or(false);
2694
2695 IconButton::new("follow-agent", IconName::Crosshair)
2696 .icon_size(IconSize::Small)
2697 .icon_color(Color::Muted)
2698 .toggle_state(following)
2699 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
2700 .tooltip(move |window, cx| {
2701 if following {
2702 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
2703 } else {
2704 Tooltip::with_meta(
2705 "Follow Agent",
2706 Some(&Follow),
2707 "Track the agent's location as it reads and edits files.",
2708 window,
2709 cx,
2710 )
2711 }
2712 })
2713 .on_click(cx.listener(move |this, _, window, cx| {
2714 this.workspace
2715 .update(cx, |workspace, cx| {
2716 if following {
2717 workspace.unfollow(CollaboratorId::Agent, window, cx);
2718 } else {
2719 workspace.follow(CollaboratorId::Agent, window, cx);
2720 }
2721 })
2722 .ok();
2723 }))
2724 }
2725
2726 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2727 let workspace = self.workspace.clone();
2728 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2729 Self::open_link(text, &workspace, window, cx);
2730 })
2731 }
2732
2733 fn open_link(
2734 url: SharedString,
2735 workspace: &WeakEntity<Workspace>,
2736 window: &mut Window,
2737 cx: &mut App,
2738 ) {
2739 let Some(workspace) = workspace.upgrade() else {
2740 cx.open_url(&url);
2741 return;
2742 };
2743
2744 if let Some(mention) = MentionUri::parse(&url).log_err() {
2745 workspace.update(cx, |workspace, cx| match mention {
2746 MentionUri::File { abs_path, .. } => {
2747 let project = workspace.project();
2748 let Some((path, entry)) = project.update(cx, |project, cx| {
2749 let path = project.find_project_path(abs_path, cx)?;
2750 let entry = project.entry_for_path(&path, cx)?;
2751 Some((path, entry))
2752 }) else {
2753 return;
2754 };
2755
2756 if entry.is_dir() {
2757 project.update(cx, |_, cx| {
2758 cx.emit(project::Event::RevealInProjectPanel(entry.id));
2759 });
2760 } else {
2761 workspace
2762 .open_path(path, None, true, window, cx)
2763 .detach_and_log_err(cx);
2764 }
2765 }
2766 MentionUri::Symbol {
2767 path, line_range, ..
2768 }
2769 | MentionUri::Selection { path, line_range } => {
2770 let project = workspace.project();
2771 let Some((path, _)) = project.update(cx, |project, cx| {
2772 let path = project.find_project_path(path, cx)?;
2773 let entry = project.entry_for_path(&path, cx)?;
2774 Some((path, entry))
2775 }) else {
2776 return;
2777 };
2778
2779 let item = workspace.open_path(path, None, true, window, cx);
2780 window
2781 .spawn(cx, async move |cx| {
2782 let Some(editor) = item.await?.downcast::<Editor>() else {
2783 return Ok(());
2784 };
2785 let range =
2786 Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
2787 editor
2788 .update_in(cx, |editor, window, cx| {
2789 editor.change_selections(
2790 SelectionEffects::scroll(Autoscroll::center()),
2791 window,
2792 cx,
2793 |s| s.select_ranges(vec![range]),
2794 );
2795 })
2796 .ok();
2797 anyhow::Ok(())
2798 })
2799 .detach_and_log_err(cx);
2800 }
2801 MentionUri::Thread { id, .. } => {
2802 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2803 panel.update(cx, |panel, cx| {
2804 panel
2805 .open_thread_by_id(&id, window, cx)
2806 .detach_and_log_err(cx)
2807 });
2808 }
2809 }
2810 MentionUri::TextThread { path, .. } => {
2811 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2812 panel.update(cx, |panel, cx| {
2813 panel
2814 .open_saved_prompt_editor(path.as_path().into(), window, cx)
2815 .detach_and_log_err(cx);
2816 });
2817 }
2818 }
2819 MentionUri::Rule { id, .. } => {
2820 let PromptId::User { uuid } = id else {
2821 return;
2822 };
2823 window.dispatch_action(
2824 Box::new(OpenRulesLibrary {
2825 prompt_to_select: Some(uuid.0),
2826 }),
2827 cx,
2828 )
2829 }
2830 MentionUri::Fetch { url } => {
2831 cx.open_url(url.as_str());
2832 }
2833 })
2834 } else {
2835 cx.open_url(&url);
2836 }
2837 }
2838
2839 fn open_tool_call_location(
2840 &self,
2841 entry_ix: usize,
2842 location_ix: usize,
2843 window: &mut Window,
2844 cx: &mut Context<Self>,
2845 ) -> Option<()> {
2846 let (tool_call_location, agent_location) = self
2847 .thread()?
2848 .read(cx)
2849 .entries()
2850 .get(entry_ix)?
2851 .location(location_ix)?;
2852
2853 let project_path = self
2854 .project
2855 .read(cx)
2856 .find_project_path(&tool_call_location.path, cx)?;
2857
2858 let open_task = self
2859 .workspace
2860 .update(cx, |workspace, cx| {
2861 workspace.open_path(project_path, None, true, window, cx)
2862 })
2863 .log_err()?;
2864 window
2865 .spawn(cx, async move |cx| {
2866 let item = open_task.await?;
2867
2868 let Some(active_editor) = item.downcast::<Editor>() else {
2869 return anyhow::Ok(());
2870 };
2871
2872 active_editor.update_in(cx, |editor, window, cx| {
2873 let multibuffer = editor.buffer().read(cx);
2874 let buffer = multibuffer.as_singleton();
2875 if agent_location.buffer.upgrade() == buffer {
2876 let excerpt_id = multibuffer.excerpt_ids().first().cloned();
2877 let anchor = editor::Anchor::in_buffer(
2878 excerpt_id.unwrap(),
2879 buffer.unwrap().read(cx).remote_id(),
2880 agent_location.position,
2881 );
2882 editor.change_selections(Default::default(), window, cx, |selections| {
2883 selections.select_anchor_ranges([anchor..anchor]);
2884 })
2885 } else {
2886 let row = tool_call_location.line.unwrap_or_default();
2887 editor.change_selections(Default::default(), window, cx, |selections| {
2888 selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]);
2889 })
2890 }
2891 })?;
2892
2893 anyhow::Ok(())
2894 })
2895 .detach_and_log_err(cx);
2896
2897 None
2898 }
2899
2900 pub fn open_thread_as_markdown(
2901 &self,
2902 workspace: Entity<Workspace>,
2903 window: &mut Window,
2904 cx: &mut App,
2905 ) -> Task<anyhow::Result<()>> {
2906 let markdown_language_task = workspace
2907 .read(cx)
2908 .app_state()
2909 .languages
2910 .language_for_name("Markdown");
2911
2912 let (thread_summary, markdown) = if let Some(thread) = self.thread() {
2913 let thread = thread.read(cx);
2914 (thread.title().to_string(), thread.to_markdown(cx))
2915 } else {
2916 return Task::ready(Ok(()));
2917 };
2918
2919 window.spawn(cx, async move |cx| {
2920 let markdown_language = markdown_language_task.await?;
2921
2922 workspace.update_in(cx, |workspace, window, cx| {
2923 let project = workspace.project().clone();
2924
2925 if !project.read(cx).is_local() {
2926 bail!("failed to open active thread as markdown in remote project");
2927 }
2928
2929 let buffer = project.update(cx, |project, cx| {
2930 project.create_local_buffer(&markdown, Some(markdown_language), cx)
2931 });
2932 let buffer = cx.new(|cx| {
2933 MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())
2934 });
2935
2936 workspace.add_item_to_active_pane(
2937 Box::new(cx.new(|cx| {
2938 let mut editor =
2939 Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
2940 editor.set_breadcrumb_header(thread_summary);
2941 editor
2942 })),
2943 None,
2944 true,
2945 window,
2946 cx,
2947 );
2948
2949 anyhow::Ok(())
2950 })??;
2951 anyhow::Ok(())
2952 })
2953 }
2954
2955 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
2956 self.list_state.scroll_to(ListOffset::default());
2957 cx.notify();
2958 }
2959
2960 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
2961 if let Some(thread) = self.thread() {
2962 let entry_count = thread.read(cx).entries().len();
2963 self.list_state.reset(entry_count);
2964 cx.notify();
2965 }
2966 }
2967
2968 fn notify_with_sound(
2969 &mut self,
2970 caption: impl Into<SharedString>,
2971 icon: IconName,
2972 window: &mut Window,
2973 cx: &mut Context<Self>,
2974 ) {
2975 self.play_notification_sound(window, cx);
2976 self.show_notification(caption, icon, window, cx);
2977 }
2978
2979 fn play_notification_sound(&self, window: &Window, cx: &mut App) {
2980 let settings = AgentSettings::get_global(cx);
2981 if settings.play_sound_when_agent_done && !window.is_window_active() {
2982 Audio::play_sound(Sound::AgentDone, cx);
2983 }
2984 }
2985
2986 fn show_notification(
2987 &mut self,
2988 caption: impl Into<SharedString>,
2989 icon: IconName,
2990 window: &mut Window,
2991 cx: &mut Context<Self>,
2992 ) {
2993 if window.is_window_active() || !self.notifications.is_empty() {
2994 return;
2995 }
2996
2997 let title = self.title(cx);
2998
2999 match AgentSettings::get_global(cx).notify_when_agent_waiting {
3000 NotifyWhenAgentWaiting::PrimaryScreen => {
3001 if let Some(primary) = cx.primary_display() {
3002 self.pop_up(icon, caption.into(), title, window, primary, cx);
3003 }
3004 }
3005 NotifyWhenAgentWaiting::AllScreens => {
3006 let caption = caption.into();
3007 for screen in cx.displays() {
3008 self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
3009 }
3010 }
3011 NotifyWhenAgentWaiting::Never => {
3012 // Don't show anything
3013 }
3014 }
3015 }
3016
3017 fn pop_up(
3018 &mut self,
3019 icon: IconName,
3020 caption: SharedString,
3021 title: SharedString,
3022 window: &mut Window,
3023 screen: Rc<dyn PlatformDisplay>,
3024 cx: &mut Context<Self>,
3025 ) {
3026 let options = AgentNotification::window_options(screen, cx);
3027
3028 let project_name = self.workspace.upgrade().and_then(|workspace| {
3029 workspace
3030 .read(cx)
3031 .project()
3032 .read(cx)
3033 .visible_worktrees(cx)
3034 .next()
3035 .map(|worktree| worktree.read(cx).root_name().to_string())
3036 });
3037
3038 if let Some(screen_window) = cx
3039 .open_window(options, |_, cx| {
3040 cx.new(|_| {
3041 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
3042 })
3043 })
3044 .log_err()
3045 {
3046 if let Some(pop_up) = screen_window.entity(cx).log_err() {
3047 self.notification_subscriptions
3048 .entry(screen_window)
3049 .or_insert_with(Vec::new)
3050 .push(cx.subscribe_in(&pop_up, window, {
3051 |this, _, event, window, cx| match event {
3052 AgentNotificationEvent::Accepted => {
3053 let handle = window.window_handle();
3054 cx.activate(true);
3055
3056 let workspace_handle = this.workspace.clone();
3057
3058 // If there are multiple Zed windows, activate the correct one.
3059 cx.defer(move |cx| {
3060 handle
3061 .update(cx, |_view, window, _cx| {
3062 window.activate_window();
3063
3064 if let Some(workspace) = workspace_handle.upgrade() {
3065 workspace.update(_cx, |workspace, cx| {
3066 workspace.focus_panel::<AgentPanel>(window, cx);
3067 });
3068 }
3069 })
3070 .log_err();
3071 });
3072
3073 this.dismiss_notifications(cx);
3074 }
3075 AgentNotificationEvent::Dismissed => {
3076 this.dismiss_notifications(cx);
3077 }
3078 }
3079 }));
3080
3081 self.notifications.push(screen_window);
3082
3083 // If the user manually refocuses the original window, dismiss the popup.
3084 self.notification_subscriptions
3085 .entry(screen_window)
3086 .or_insert_with(Vec::new)
3087 .push({
3088 let pop_up_weak = pop_up.downgrade();
3089
3090 cx.observe_window_activation(window, move |_, window, cx| {
3091 if window.is_window_active() {
3092 if let Some(pop_up) = pop_up_weak.upgrade() {
3093 pop_up.update(cx, |_, cx| {
3094 cx.emit(AgentNotificationEvent::Dismissed);
3095 });
3096 }
3097 }
3098 })
3099 });
3100 }
3101 }
3102 }
3103
3104 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
3105 for window in self.notifications.drain(..) {
3106 window
3107 .update(cx, |_, window, _| {
3108 window.remove_window();
3109 })
3110 .ok();
3111
3112 self.notification_subscriptions.remove(&window);
3113 }
3114 }
3115
3116 fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement {
3117 let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
3118 .shape(ui::IconButtonShape::Square)
3119 .icon_size(IconSize::Small)
3120 .icon_color(Color::Ignored)
3121 .tooltip(Tooltip::text("Open Thread as Markdown"))
3122 .on_click(cx.listener(move |this, _, window, cx| {
3123 if let Some(workspace) = this.workspace.upgrade() {
3124 this.open_thread_as_markdown(workspace, window, cx)
3125 .detach_and_log_err(cx);
3126 }
3127 }));
3128
3129 let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
3130 .shape(ui::IconButtonShape::Square)
3131 .icon_size(IconSize::Small)
3132 .icon_color(Color::Ignored)
3133 .tooltip(Tooltip::text("Scroll To Top"))
3134 .on_click(cx.listener(move |this, _, _, cx| {
3135 this.scroll_to_top(cx);
3136 }));
3137
3138 h_flex()
3139 .w_full()
3140 .mr_1()
3141 .pb_2()
3142 .px(RESPONSE_PADDING_X)
3143 .opacity(0.4)
3144 .hover(|style| style.opacity(1.))
3145 .flex_wrap()
3146 .justify_end()
3147 .child(open_as_markdown)
3148 .child(scroll_to_top)
3149 }
3150
3151 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
3152 div()
3153 .id("acp-thread-scrollbar")
3154 .occlude()
3155 .on_mouse_move(cx.listener(|_, _, _, cx| {
3156 cx.notify();
3157 cx.stop_propagation()
3158 }))
3159 .on_hover(|_, _, cx| {
3160 cx.stop_propagation();
3161 })
3162 .on_any_mouse_down(|_, _, cx| {
3163 cx.stop_propagation();
3164 })
3165 .on_mouse_up(
3166 MouseButton::Left,
3167 cx.listener(|_, _, _, cx| {
3168 cx.stop_propagation();
3169 }),
3170 )
3171 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3172 cx.notify();
3173 }))
3174 .h_full()
3175 .absolute()
3176 .right_1()
3177 .top_1()
3178 .bottom_0()
3179 .w(px(12.))
3180 .cursor_default()
3181 .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
3182 }
3183
3184 fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3185 self.entry_view_state.update(cx, |entry_view_state, cx| {
3186 entry_view_state.settings_changed(cx);
3187 });
3188 }
3189
3190 pub(crate) fn insert_dragged_files(
3191 &self,
3192 paths: Vec<project::ProjectPath>,
3193 added_worktrees: Vec<Entity<project::Worktree>>,
3194 window: &mut Window,
3195 cx: &mut Context<Self>,
3196 ) {
3197 self.message_editor.update(cx, |message_editor, cx| {
3198 message_editor.insert_dragged_files(paths, window, cx);
3199 drop(added_worktrees);
3200 })
3201 }
3202
3203 fn render_thread_error(&self, window: &mut Window, cx: &mut Context<'_, Self>) -> Option<Div> {
3204 let content = match self.thread_error.as_ref()? {
3205 ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
3206 ThreadError::PaymentRequired => self.render_payment_required_error(cx),
3207 ThreadError::ModelRequestLimitReached(plan) => {
3208 self.render_model_request_limit_reached_error(*plan, cx)
3209 }
3210 ThreadError::ToolUseLimitReached => {
3211 self.render_tool_use_limit_reached_error(window, cx)?
3212 }
3213 };
3214
3215 Some(
3216 div()
3217 .border_t_1()
3218 .border_color(cx.theme().colors().border)
3219 .child(content),
3220 )
3221 }
3222
3223 fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
3224 let icon = Icon::new(IconName::XCircle)
3225 .size(IconSize::Small)
3226 .color(Color::Error);
3227
3228 Callout::new()
3229 .icon(icon)
3230 .title("Error")
3231 .description(error.clone())
3232 .secondary_action(self.create_copy_button(error.to_string()))
3233 .primary_action(self.dismiss_error_button(cx))
3234 .bg_color(self.error_callout_bg(cx))
3235 }
3236
3237 fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
3238 const ERROR_MESSAGE: &str =
3239 "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
3240
3241 let icon = Icon::new(IconName::XCircle)
3242 .size(IconSize::Small)
3243 .color(Color::Error);
3244
3245 Callout::new()
3246 .icon(icon)
3247 .title("Free Usage Exceeded")
3248 .description(ERROR_MESSAGE)
3249 .tertiary_action(self.upgrade_button(cx))
3250 .secondary_action(self.create_copy_button(ERROR_MESSAGE))
3251 .primary_action(self.dismiss_error_button(cx))
3252 .bg_color(self.error_callout_bg(cx))
3253 }
3254
3255 fn render_model_request_limit_reached_error(
3256 &self,
3257 plan: cloud_llm_client::Plan,
3258 cx: &mut Context<Self>,
3259 ) -> Callout {
3260 let error_message = match plan {
3261 cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
3262 cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
3263 "Upgrade to Zed Pro for more prompts."
3264 }
3265 };
3266
3267 let icon = Icon::new(IconName::XCircle)
3268 .size(IconSize::Small)
3269 .color(Color::Error);
3270
3271 Callout::new()
3272 .icon(icon)
3273 .title("Model Prompt Limit Reached")
3274 .description(error_message)
3275 .tertiary_action(self.upgrade_button(cx))
3276 .secondary_action(self.create_copy_button(error_message))
3277 .primary_action(self.dismiss_error_button(cx))
3278 .bg_color(self.error_callout_bg(cx))
3279 }
3280
3281 fn render_tool_use_limit_reached_error(
3282 &self,
3283 window: &mut Window,
3284 cx: &mut Context<Self>,
3285 ) -> Option<Callout> {
3286 let thread = self.as_native_thread(cx)?;
3287 let supports_burn_mode = thread
3288 .read(cx)
3289 .model()
3290 .map_or(false, |model| model.supports_burn_mode());
3291
3292 let focus_handle = self.focus_handle(cx);
3293
3294 let icon = Icon::new(IconName::Info)
3295 .size(IconSize::Small)
3296 .color(Color::Info);
3297
3298 Some(
3299 Callout::new()
3300 .icon(icon)
3301 .title("Consecutive tool use limit reached.")
3302 .when(supports_burn_mode, |this| {
3303 this.secondary_action(
3304 Button::new("continue-burn-mode", "Continue with Burn Mode")
3305 .style(ButtonStyle::Filled)
3306 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3307 .layer(ElevationIndex::ModalSurface)
3308 .label_size(LabelSize::Small)
3309 .key_binding(
3310 KeyBinding::for_action_in(
3311 &ContinueWithBurnMode,
3312 &focus_handle,
3313 window,
3314 cx,
3315 )
3316 .map(|kb| kb.size(rems_from_px(10.))),
3317 )
3318 .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use."))
3319 .on_click({
3320 cx.listener(move |this, _, _window, cx| {
3321 thread.update(cx, |thread, cx| {
3322 thread.set_completion_mode(CompletionMode::Burn, cx);
3323 });
3324 this.resume_chat(cx);
3325 })
3326 }),
3327 )
3328 })
3329 .primary_action(
3330 Button::new("continue-conversation", "Continue")
3331 .layer(ElevationIndex::ModalSurface)
3332 .label_size(LabelSize::Small)
3333 .key_binding(
3334 KeyBinding::for_action_in(&ContinueThread, &focus_handle, window, cx)
3335 .map(|kb| kb.size(rems_from_px(10.))),
3336 )
3337 .on_click(cx.listener(|this, _, _window, cx| {
3338 this.resume_chat(cx);
3339 })),
3340 ),
3341 )
3342 }
3343
3344 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
3345 let message = message.into();
3346
3347 IconButton::new("copy", IconName::Copy)
3348 .icon_size(IconSize::Small)
3349 .icon_color(Color::Muted)
3350 .tooltip(Tooltip::text("Copy Error Message"))
3351 .on_click(move |_, _, cx| {
3352 cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
3353 })
3354 }
3355
3356 fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3357 IconButton::new("dismiss", IconName::Close)
3358 .icon_size(IconSize::Small)
3359 .icon_color(Color::Muted)
3360 .tooltip(Tooltip::text("Dismiss Error"))
3361 .on_click(cx.listener({
3362 move |this, _, _, cx| {
3363 this.clear_thread_error(cx);
3364 cx.notify();
3365 }
3366 }))
3367 }
3368
3369 fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3370 Button::new("upgrade", "Upgrade")
3371 .label_size(LabelSize::Small)
3372 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
3373 .on_click(cx.listener({
3374 move |this, _, _, cx| {
3375 this.clear_thread_error(cx);
3376 cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx));
3377 }
3378 }))
3379 }
3380
3381 fn error_callout_bg(&self, cx: &Context<Self>) -> Hsla {
3382 cx.theme().status().error.opacity(0.08)
3383 }
3384}
3385
3386impl Focusable for AcpThreadView {
3387 fn focus_handle(&self, cx: &App) -> FocusHandle {
3388 self.message_editor.focus_handle(cx)
3389 }
3390}
3391
3392impl Render for AcpThreadView {
3393 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3394 let has_messages = self.list_state.item_count() > 0;
3395
3396 v_flex()
3397 .size_full()
3398 .key_context("AcpThread")
3399 .on_action(cx.listener(Self::open_agent_diff))
3400 .on_action(cx.listener(Self::toggle_burn_mode))
3401 .bg(cx.theme().colors().panel_background)
3402 .child(match &self.thread_state {
3403 ThreadState::Unauthenticated { connection } => v_flex()
3404 .p_2()
3405 .flex_1()
3406 .items_center()
3407 .justify_center()
3408 .child(self.render_pending_auth_state())
3409 .child(h_flex().mt_1p5().justify_center().children(
3410 connection.auth_methods().into_iter().map(|method| {
3411 Button::new(
3412 SharedString::from(method.id.0.clone()),
3413 method.name.clone(),
3414 )
3415 .on_click({
3416 let method_id = method.id.clone();
3417 cx.listener(move |this, _, window, cx| {
3418 this.authenticate(method_id.clone(), window, cx)
3419 })
3420 })
3421 }),
3422 )),
3423 ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
3424 ThreadState::LoadError(e) => v_flex()
3425 .p_2()
3426 .flex_1()
3427 .items_center()
3428 .justify_center()
3429 .child(self.render_load_error(e, cx)),
3430 ThreadState::ServerExited { status } => v_flex()
3431 .p_2()
3432 .flex_1()
3433 .items_center()
3434 .justify_center()
3435 .child(self.render_server_exited(*status, cx)),
3436 ThreadState::Ready { thread, .. } => {
3437 let thread_clone = thread.clone();
3438
3439 v_flex().flex_1().map(|this| {
3440 if has_messages {
3441 this.child(
3442 list(
3443 self.list_state.clone(),
3444 cx.processor(|this, index: usize, window, cx| {
3445 let Some((entry, len)) = this.thread().and_then(|thread| {
3446 let entries = &thread.read(cx).entries();
3447 Some((entries.get(index)?, entries.len()))
3448 }) else {
3449 return Empty.into_any();
3450 };
3451 this.render_entry(index, len, entry, window, cx)
3452 }),
3453 )
3454 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
3455 .flex_grow()
3456 .into_any(),
3457 )
3458 .child(self.render_vertical_scrollbar(cx))
3459 .children(
3460 match thread_clone.read(cx).status() {
3461 ThreadStatus::Idle
3462 | ThreadStatus::WaitingForToolConfirmation => None,
3463 ThreadStatus::Generating => div()
3464 .px_5()
3465 .py_2()
3466 .child(LoadingLabel::new("").size(LabelSize::Small))
3467 .into(),
3468 },
3469 )
3470 } else {
3471 this.child(self.render_empty_state(cx))
3472 }
3473 })
3474 }
3475 })
3476 // The activity bar is intentionally rendered outside of the ThreadState::Ready match
3477 // above so that the scrollbar doesn't render behind it. The current setup allows
3478 // the scrollbar to stop exactly at the activity bar start.
3479 .when(has_messages, |this| match &self.thread_state {
3480 ThreadState::Ready { thread, .. } => {
3481 this.children(self.render_activity_bar(thread, window, cx))
3482 }
3483 _ => this,
3484 })
3485 .children(self.render_thread_error(window, cx))
3486 .child(self.render_message_editor(window, cx))
3487 }
3488}
3489
3490fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
3491 let theme_settings = ThemeSettings::get_global(cx);
3492 let colors = cx.theme().colors();
3493
3494 let buffer_font_size = TextSize::Small.rems(cx);
3495
3496 let mut text_style = window.text_style();
3497 let line_height = buffer_font_size * 1.75;
3498
3499 let font_family = if buffer_font {
3500 theme_settings.buffer_font.family.clone()
3501 } else {
3502 theme_settings.ui_font.family.clone()
3503 };
3504
3505 let font_size = if buffer_font {
3506 TextSize::Small.rems(cx)
3507 } else {
3508 TextSize::Default.rems(cx)
3509 };
3510
3511 text_style.refine(&TextStyleRefinement {
3512 font_family: Some(font_family),
3513 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
3514 font_features: Some(theme_settings.ui_font.features.clone()),
3515 font_size: Some(font_size.into()),
3516 line_height: Some(line_height.into()),
3517 color: Some(cx.theme().colors().text),
3518 ..Default::default()
3519 });
3520
3521 MarkdownStyle {
3522 base_text_style: text_style.clone(),
3523 syntax: cx.theme().syntax().clone(),
3524 selection_background_color: cx.theme().colors().element_selection_background,
3525 code_block_overflow_x_scroll: true,
3526 table_overflow_x_scroll: true,
3527 heading_level_styles: Some(HeadingLevelStyles {
3528 h1: Some(TextStyleRefinement {
3529 font_size: Some(rems(1.15).into()),
3530 ..Default::default()
3531 }),
3532 h2: Some(TextStyleRefinement {
3533 font_size: Some(rems(1.1).into()),
3534 ..Default::default()
3535 }),
3536 h3: Some(TextStyleRefinement {
3537 font_size: Some(rems(1.05).into()),
3538 ..Default::default()
3539 }),
3540 h4: Some(TextStyleRefinement {
3541 font_size: Some(rems(1.).into()),
3542 ..Default::default()
3543 }),
3544 h5: Some(TextStyleRefinement {
3545 font_size: Some(rems(0.95).into()),
3546 ..Default::default()
3547 }),
3548 h6: Some(TextStyleRefinement {
3549 font_size: Some(rems(0.875).into()),
3550 ..Default::default()
3551 }),
3552 }),
3553 code_block: StyleRefinement {
3554 padding: EdgesRefinement {
3555 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3556 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3557 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3558 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
3559 },
3560 margin: EdgesRefinement {
3561 top: Some(Length::Definite(Pixels(8.).into())),
3562 left: Some(Length::Definite(Pixels(0.).into())),
3563 right: Some(Length::Definite(Pixels(0.).into())),
3564 bottom: Some(Length::Definite(Pixels(12.).into())),
3565 },
3566 border_style: Some(BorderStyle::Solid),
3567 border_widths: EdgesRefinement {
3568 top: Some(AbsoluteLength::Pixels(Pixels(1.))),
3569 left: Some(AbsoluteLength::Pixels(Pixels(1.))),
3570 right: Some(AbsoluteLength::Pixels(Pixels(1.))),
3571 bottom: Some(AbsoluteLength::Pixels(Pixels(1.))),
3572 },
3573 border_color: Some(colors.border_variant),
3574 background: Some(colors.editor_background.into()),
3575 text: Some(TextStyleRefinement {
3576 font_family: Some(theme_settings.buffer_font.family.clone()),
3577 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3578 font_features: Some(theme_settings.buffer_font.features.clone()),
3579 font_size: Some(buffer_font_size.into()),
3580 ..Default::default()
3581 }),
3582 ..Default::default()
3583 },
3584 inline_code: TextStyleRefinement {
3585 font_family: Some(theme_settings.buffer_font.family.clone()),
3586 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
3587 font_features: Some(theme_settings.buffer_font.features.clone()),
3588 font_size: Some(buffer_font_size.into()),
3589 background_color: Some(colors.editor_foreground.opacity(0.08)),
3590 ..Default::default()
3591 },
3592 link: TextStyleRefinement {
3593 background_color: Some(colors.editor_foreground.opacity(0.025)),
3594 underline: Some(UnderlineStyle {
3595 color: Some(colors.text_accent.opacity(0.5)),
3596 thickness: px(1.),
3597 ..Default::default()
3598 }),
3599 ..Default::default()
3600 },
3601 ..Default::default()
3602 }
3603}
3604
3605fn plan_label_markdown_style(
3606 status: &acp::PlanEntryStatus,
3607 window: &Window,
3608 cx: &App,
3609) -> MarkdownStyle {
3610 let default_md_style = default_markdown_style(false, window, cx);
3611
3612 MarkdownStyle {
3613 base_text_style: TextStyle {
3614 color: cx.theme().colors().text_muted,
3615 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3616 Some(gpui::StrikethroughStyle {
3617 thickness: px(1.),
3618 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3619 })
3620 } else {
3621 None
3622 },
3623 ..default_md_style.base_text_style
3624 },
3625 ..default_md_style
3626 }
3627}
3628
3629fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
3630 let default_md_style = default_markdown_style(true, window, cx);
3631
3632 MarkdownStyle {
3633 base_text_style: TextStyle {
3634 ..default_md_style.base_text_style
3635 },
3636 selection_background_color: cx.theme().colors().element_selection_background,
3637 ..Default::default()
3638 }
3639}
3640
3641#[cfg(test)]
3642pub(crate) mod tests {
3643 use acp_thread::{AgentServerName, StubAgentConnection};
3644 use agent::{TextThreadStore, ThreadStore};
3645 use agent_client_protocol::SessionId;
3646 use editor::EditorSettings;
3647 use fs::FakeFs;
3648 use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext};
3649 use project::Project;
3650 use serde_json::json;
3651 use settings::SettingsStore;
3652 use std::any::Any;
3653 use std::path::Path;
3654 use workspace::Item;
3655
3656 use super::*;
3657
3658 #[gpui::test]
3659 async fn test_drop(cx: &mut TestAppContext) {
3660 init_test(cx);
3661
3662 let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3663 let weak_view = thread_view.downgrade();
3664 drop(thread_view);
3665 assert!(!weak_view.is_upgradable());
3666 }
3667
3668 #[gpui::test]
3669 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3670 init_test(cx);
3671
3672 let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3673
3674 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3675 message_editor.update_in(cx, |editor, window, cx| {
3676 editor.set_text("Hello", window, cx);
3677 });
3678
3679 cx.deactivate_window();
3680
3681 thread_view.update_in(cx, |thread_view, window, cx| {
3682 thread_view.send(window, cx);
3683 });
3684
3685 cx.run_until_parked();
3686
3687 assert!(
3688 cx.windows()
3689 .iter()
3690 .any(|window| window.downcast::<AgentNotification>().is_some())
3691 );
3692 }
3693
3694 #[gpui::test]
3695 async fn test_notification_for_error(cx: &mut TestAppContext) {
3696 init_test(cx);
3697
3698 let (thread_view, cx) =
3699 setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
3700
3701 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3702 message_editor.update_in(cx, |editor, window, cx| {
3703 editor.set_text("Hello", window, cx);
3704 });
3705
3706 cx.deactivate_window();
3707
3708 thread_view.update_in(cx, |thread_view, window, cx| {
3709 thread_view.send(window, cx);
3710 });
3711
3712 cx.run_until_parked();
3713
3714 assert!(
3715 cx.windows()
3716 .iter()
3717 .any(|window| window.downcast::<AgentNotification>().is_some())
3718 );
3719 }
3720
3721 #[gpui::test]
3722 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3723 init_test(cx);
3724
3725 let tool_call_id = acp::ToolCallId("1".into());
3726 let tool_call = acp::ToolCall {
3727 id: tool_call_id.clone(),
3728 title: "Label".into(),
3729 kind: acp::ToolKind::Edit,
3730 status: acp::ToolCallStatus::Pending,
3731 content: vec!["hi".into()],
3732 locations: vec![],
3733 raw_input: None,
3734 raw_output: None,
3735 };
3736 let connection =
3737 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3738 tool_call_id,
3739 vec![acp::PermissionOption {
3740 id: acp::PermissionOptionId("1".into()),
3741 name: "Allow".into(),
3742 kind: acp::PermissionOptionKind::AllowOnce,
3743 }],
3744 )]));
3745
3746 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3747
3748 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3749
3750 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
3751 message_editor.update_in(cx, |editor, window, cx| {
3752 editor.set_text("Hello", window, cx);
3753 });
3754
3755 cx.deactivate_window();
3756
3757 thread_view.update_in(cx, |thread_view, window, cx| {
3758 thread_view.send(window, cx);
3759 });
3760
3761 cx.run_until_parked();
3762
3763 assert!(
3764 cx.windows()
3765 .iter()
3766 .any(|window| window.downcast::<AgentNotification>().is_some())
3767 );
3768 }
3769
3770 async fn setup_thread_view(
3771 agent: impl AgentServer + 'static,
3772 cx: &mut TestAppContext,
3773 ) -> (Entity<AcpThreadView>, &mut VisualTestContext) {
3774 let fs = FakeFs::new(cx.executor());
3775 let project = Project::test(fs, [], cx).await;
3776 let (workspace, cx) =
3777 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3778
3779 let thread_store =
3780 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3781 let text_thread_store =
3782 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3783 let history_store =
3784 cx.update(|_window, cx| cx.new(|cx| agent2::HistoryStore::get_or_init(cx)));
3785
3786 let thread_view = cx.update(|window, cx| {
3787 cx.new(|cx| {
3788 AcpThreadView::new(
3789 Rc::new(agent),
3790 workspace.downgrade(),
3791 project,
3792 history_store.clone(),
3793 thread_store.clone(),
3794 text_thread_store.clone(),
3795 None,
3796 window,
3797 cx,
3798 )
3799 })
3800 });
3801 cx.run_until_parked();
3802 (thread_view, cx)
3803 }
3804
3805 fn add_to_workspace(thread_view: Entity<AcpThreadView>, cx: &mut VisualTestContext) {
3806 let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3807
3808 workspace
3809 .update_in(cx, |workspace, window, cx| {
3810 workspace.add_item_to_active_pane(
3811 Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3812 None,
3813 true,
3814 window,
3815 cx,
3816 );
3817 })
3818 .unwrap();
3819 }
3820
3821 struct ThreadViewItem(Entity<AcpThreadView>);
3822
3823 impl Item for ThreadViewItem {
3824 type Event = ();
3825
3826 fn include_in_nav_history() -> bool {
3827 false
3828 }
3829
3830 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3831 "Test".into()
3832 }
3833 }
3834
3835 impl EventEmitter<()> for ThreadViewItem {}
3836
3837 impl Focusable for ThreadViewItem {
3838 fn focus_handle(&self, cx: &App) -> FocusHandle {
3839 self.0.read(cx).focus_handle(cx).clone()
3840 }
3841 }
3842
3843 impl Render for ThreadViewItem {
3844 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3845 self.0.clone().into_any_element()
3846 }
3847 }
3848
3849 struct StubAgentServer<C> {
3850 connection: C,
3851 }
3852
3853 impl<C> StubAgentServer<C> {
3854 fn new(connection: C) -> Self {
3855 Self { connection }
3856 }
3857 }
3858
3859 impl StubAgentServer<StubAgentConnection> {
3860 fn default_response() -> Self {
3861 let conn = StubAgentConnection::new();
3862 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
3863 content: "Default response".into(),
3864 }]);
3865 Self::new(conn)
3866 }
3867 }
3868
3869 impl<C> AgentServer for StubAgentServer<C>
3870 where
3871 C: 'static + AgentConnection + Send + Clone,
3872 {
3873 fn logo(&self) -> ui::IconName {
3874 ui::IconName::Ai
3875 }
3876
3877 fn name(&self) -> AgentServerName {
3878 AgentServerName("Test".into())
3879 }
3880
3881 fn empty_state_headline(&self) -> &'static str {
3882 "Test"
3883 }
3884
3885 fn empty_state_message(&self) -> &'static str {
3886 "Test"
3887 }
3888
3889 fn connect(
3890 &self,
3891 _root_dir: &Path,
3892 _project: &Entity<Project>,
3893 _cx: &mut App,
3894 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3895 Task::ready(Ok(Rc::new(self.connection.clone())))
3896 }
3897 }
3898
3899 #[derive(Clone)]
3900 struct SaboteurAgentConnection;
3901
3902 impl AgentConnection for SaboteurAgentConnection {
3903 fn new_thread(
3904 self: Rc<Self>,
3905 project: Entity<Project>,
3906 _cwd: &Path,
3907 cx: &mut gpui::App,
3908 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3909 Task::ready(Ok(cx.new(|cx| {
3910 AcpThread::new(
3911 "SaboteurAgentConnection",
3912 self,
3913 project,
3914 SessionId("test".into()),
3915 cx,
3916 )
3917 })))
3918 }
3919
3920 fn auth_methods(&self) -> &[acp::AuthMethod] {
3921 &[]
3922 }
3923
3924 fn authenticate(
3925 &self,
3926 _method_id: acp::AuthMethodId,
3927 _cx: &mut App,
3928 ) -> Task<gpui::Result<()>> {
3929 unimplemented!()
3930 }
3931
3932 fn prompt(
3933 &self,
3934 _id: Option<acp_thread::UserMessageId>,
3935 _params: acp::PromptRequest,
3936 _cx: &mut App,
3937 ) -> Task<gpui::Result<acp::PromptResponse>> {
3938 Task::ready(Err(anyhow::anyhow!("Error prompting")))
3939 }
3940
3941 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3942 unimplemented!()
3943 }
3944
3945 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3946 self
3947 }
3948 }
3949
3950 pub(crate) fn init_test(cx: &mut TestAppContext) {
3951 cx.update(|cx| {
3952 let settings_store = SettingsStore::test(cx);
3953 cx.set_global(settings_store);
3954 language::init(cx);
3955 Project::init_settings(cx);
3956 AgentSettings::register(cx);
3957 workspace::init_settings(cx);
3958 ThemeSettings::register(cx);
3959 release_channel::init(SemanticVersion::default(), cx);
3960 EditorSettings::register(cx);
3961 });
3962 }
3963
3964 #[gpui::test]
3965 async fn test_rewind_views(cx: &mut TestAppContext) {
3966 init_test(cx);
3967
3968 let fs = FakeFs::new(cx.executor());
3969 fs.insert_tree(
3970 "/project",
3971 json!({
3972 "test1.txt": "old content 1",
3973 "test2.txt": "old content 2"
3974 }),
3975 )
3976 .await;
3977 let project = Project::test(fs, [Path::new("/project")], cx).await;
3978 let (workspace, cx) =
3979 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
3980
3981 let thread_store =
3982 cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
3983 let text_thread_store =
3984 cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
3985 let history_store =
3986 cx.update(|_window, cx| cx.new(|cx| agent2::HistoryStore::get_or_init(cx)));
3987
3988 let connection = Rc::new(StubAgentConnection::new());
3989 let thread_view = cx.update(|window, cx| {
3990 cx.new(|cx| {
3991 AcpThreadView::new(
3992 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
3993 workspace.downgrade(),
3994 project.clone(),
3995 history_store,
3996 thread_store.clone(),
3997 text_thread_store.clone(),
3998 None,
3999 window,
4000 cx,
4001 )
4002 })
4003 });
4004
4005 cx.run_until_parked();
4006
4007 let thread = thread_view
4008 .read_with(cx, |view, _| view.thread().cloned())
4009 .unwrap();
4010
4011 // First user message
4012 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4013 id: acp::ToolCallId("tool1".into()),
4014 title: "Edit file 1".into(),
4015 kind: acp::ToolKind::Edit,
4016 status: acp::ToolCallStatus::Completed,
4017 content: vec![acp::ToolCallContent::Diff {
4018 diff: acp::Diff {
4019 path: "/project/test1.txt".into(),
4020 old_text: Some("old content 1".into()),
4021 new_text: "new content 1".into(),
4022 },
4023 }],
4024 locations: vec![],
4025 raw_input: None,
4026 raw_output: None,
4027 })]);
4028
4029 thread
4030 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4031 .await
4032 .unwrap();
4033 cx.run_until_parked();
4034
4035 thread.read_with(cx, |thread, _| {
4036 assert_eq!(thread.entries().len(), 2);
4037 });
4038
4039 thread_view.read_with(cx, |view, cx| {
4040 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4041 assert!(
4042 entry_view_state
4043 .entry(0)
4044 .unwrap()
4045 .message_editor()
4046 .is_some()
4047 );
4048 assert!(entry_view_state.entry(1).unwrap().has_content());
4049 });
4050 });
4051
4052 // Second user message
4053 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall {
4054 id: acp::ToolCallId("tool2".into()),
4055 title: "Edit file 2".into(),
4056 kind: acp::ToolKind::Edit,
4057 status: acp::ToolCallStatus::Completed,
4058 content: vec![acp::ToolCallContent::Diff {
4059 diff: acp::Diff {
4060 path: "/project/test2.txt".into(),
4061 old_text: Some("old content 2".into()),
4062 new_text: "new content 2".into(),
4063 },
4064 }],
4065 locations: vec![],
4066 raw_input: None,
4067 raw_output: None,
4068 })]);
4069
4070 thread
4071 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4072 .await
4073 .unwrap();
4074 cx.run_until_parked();
4075
4076 let second_user_message_id = thread.read_with(cx, |thread, _| {
4077 assert_eq!(thread.entries().len(), 4);
4078 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4079 panic!();
4080 };
4081 user_message.id.clone().unwrap()
4082 });
4083
4084 thread_view.read_with(cx, |view, cx| {
4085 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4086 assert!(
4087 entry_view_state
4088 .entry(0)
4089 .unwrap()
4090 .message_editor()
4091 .is_some()
4092 );
4093 assert!(entry_view_state.entry(1).unwrap().has_content());
4094 assert!(
4095 entry_view_state
4096 .entry(2)
4097 .unwrap()
4098 .message_editor()
4099 .is_some()
4100 );
4101 assert!(entry_view_state.entry(3).unwrap().has_content());
4102 });
4103 });
4104
4105 // Rewind to first message
4106 thread
4107 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4108 .await
4109 .unwrap();
4110
4111 cx.run_until_parked();
4112
4113 thread.read_with(cx, |thread, _| {
4114 assert_eq!(thread.entries().len(), 2);
4115 });
4116
4117 thread_view.read_with(cx, |view, cx| {
4118 view.entry_view_state.read_with(cx, |entry_view_state, _| {
4119 assert!(
4120 entry_view_state
4121 .entry(0)
4122 .unwrap()
4123 .message_editor()
4124 .is_some()
4125 );
4126 assert!(entry_view_state.entry(1).unwrap().has_content());
4127
4128 // Old views should be dropped
4129 assert!(entry_view_state.entry(2).is_none());
4130 assert!(entry_view_state.entry(3).is_none());
4131 });
4132 });
4133 }
4134
4135 #[gpui::test]
4136 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4137 init_test(cx);
4138
4139 let connection = StubAgentConnection::new();
4140
4141 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4142 content: acp::ContentBlock::Text(acp::TextContent {
4143 text: "Response".into(),
4144 annotations: None,
4145 }),
4146 }]);
4147
4148 let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4149 add_to_workspace(thread_view.clone(), cx);
4150
4151 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4152 message_editor.update_in(cx, |editor, window, cx| {
4153 editor.set_text("Original message to edit", window, cx);
4154 });
4155 thread_view.update_in(cx, |thread_view, window, cx| {
4156 thread_view.send(window, cx);
4157 });
4158
4159 cx.run_until_parked();
4160
4161 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4162 assert_eq!(view.editing_message, None);
4163
4164 view.entry_view_state
4165 .read(cx)
4166 .entry(0)
4167 .unwrap()
4168 .message_editor()
4169 .unwrap()
4170 .clone()
4171 });
4172
4173 // Focus
4174 cx.focus(&user_message_editor);
4175 thread_view.read_with(cx, |view, _cx| {
4176 assert_eq!(view.editing_message, Some(0));
4177 });
4178
4179 // Edit
4180 user_message_editor.update_in(cx, |editor, window, cx| {
4181 editor.set_text("Edited message content", window, cx);
4182 });
4183
4184 // Cancel
4185 user_message_editor.update_in(cx, |_editor, window, cx| {
4186 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4187 });
4188
4189 thread_view.read_with(cx, |view, _cx| {
4190 assert_eq!(view.editing_message, None);
4191 });
4192
4193 user_message_editor.read_with(cx, |editor, cx| {
4194 assert_eq!(editor.text(cx), "Original message to edit");
4195 });
4196 }
4197
4198 #[gpui::test]
4199 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4200 init_test(cx);
4201
4202 let connection = StubAgentConnection::new();
4203
4204 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4205 content: acp::ContentBlock::Text(acp::TextContent {
4206 text: "Response".into(),
4207 annotations: None,
4208 }),
4209 }]);
4210
4211 let (thread_view, cx) =
4212 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4213 add_to_workspace(thread_view.clone(), cx);
4214
4215 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4216 message_editor.update_in(cx, |editor, window, cx| {
4217 editor.set_text("Original message to edit", window, cx);
4218 });
4219 thread_view.update_in(cx, |thread_view, window, cx| {
4220 thread_view.send(window, cx);
4221 });
4222
4223 cx.run_until_parked();
4224
4225 let user_message_editor = thread_view.read_with(cx, |view, cx| {
4226 assert_eq!(view.editing_message, None);
4227 assert_eq!(view.thread().unwrap().read(cx).entries().len(), 2);
4228
4229 view.entry_view_state
4230 .read(cx)
4231 .entry(0)
4232 .unwrap()
4233 .message_editor()
4234 .unwrap()
4235 .clone()
4236 });
4237
4238 // Focus
4239 cx.focus(&user_message_editor);
4240
4241 // Edit
4242 user_message_editor.update_in(cx, |editor, window, cx| {
4243 editor.set_text("Edited message content", window, cx);
4244 });
4245
4246 // Send
4247 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
4248 content: acp::ContentBlock::Text(acp::TextContent {
4249 text: "New Response".into(),
4250 annotations: None,
4251 }),
4252 }]);
4253
4254 user_message_editor.update_in(cx, |_editor, window, cx| {
4255 window.dispatch_action(Box::new(Chat), cx);
4256 });
4257
4258 cx.run_until_parked();
4259
4260 thread_view.read_with(cx, |view, cx| {
4261 assert_eq!(view.editing_message, None);
4262
4263 let entries = view.thread().unwrap().read(cx).entries();
4264 assert_eq!(entries.len(), 2);
4265 assert_eq!(
4266 entries[0].to_markdown(cx),
4267 "## User\n\nEdited message content\n\n"
4268 );
4269 assert_eq!(
4270 entries[1].to_markdown(cx),
4271 "## Assistant\n\nNew Response\n\n"
4272 );
4273
4274 let new_editor = view.entry_view_state.read_with(cx, |state, _cx| {
4275 assert!(!state.entry(1).unwrap().has_content());
4276 state.entry(0).unwrap().message_editor().unwrap().clone()
4277 });
4278
4279 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4280 })
4281 }
4282
4283 #[gpui::test]
4284 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4285 init_test(cx);
4286
4287 let connection = StubAgentConnection::new();
4288
4289 let (thread_view, cx) =
4290 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4291 add_to_workspace(thread_view.clone(), cx);
4292
4293 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4294 message_editor.update_in(cx, |editor, window, cx| {
4295 editor.set_text("Original message to edit", window, cx);
4296 });
4297 thread_view.update_in(cx, |thread_view, window, cx| {
4298 thread_view.send(window, cx);
4299 });
4300
4301 cx.run_until_parked();
4302
4303 let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4304 let thread = view.thread().unwrap().read(cx);
4305 assert_eq!(thread.entries().len(), 1);
4306
4307 let editor = view
4308 .entry_view_state
4309 .read(cx)
4310 .entry(0)
4311 .unwrap()
4312 .message_editor()
4313 .unwrap()
4314 .clone();
4315
4316 (editor, thread.session_id().clone())
4317 });
4318
4319 // Focus
4320 cx.focus(&user_message_editor);
4321
4322 thread_view.read_with(cx, |view, _cx| {
4323 assert_eq!(view.editing_message, Some(0));
4324 });
4325
4326 // Edit
4327 user_message_editor.update_in(cx, |editor, window, cx| {
4328 editor.set_text("Edited message content", window, cx);
4329 });
4330
4331 thread_view.read_with(cx, |view, _cx| {
4332 assert_eq!(view.editing_message, Some(0));
4333 });
4334
4335 // Finish streaming response
4336 cx.update(|_, cx| {
4337 connection.send_update(
4338 session_id.clone(),
4339 acp::SessionUpdate::AgentMessageChunk {
4340 content: acp::ContentBlock::Text(acp::TextContent {
4341 text: "Response".into(),
4342 annotations: None,
4343 }),
4344 },
4345 cx,
4346 );
4347 connection.end_turn(session_id, acp::StopReason::EndTurn);
4348 });
4349
4350 thread_view.read_with(cx, |view, _cx| {
4351 assert_eq!(view.editing_message, Some(0));
4352 });
4353
4354 cx.run_until_parked();
4355
4356 // Should still be editing
4357 cx.update(|window, cx| {
4358 assert!(user_message_editor.focus_handle(cx).is_focused(window));
4359 assert_eq!(thread_view.read(cx).editing_message, Some(0));
4360 assert_eq!(
4361 user_message_editor.read(cx).text(cx),
4362 "Edited message content"
4363 );
4364 });
4365 }
4366
4367 #[gpui::test]
4368 async fn test_interrupt(cx: &mut TestAppContext) {
4369 init_test(cx);
4370
4371 let connection = StubAgentConnection::new();
4372
4373 let (thread_view, cx) =
4374 setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4375 add_to_workspace(thread_view.clone(), cx);
4376
4377 let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
4378 message_editor.update_in(cx, |editor, window, cx| {
4379 editor.set_text("Message 1", window, cx);
4380 });
4381 thread_view.update_in(cx, |thread_view, window, cx| {
4382 thread_view.send(window, cx);
4383 });
4384
4385 let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4386 let thread = view.thread().unwrap();
4387
4388 (thread.clone(), thread.read(cx).session_id().clone())
4389 });
4390
4391 cx.run_until_parked();
4392
4393 cx.update(|_, cx| {
4394 connection.send_update(
4395 session_id.clone(),
4396 acp::SessionUpdate::AgentMessageChunk {
4397 content: "Message 1 resp".into(),
4398 },
4399 cx,
4400 );
4401 });
4402
4403 cx.run_until_parked();
4404
4405 thread.read_with(cx, |thread, cx| {
4406 assert_eq!(
4407 thread.to_markdown(cx),
4408 indoc::indoc! {"
4409 ## User
4410
4411 Message 1
4412
4413 ## Assistant
4414
4415 Message 1 resp
4416
4417 "}
4418 )
4419 });
4420
4421 message_editor.update_in(cx, |editor, window, cx| {
4422 editor.set_text("Message 2", window, cx);
4423 });
4424 thread_view.update_in(cx, |thread_view, window, cx| {
4425 thread_view.send(window, cx);
4426 });
4427
4428 cx.update(|_, cx| {
4429 // Simulate a response sent after beginning to cancel
4430 connection.send_update(
4431 session_id.clone(),
4432 acp::SessionUpdate::AgentMessageChunk {
4433 content: "onse".into(),
4434 },
4435 cx,
4436 );
4437 });
4438
4439 cx.run_until_parked();
4440
4441 // Last Message 1 response should appear before Message 2
4442 thread.read_with(cx, |thread, cx| {
4443 assert_eq!(
4444 thread.to_markdown(cx),
4445 indoc::indoc! {"
4446 ## User
4447
4448 Message 1
4449
4450 ## Assistant
4451
4452 Message 1 response
4453
4454 ## User
4455
4456 Message 2
4457
4458 "}
4459 )
4460 });
4461
4462 cx.update(|_, cx| {
4463 connection.send_update(
4464 session_id.clone(),
4465 acp::SessionUpdate::AgentMessageChunk {
4466 content: "Message 2 response".into(),
4467 },
4468 cx,
4469 );
4470 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4471 });
4472
4473 cx.run_until_parked();
4474
4475 thread.read_with(cx, |thread, cx| {
4476 assert_eq!(
4477 thread.to_markdown(cx),
4478 indoc::indoc! {"
4479 ## User
4480
4481 Message 1
4482
4483 ## Assistant
4484
4485 Message 1 response
4486
4487 ## User
4488
4489 Message 2
4490
4491 ## Assistant
4492
4493 Message 2 response
4494
4495 "}
4496 )
4497 });
4498 }
4499}