thread_view.rs

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