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