1use anyhow::Result;
2use editor::{Editor, MultiBuffer};
3use gpui::{
4 App, Empty, Entity, Focusable, ListState, SharedString, Subscription, Window, div, list,
5 prelude::*,
6};
7use gpui::{FocusHandle, Task};
8use language::Buffer;
9use ui::Tooltip;
10use ui::prelude::*;
11use zed_actions::agent::Chat;
12
13use crate::{
14 AcpThread, AcpThreadEvent, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry,
15};
16
17pub struct AcpThreadView {
18 thread: Entity<AcpThread>,
19 // todo! use full message editor from agent2
20 message_editor: Entity<Editor>,
21 list_state: ListState,
22 send_task: Option<Task<Result<()>>>,
23 _subscription: Subscription,
24}
25
26impl AcpThreadView {
27 pub fn new(thread: Entity<AcpThread>, window: &mut Window, cx: &mut Context<Self>) -> Self {
28 let message_editor = cx.new(|cx| {
29 let buffer = cx.new(|cx| Buffer::local("", cx));
30 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
31
32 let mut editor = Editor::new(
33 editor::EditorMode::AutoHeight {
34 min_lines: 5,
35 max_lines: None,
36 },
37 buffer,
38 None,
39 window,
40 cx,
41 );
42 editor.set_placeholder_text("Send a message", cx);
43 editor.set_soft_wrap();
44 editor
45 });
46
47 let subscription = cx.subscribe(&thread, |this, _, event, cx| {
48 let count = this.list_state.item_count();
49 match event {
50 AcpThreadEvent::NewEntry => {
51 this.list_state.splice(count..count, 1);
52 }
53 AcpThreadEvent::LastEntryUpdated => {
54 this.list_state.splice(count - 1..count, 1);
55 }
56 }
57 cx.notify();
58 });
59
60 let list_state = ListState::new(
61 thread.read(cx).entries.len(),
62 gpui::ListAlignment::Top,
63 px(1000.0),
64 cx.processor({
65 move |this: &mut Self, item: usize, window, cx| {
66 let Some(entry) = this.thread.read(cx).entries.get(item) else {
67 return Empty.into_any();
68 };
69 this.render_entry(entry, window, cx)
70 }
71 }),
72 );
73 Self {
74 thread,
75 message_editor,
76 send_task: None,
77 list_state: list_state,
78 _subscription: subscription,
79 }
80 }
81
82 pub fn title(&self, cx: &App) -> SharedString {
83 self.thread.read(cx).title()
84 }
85
86 pub fn cancel(&mut self) {
87 self.send_task.take();
88 }
89
90 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
91 let text = self.message_editor.read(cx).text(cx);
92 if text.is_empty() {
93 return;
94 }
95
96 let task = self.thread.update(cx, |thread, cx| {
97 let message = Message {
98 role: Role::User,
99 chunks: vec![MessageChunk::Text { chunk: text.into() }],
100 };
101 thread.send(message, cx)
102 });
103
104 self.send_task = Some(cx.spawn(async move |this, cx| {
105 task.await?;
106
107 this.update(cx, |this, _cx| {
108 this.send_task.take();
109 })
110 }));
111
112 self.message_editor.update(cx, |editor, cx| {
113 editor.clear(window, cx);
114 });
115 }
116
117 fn render_entry(
118 &self,
119 entry: &ThreadEntry,
120 _window: &mut Window,
121 cx: &Context<Self>,
122 ) -> AnyElement {
123 match &entry.content {
124 AgentThreadEntryContent::Message(message) => {
125 let message_body = div()
126 .children(message.chunks.iter().map(|chunk| match chunk {
127 MessageChunk::Text { chunk } => {
128 // todo! markdown
129 Label::new(chunk.clone())
130 }
131 _ => todo!(),
132 }))
133 .into_any();
134
135 match message.role {
136 Role::User => div()
137 .my_1()
138 .p_2()
139 .bg(cx.theme().colors().editor_background)
140 .rounded_lg()
141 .shadow_md()
142 .border_1()
143 .border_color(cx.theme().colors().border)
144 .child(message_body)
145 .into_any(),
146 Role::Assistant => message_body,
147 }
148 }
149 AgentThreadEntryContent::ReadFile { path, content: _ } => {
150 // todo!
151 div()
152 .child(format!("<Reading file {}>", path.display()))
153 .into_any()
154 }
155 }
156 }
157}
158
159impl Focusable for AcpThreadView {
160 fn focus_handle(&self, cx: &App) -> FocusHandle {
161 self.message_editor.focus_handle(cx)
162 }
163}
164
165impl Render for AcpThreadView {
166 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
167 let text = self.message_editor.read(cx).text(cx);
168 let is_editor_empty = text.is_empty();
169 let focus_handle = self.message_editor.focus_handle(cx);
170
171 v_flex()
172 .key_context("MessageEditor")
173 .on_action(cx.listener(Self::chat))
174 .child(
175 div()
176 .child(
177 list(self.list_state.clone())
178 .with_sizing_behavior(gpui::ListSizingBehavior::Infer),
179 )
180 .p_2(),
181 )
182 .when(self.send_task.is_some(), |this| {
183 this.child(
184 div().p_2().child(
185 Label::new("Generating...")
186 .color(Color::Muted)
187 .size(LabelSize::Small),
188 ),
189 )
190 })
191 .child(
192 div()
193 .bg(cx.theme().colors().editor_background)
194 .border_t_1()
195 .border_color(cx.theme().colors().border)
196 .p_2()
197 .child(self.message_editor.clone()),
198 )
199 .child(
200 h_flex()
201 .p_2()
202 .justify_end()
203 .child(if self.send_task.is_some() {
204 IconButton::new("stop-generation", IconName::StopFilled)
205 .icon_color(Color::Error)
206 .style(ButtonStyle::Tinted(ui::TintColor::Error))
207 .tooltip(move |window, cx| {
208 Tooltip::for_action(
209 "Stop Generation",
210 &editor::actions::Cancel,
211 window,
212 cx,
213 )
214 })
215 .disabled(is_editor_empty)
216 .on_click(cx.listener(|this, _event, _, _| this.cancel()))
217 } else {
218 IconButton::new("send-message", IconName::Send)
219 .icon_color(Color::Accent)
220 .style(ButtonStyle::Filled)
221 .disabled(is_editor_empty)
222 .on_click({
223 let focus_handle = focus_handle.clone();
224 move |_event, window, cx| {
225 focus_handle.dispatch_action(&Chat, window, cx);
226 }
227 })
228 .when(!is_editor_empty, |button| {
229 button.tooltip(move |window, cx| {
230 Tooltip::for_action("Send", &Chat, window, cx)
231 })
232 })
233 .when(is_editor_empty, |button| {
234 button.tooltip(Tooltip::text("Type a message to submit"))
235 })
236 }),
237 )
238 }
239}