thread_view.rs

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