thread_view.rs

  1use std::rc::Rc;
  2
  3use anyhow::Result;
  4use editor::{Editor, MultiBuffer};
  5use gpui::{
  6    App, EdgesRefinement, Empty, Entity, Focusable, ListState, SharedString, StyleRefinement,
  7    Subscription, TextStyleRefinement, UnderlineStyle, Window, div, list, prelude::*,
  8};
  9use gpui::{FocusHandle, Task};
 10use language::Buffer;
 11use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle};
 12use settings::Settings as _;
 13use theme::ThemeSettings;
 14use ui::Tooltip;
 15use ui::prelude::*;
 16use zed_actions::agent::Chat;
 17
 18use crate::{AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry};
 19
 20pub struct AcpThreadView {
 21    thread: Entity<AcpThread>,
 22    // todo! use full message editor from agent2
 23    message_editor: Entity<Editor>,
 24    list_state: ListState,
 25    send_task: Option<Task<Result<()>>>,
 26    _subscription: Subscription,
 27}
 28
 29impl AcpThreadView {
 30    pub fn new(thread: Entity<AcpThread>, window: &mut Window, cx: &mut Context<Self>) -> Self {
 31        let message_editor = cx.new(|cx| {
 32            let buffer = cx.new(|cx| Buffer::local("", cx));
 33            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 34
 35            let mut editor = Editor::new(
 36                editor::EditorMode::AutoHeight {
 37                    min_lines: 5,
 38                    max_lines: None,
 39                },
 40                buffer,
 41                None,
 42                window,
 43                cx,
 44            );
 45            editor.set_placeholder_text("Send a message", cx);
 46            editor.set_soft_wrap();
 47            editor
 48        });
 49
 50        let subscription = cx.subscribe(&thread, |this, _, event, cx| {
 51            let count = this.list_state.item_count();
 52            match event {
 53                AcpThreadEvent::NewEntry => {
 54                    this.list_state.splice(count..count, 1);
 55                }
 56                AcpThreadEvent::LastEntryUpdated => {
 57                    this.list_state.splice(count - 1..count, 1);
 58                }
 59            }
 60            cx.notify();
 61        });
 62
 63        let list_state = ListState::new(
 64            thread.read(cx).entries.len(),
 65            gpui::ListAlignment::Top,
 66            px(1000.0),
 67            cx.processor({
 68                move |this: &mut Self, item: usize, window, cx| {
 69                    let Some(entry) = this.thread.read(cx).entries.get(item) else {
 70                        return Empty.into_any();
 71                    };
 72                    this.render_entry(entry, window, cx)
 73                }
 74            }),
 75        );
 76        Self {
 77            thread,
 78            message_editor,
 79            send_task: None,
 80            list_state: list_state,
 81            _subscription: subscription,
 82        }
 83    }
 84
 85    pub fn title(&self, cx: &App) -> SharedString {
 86        self.thread.read(cx).title()
 87    }
 88
 89    pub fn cancel(&mut self) {
 90        self.send_task.take();
 91    }
 92
 93    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
 94        let text = self.message_editor.read(cx).text(cx);
 95        if text.is_empty() {
 96            return;
 97        }
 98
 99        let task = self.thread.update(cx, |thread, cx| thread.send(&text, cx));
100
101        self.send_task = Some(cx.spawn(async move |this, cx| {
102            task.await?;
103
104            this.update(cx, |this, _cx| {
105                this.send_task.take();
106            })
107        }));
108
109        self.message_editor.update(cx, |editor, cx| {
110            editor.clear(window, cx);
111        });
112    }
113
114    fn render_entry(
115        &self,
116        entry: &ThreadEntry,
117        window: &mut Window,
118        cx: &Context<Self>,
119    ) -> AnyElement {
120        match &entry.content {
121            AgentThreadEntryContent::Message(message) => {
122                let style = if message.role == Role::User {
123                    user_message_markdown_style(window, cx)
124                } else {
125                    default_markdown_style(window, cx)
126                };
127                let message_body = div()
128                    .children(message.chunks.iter().map(|chunk| match chunk {
129                        MessageChunk::Text { chunk } => {
130                            // todo!() open link
131                            MarkdownElement::new(chunk.clone(), style.clone())
132                        }
133                        _ => todo!(),
134                    }))
135                    .into_any();
136
137                match message.role {
138                    Role::User => div()
139                        .text_xs()
140                        .m_1()
141                        .p_2()
142                        .bg(cx.theme().colors().editor_background)
143                        .rounded_lg()
144                        .shadow_md()
145                        .border_1()
146                        .border_color(cx.theme().colors().border)
147                        .child(message_body)
148                        .into_any(),
149                    Role::Assistant => div()
150                        .text_ui(cx)
151                        .px_2()
152                        .py_4()
153                        .child(message_body)
154                        .into_any(),
155                }
156            }
157            AgentThreadEntryContent::ReadFile { path, content: _ } => {
158                // todo!
159                div()
160                    .child(format!("<Reading file {}>", path.display()))
161                    .into_any()
162            }
163        }
164    }
165}
166
167impl Focusable for AcpThreadView {
168    fn focus_handle(&self, cx: &App) -> FocusHandle {
169        self.message_editor.focus_handle(cx)
170    }
171}
172
173impl Render for AcpThreadView {
174    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
175        let text = self.message_editor.read(cx).text(cx);
176        let is_editor_empty = text.is_empty();
177        let focus_handle = self.message_editor.focus_handle(cx);
178
179        v_flex()
180            .key_context("MessageEditor")
181            .on_action(cx.listener(Self::chat))
182            .child(
183                div()
184                    .child(
185                        list(self.list_state.clone())
186                            .with_sizing_behavior(gpui::ListSizingBehavior::Infer),
187                    )
188                    .p_2(),
189            )
190            .when(self.send_task.is_some(), |this| {
191                this.child(
192                    div().p_2().child(
193                        Label::new("Generating...")
194                            .color(Color::Muted)
195                            .size(LabelSize::Small),
196                    ),
197                )
198            })
199            .child(
200                div()
201                    .bg(cx.theme().colors().editor_background)
202                    .border_t_1()
203                    .border_color(cx.theme().colors().border)
204                    .p_2()
205                    .child(self.message_editor.clone()),
206            )
207            .child(
208                h_flex()
209                    .p_2()
210                    .justify_end()
211                    .child(if self.send_task.is_some() {
212                        IconButton::new("stop-generation", IconName::StopFilled)
213                            .icon_color(Color::Error)
214                            .style(ButtonStyle::Tinted(ui::TintColor::Error))
215                            .tooltip(move |window, cx| {
216                                Tooltip::for_action(
217                                    "Stop Generation",
218                                    &editor::actions::Cancel,
219                                    window,
220                                    cx,
221                                )
222                            })
223                            .disabled(is_editor_empty)
224                            .on_click(cx.listener(|this, _event, _, _| this.cancel()))
225                    } else {
226                        IconButton::new("send-message", IconName::Send)
227                            .icon_color(Color::Accent)
228                            .style(ButtonStyle::Filled)
229                            .disabled(is_editor_empty)
230                            .on_click({
231                                let focus_handle = focus_handle.clone();
232                                move |_event, window, cx| {
233                                    focus_handle.dispatch_action(&Chat, window, cx);
234                                }
235                            })
236                            .when(!is_editor_empty, |button| {
237                                button.tooltip(move |window, cx| {
238                                    Tooltip::for_action("Send", &Chat, window, cx)
239                                })
240                            })
241                            .when(is_editor_empty, |button| {
242                                button.tooltip(Tooltip::text("Type a message to submit"))
243                            })
244                    }),
245            )
246    }
247}
248
249fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
250    let mut style = default_markdown_style(window, cx);
251    let mut text_style = window.text_style();
252    let theme_settings = ThemeSettings::get_global(cx);
253
254    let buffer_font = theme_settings.buffer_font.family.clone();
255    let buffer_font_size = TextSize::Small.rems(cx);
256
257    text_style.refine(&TextStyleRefinement {
258        font_family: Some(buffer_font),
259        font_size: Some(buffer_font_size.into()),
260        ..Default::default()
261    });
262
263    style.base_text_style = text_style;
264    style
265}
266
267fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
268    let theme_settings = ThemeSettings::get_global(cx);
269    let colors = cx.theme().colors();
270    let ui_font_size = TextSize::Default.rems(cx);
271    let buffer_font_size = TextSize::Small.rems(cx);
272    let mut text_style = window.text_style();
273    let line_height = buffer_font_size * 1.75;
274
275    text_style.refine(&TextStyleRefinement {
276        font_family: Some(theme_settings.ui_font.family.clone()),
277        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
278        font_features: Some(theme_settings.ui_font.features.clone()),
279        font_size: Some(ui_font_size.into()),
280        line_height: Some(line_height.into()),
281        color: Some(cx.theme().colors().text),
282        ..Default::default()
283    });
284
285    MarkdownStyle {
286        base_text_style: text_style.clone(),
287        syntax: cx.theme().syntax().clone(),
288        selection_background_color: cx.theme().colors().element_selection_background,
289        code_block_overflow_x_scroll: true,
290        table_overflow_x_scroll: true,
291        heading_level_styles: Some(HeadingLevelStyles {
292            h1: Some(TextStyleRefinement {
293                font_size: Some(rems(1.15).into()),
294                ..Default::default()
295            }),
296            h2: Some(TextStyleRefinement {
297                font_size: Some(rems(1.1).into()),
298                ..Default::default()
299            }),
300            h3: Some(TextStyleRefinement {
301                font_size: Some(rems(1.05).into()),
302                ..Default::default()
303            }),
304            h4: Some(TextStyleRefinement {
305                font_size: Some(rems(1.).into()),
306                ..Default::default()
307            }),
308            h5: Some(TextStyleRefinement {
309                font_size: Some(rems(0.95).into()),
310                ..Default::default()
311            }),
312            h6: Some(TextStyleRefinement {
313                font_size: Some(rems(0.875).into()),
314                ..Default::default()
315            }),
316        }),
317        code_block: StyleRefinement {
318            padding: EdgesRefinement {
319                top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
320                left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
321                right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
322                bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
323            },
324            background: Some(colors.editor_background.into()),
325            text: Some(TextStyleRefinement {
326                font_family: Some(theme_settings.buffer_font.family.clone()),
327                font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
328                font_features: Some(theme_settings.buffer_font.features.clone()),
329                font_size: Some(buffer_font_size.into()),
330                ..Default::default()
331            }),
332            ..Default::default()
333        },
334        inline_code: TextStyleRefinement {
335            font_family: Some(theme_settings.buffer_font.family.clone()),
336            font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
337            font_features: Some(theme_settings.buffer_font.features.clone()),
338            font_size: Some(buffer_font_size.into()),
339            background_color: Some(colors.editor_foreground.opacity(0.08)),
340            ..Default::default()
341        },
342        link: TextStyleRefinement {
343            background_color: Some(colors.editor_foreground.opacity(0.025)),
344            underline: Some(UnderlineStyle {
345                color: Some(colors.text_accent.opacity(0.5)),
346                thickness: px(1.),
347                ..Default::default()
348            }),
349            ..Default::default()
350        },
351        link_callback: Some(Rc::new(move |_url, _cx| {
352            // todo!()
353            // if MentionLink::is_valid(url) {
354            //     let colors = cx.theme().colors();
355            //     Some(TextStyleRefinement {
356            //         background_color: Some(colors.element_background),
357            //         ..Default::default()
358            //     })
359            // } else {
360            None
361            // }
362        })),
363        ..Default::default()
364    }
365}