thread_view.rs

   1use std::path::Path;
   2use std::rc::Rc;
   3use std::sync::Arc;
   4use std::time::Duration;
   5
   6use agentic_coding_protocol::{self as acp};
   7use editor::{Editor, EditorMode, MinimapVisibility, 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 language::language_settings::SoftWrap;
  16use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
  17use project::Project;
  18use settings::Settings as _;
  19use theme::ThemeSettings;
  20use ui::prelude::*;
  21use ui::{Button, Tooltip};
  22use util::{ResultExt, paths};
  23use zed_actions::agent::Chat;
  24
  25use crate::{
  26    AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, AssistantMessage,
  27    AssistantMessageChunk, Diff, ThreadEntry, ThreadStatus, ToolCall, ToolCallConfirmation,
  28    ToolCallContent, ToolCallId, ToolCallStatus, UserMessageChunk,
  29};
  30
  31pub struct AcpThreadView {
  32    agent: Arc<AcpServer>,
  33    thread_state: ThreadState,
  34    // todo! reconsider structure. currently pretty sparse, but easy to clean up if we need to delete entries.
  35    thread_entry_views: Vec<Option<ThreadEntryView>>,
  36    message_editor: Entity<Editor>,
  37    last_error: Option<Entity<Markdown>>,
  38    list_state: ListState,
  39    auth_task: Option<Task<()>>,
  40}
  41
  42#[derive(Debug)]
  43enum ThreadEntryView {
  44    Diff { editor: Entity<Editor> },
  45}
  46
  47enum ThreadState {
  48    Loading {
  49        _task: Task<()>,
  50    },
  51    Ready {
  52        thread: Entity<AcpThread>,
  53        _subscription: Subscription,
  54    },
  55    LoadError(SharedString),
  56    Unauthenticated,
  57}
  58
  59impl AcpThreadView {
  60    pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
  61        let message_editor = cx.new(|cx| {
  62            let buffer = cx.new(|cx| Buffer::local("", cx));
  63            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
  64
  65            let mut editor = Editor::new(
  66                editor::EditorMode::AutoHeight {
  67                    min_lines: 4,
  68                    max_lines: None,
  69                },
  70                buffer,
  71                None,
  72                window,
  73                cx,
  74            );
  75            editor.set_placeholder_text("Send a message", cx);
  76            editor.set_soft_wrap();
  77            editor
  78        });
  79
  80        let list_state = ListState::new(
  81            0,
  82            gpui::ListAlignment::Bottom,
  83            px(2048.0),
  84            cx.processor({
  85                move |this: &mut Self, item: usize, window, cx| {
  86                    let Some(entry) = this
  87                        .thread()
  88                        .and_then(|thread| thread.read(cx).entries.get(item))
  89                    else {
  90                        return Empty.into_any();
  91                    };
  92                    this.render_entry(item, entry, window, cx)
  93                }
  94            }),
  95        );
  96
  97        let root_dir = project
  98            .read(cx)
  99            .visible_worktrees(cx)
 100            .next()
 101            .map(|worktree| worktree.read(cx).abs_path())
 102            .unwrap_or_else(|| paths::home_dir().as_path().into());
 103
 104        let cli_path =
 105            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
 106
 107        let child = util::command::new_smol_command("node")
 108            .arg(cli_path)
 109            .arg("--acp")
 110            .current_dir(root_dir)
 111            .stdin(std::process::Stdio::piped())
 112            .stdout(std::process::Stdio::piped())
 113            .stderr(std::process::Stdio::inherit())
 114            .kill_on_drop(true)
 115            .spawn()
 116            .unwrap();
 117
 118        let agent = AcpServer::stdio(child, project, cx);
 119
 120        Self {
 121            thread_state: Self::initial_state(agent.clone(), window, cx),
 122            agent,
 123            message_editor,
 124            thread_entry_views: Vec::new(),
 125            list_state: list_state,
 126            last_error: None,
 127            auth_task: None,
 128        }
 129    }
 130
 131    fn initial_state(
 132        agent: Arc<AcpServer>,
 133        window: &mut Window,
 134        cx: &mut Context<Self>,
 135    ) -> ThreadState {
 136        let load_task = cx.spawn_in(window, async move |this, cx| {
 137            let result = match agent.initialize().await {
 138                Err(e) => Err(e),
 139                Ok(response) => {
 140                    if !response.is_authenticated {
 141                        this.update(cx, |this, _| {
 142                            this.thread_state = ThreadState::Unauthenticated;
 143                        })
 144                        .ok();
 145                        return;
 146                    }
 147                    agent.clone().create_thread(cx).await
 148                }
 149            };
 150
 151            this.update_in(cx, |this, window, cx| {
 152                match result {
 153                    Ok(thread) => {
 154                        let subscription =
 155                            cx.subscribe_in(&thread, window, Self::handle_thread_event);
 156                        this.list_state
 157                            .splice(0..0, thread.read(cx).entries().len());
 158
 159                        this.thread_state = ThreadState::Ready {
 160                            thread,
 161                            _subscription: subscription,
 162                        };
 163                    }
 164                    Err(e) => {
 165                        if let Some(exit_status) = agent.exit_status() {
 166                            this.thread_state = ThreadState::LoadError(
 167                                format!(
 168                                    "Gemini exited with status {}",
 169                                    exit_status.code().unwrap_or(-127)
 170                                )
 171                                .into(),
 172                            )
 173                        } else {
 174                            this.thread_state = ThreadState::LoadError(e.to_string().into())
 175                        }
 176                    }
 177                };
 178                cx.notify();
 179            })
 180            .log_err();
 181        });
 182
 183        ThreadState::Loading { _task: load_task }
 184    }
 185
 186    fn thread(&self) -> Option<&Entity<AcpThread>> {
 187        match &self.thread_state {
 188            ThreadState::Ready { thread, .. } => Some(thread),
 189            ThreadState::Loading { .. }
 190            | ThreadState::LoadError(..)
 191            | ThreadState::Unauthenticated => None,
 192        }
 193    }
 194
 195    pub fn title(&self, cx: &App) -> SharedString {
 196        match &self.thread_state {
 197            ThreadState::Ready { thread, .. } => thread.read(cx).title(),
 198            ThreadState::Loading { .. } => "Loading...".into(),
 199            ThreadState::LoadError(_) => "Failed to load".into(),
 200            ThreadState::Unauthenticated => "Not authenticated".into(),
 201        }
 202    }
 203
 204    pub fn cancel(&mut self, cx: &mut Context<Self>) {
 205        self.last_error.take();
 206
 207        if let Some(thread) = self.thread() {
 208            thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
 209        }
 210    }
 211
 212    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
 213        self.last_error.take();
 214        let text = self.message_editor.read(cx).text(cx);
 215        if text.is_empty() {
 216            return;
 217        }
 218        let Some(thread) = self.thread() else { return };
 219
 220        let task = thread.update(cx, |thread, cx| thread.send(&text, cx));
 221
 222        cx.spawn(async move |this, cx| {
 223            let result = task.await;
 224
 225            this.update(cx, |this, cx| {
 226                if let Err(err) = result {
 227                    this.last_error =
 228                        Some(cx.new(|cx| {
 229                            Markdown::new(format!("Error: {err}").into(), None, None, cx)
 230                        }))
 231                }
 232            })
 233        })
 234        .detach();
 235
 236        self.message_editor.update(cx, |editor, cx| {
 237            editor.clear(window, cx);
 238        });
 239    }
 240
 241    fn handle_thread_event(
 242        &mut self,
 243        thread: &Entity<AcpThread>,
 244        event: &AcpThreadEvent,
 245        window: &mut Window,
 246        cx: &mut Context<Self>,
 247    ) {
 248        let count = self.list_state.item_count();
 249        match event {
 250            AcpThreadEvent::NewEntry => {
 251                self.sync_thread_entry_view(thread.read(cx).entries.len() - 1, window, cx);
 252                self.list_state.splice(count..count, 1);
 253            }
 254            AcpThreadEvent::EntryUpdated(index) => {
 255                let index = *index;
 256                self.sync_thread_entry_view(index, window, cx);
 257                self.list_state.splice(index..index + 1, 1);
 258            }
 259        }
 260        cx.notify();
 261    }
 262
 263    // todo! should we do this on the fly from render?
 264    fn sync_thread_entry_view(
 265        &mut self,
 266        entry_ix: usize,
 267        window: &mut Window,
 268        cx: &mut Context<Self>,
 269    ) {
 270        let multibuffer = match (
 271            self.entry_diff_multibuffer(entry_ix, cx),
 272            self.thread_entry_views.get(entry_ix),
 273        ) {
 274            (Some(multibuffer), Some(Some(ThreadEntryView::Diff { editor }))) => {
 275                if editor.read(cx).buffer() == &multibuffer {
 276                    // same buffer, all synced up
 277                    return;
 278                }
 279                // new buffer, replace editor
 280                multibuffer
 281            }
 282            (Some(multibuffer), _) => multibuffer,
 283            (None, Some(Some(ThreadEntryView::Diff { .. }))) => {
 284                // no longer displaying a diff, drop editor
 285                self.thread_entry_views[entry_ix] = None;
 286                return;
 287            }
 288            (None, _) => return,
 289        };
 290
 291        let editor = cx.new(|cx| {
 292            let mut editor = Editor::new(
 293                EditorMode::Full {
 294                    scale_ui_elements_with_buffer_font_size: false,
 295                    show_active_line_background: false,
 296                    sized_by_content: true,
 297                },
 298                multibuffer.clone(),
 299                None,
 300                window,
 301                cx,
 302            );
 303            editor.set_show_gutter(false, cx);
 304            editor.disable_inline_diagnostics();
 305            editor.disable_expand_excerpt_buttons(cx);
 306            editor.set_show_vertical_scrollbar(false, cx);
 307            editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
 308            editor.set_soft_wrap_mode(SoftWrap::None, cx);
 309            editor.scroll_manager.set_forbid_vertical_scroll(true);
 310            editor.set_show_indent_guides(false, cx);
 311            editor.set_read_only(true);
 312            editor.set_show_breakpoints(false, cx);
 313            editor.set_show_code_actions(false, cx);
 314            editor.set_show_git_diff_gutter(false, cx);
 315            editor.set_expand_all_diff_hunks(cx);
 316            editor.set_text_style_refinement(TextStyleRefinement {
 317                font_size: Some(
 318                    TextSize::Small
 319                        .rems(cx)
 320                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
 321                        .into(),
 322                ),
 323                ..Default::default()
 324            });
 325            editor
 326        });
 327
 328        if entry_ix >= self.thread_entry_views.len() {
 329            self.thread_entry_views
 330                .resize_with(entry_ix + 1, Default::default);
 331        }
 332
 333        self.thread_entry_views[entry_ix] = Some(ThreadEntryView::Diff {
 334            editor: editor.clone(),
 335        });
 336    }
 337
 338    fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
 339        let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
 340        if let AgentThreadEntryContent::ToolCall(ToolCall {
 341            content: Some(ToolCallContent::Diff { diff }),
 342            ..
 343        }) = &entry.content
 344        {
 345            Some(diff.multibuffer.clone())
 346        } else {
 347            None
 348        }
 349    }
 350
 351    fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 352        let agent = self.agent.clone();
 353        self.last_error.take();
 354        self.auth_task = Some(cx.spawn_in(window, async move |this, cx| {
 355            let result = agent.authenticate().await;
 356
 357            this.update_in(cx, |this, window, cx| {
 358                if let Err(err) = result {
 359                    this.last_error =
 360                        Some(cx.new(|cx| {
 361                            Markdown::new(format!("Error: {err}").into(), None, None, cx)
 362                        }))
 363                } else {
 364                    this.thread_state = Self::initial_state(agent, window, cx)
 365                }
 366                this.auth_task.take()
 367            })
 368            .ok();
 369        }));
 370    }
 371
 372    fn authorize_tool_call(
 373        &mut self,
 374        id: ToolCallId,
 375        outcome: acp::ToolCallConfirmationOutcome,
 376        cx: &mut Context<Self>,
 377    ) {
 378        let Some(thread) = self.thread() else {
 379            return;
 380        };
 381        thread.update(cx, |thread, cx| {
 382            thread.authorize_tool_call(id, outcome, cx);
 383        });
 384        cx.notify();
 385    }
 386
 387    fn render_entry(
 388        &self,
 389        index: usize,
 390        entry: &ThreadEntry,
 391        window: &mut Window,
 392        cx: &Context<Self>,
 393    ) -> AnyElement {
 394        match &entry.content {
 395            AgentThreadEntryContent::UserMessage(message) => {
 396                let style = user_message_markdown_style(window, cx);
 397                let message_body = div().children(message.chunks.iter().map(|chunk| match chunk {
 398                    UserMessageChunk::Text { chunk } => {
 399                        // todo!() open link
 400                        MarkdownElement::new(chunk.clone(), style.clone())
 401                    }
 402                    _ => todo!(),
 403                }));
 404                div()
 405                    .p_2()
 406                    .pt_5()
 407                    .child(
 408                        div()
 409                            .text_xs()
 410                            .p_3()
 411                            .bg(cx.theme().colors().editor_background)
 412                            .rounded_lg()
 413                            .shadow_md()
 414                            .border_1()
 415                            .border_color(cx.theme().colors().border)
 416                            .child(message_body),
 417                    )
 418                    .into_any()
 419            }
 420            AgentThreadEntryContent::AssistantMessage(AssistantMessage { chunks }) => {
 421                let style = default_markdown_style(window, cx);
 422                let message_body = div()
 423                    .children(chunks.iter().map(|chunk| match chunk {
 424                        AssistantMessageChunk::Text { chunk } => {
 425                            // todo!() open link
 426                            MarkdownElement::new(chunk.clone(), style.clone()).into_any_element()
 427                        }
 428                        AssistantMessageChunk::Thought { chunk } => {
 429                            self.render_thinking_block(chunk.clone(), window, cx)
 430                        }
 431                    }))
 432                    .into_any();
 433
 434                div()
 435                    .text_ui(cx)
 436                    .p_5()
 437                    .pt_2()
 438                    .child(message_body)
 439                    .into_any()
 440            }
 441            AgentThreadEntryContent::ToolCall(tool_call) => div()
 442                .px_2()
 443                .py_4()
 444                .child(self.render_tool_call(index, tool_call, window, cx))
 445                .into_any(),
 446        }
 447    }
 448
 449    fn render_thinking_block(
 450        &self,
 451        chunk: Entity<Markdown>,
 452        window: &Window,
 453        cx: &Context<Self>,
 454    ) -> AnyElement {
 455        v_flex()
 456            .mt_neg_2()
 457            .mb_3()
 458            .child(
 459                h_flex().group("disclosure-header").justify_between().child(
 460                    h_flex()
 461                        .gap_1p5()
 462                        .child(
 463                            Icon::new(IconName::LightBulb)
 464                                .size(IconSize::XSmall)
 465                                .color(Color::Muted),
 466                        )
 467                        .child(Label::new("Thinking").size(LabelSize::Small)),
 468                ),
 469            )
 470            .child(
 471                div()
 472                    .relative()
 473                    .rounded_b_lg()
 474                    .mt_2()
 475                    .pl_4()
 476                    .child(div().text_ui_sm(cx).child(
 477                        // todo! url click
 478                        MarkdownElement::new(chunk, default_markdown_style(window, cx)),
 479                        // .on_url_click({
 480                        //     let workspace = self.workspace.clone();
 481                        //     move |text, window, cx| {
 482                        //         open_markdown_link(text, workspace.clone(), window, cx);
 483                        //     }
 484                        // }),
 485                    )),
 486            )
 487            .into_any_element()
 488    }
 489
 490    fn render_tool_call(
 491        &self,
 492        entry_ix: usize,
 493        tool_call: &ToolCall,
 494        window: &Window,
 495        cx: &Context<Self>,
 496    ) -> Div {
 497        let status_icon = match &tool_call.status {
 498            ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
 499            ToolCallStatus::Allowed {
 500                status: acp::ToolCallStatus::Running,
 501                ..
 502            } => Icon::new(IconName::ArrowCircle)
 503                .color(Color::Success)
 504                .size(IconSize::Small)
 505                .with_animation(
 506                    "running",
 507                    Animation::new(Duration::from_secs(2)).repeat(),
 508                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
 509                )
 510                .into_any_element(),
 511            ToolCallStatus::Allowed {
 512                status: acp::ToolCallStatus::Finished,
 513                ..
 514            } => Icon::new(IconName::Check)
 515                .color(Color::Success)
 516                .size(IconSize::Small)
 517                .into_any_element(),
 518            ToolCallStatus::Rejected
 519            | ToolCallStatus::Canceled
 520            | ToolCallStatus::Allowed {
 521                status: acp::ToolCallStatus::Error,
 522                ..
 523            } => Icon::new(IconName::X)
 524                .color(Color::Error)
 525                .size(IconSize::Small)
 526                .into_any_element(),
 527        };
 528
 529        let content = match &tool_call.status {
 530            ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
 531                Some(self.render_tool_call_confirmation(
 532                    entry_ix,
 533                    tool_call.id,
 534                    confirmation,
 535                    tool_call.content.as_ref(),
 536                    window,
 537                    cx,
 538                ))
 539            }
 540            ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
 541                tool_call.content.as_ref().map(|content| {
 542                    div()
 543                        .border_color(cx.theme().colors().border)
 544                        .border_t_1()
 545                        .px_2()
 546                        .py_1p5()
 547                        .child(self.render_tool_call_content(entry_ix, content, window, cx))
 548                        .into_any_element()
 549                })
 550            }
 551            ToolCallStatus::Rejected => None,
 552        };
 553
 554        v_flex()
 555            .text_xs()
 556            .rounded_md()
 557            .border_1()
 558            .border_color(cx.theme().colors().border)
 559            .bg(cx.theme().colors().editor_background)
 560            .child(
 561                h_flex()
 562                    .px_2()
 563                    .py_1p5()
 564                    .w_full()
 565                    .gap_1p5()
 566                    .child(
 567                        Icon::new(tool_call.icon)
 568                            .size(IconSize::Small)
 569                            .color(Color::Muted),
 570                    )
 571                    // todo! danilo please help
 572                    .child(MarkdownElement::new(
 573                        tool_call.label.clone(),
 574                        default_markdown_style(window, cx),
 575                    ))
 576                    .child(div().w_full())
 577                    .child(status_icon),
 578            )
 579            .children(content)
 580    }
 581
 582    fn render_tool_call_content(
 583        &self,
 584        entry_ix: usize,
 585        content: &ToolCallContent,
 586        window: &Window,
 587        cx: &Context<Self>,
 588    ) -> AnyElement {
 589        match content {
 590            ToolCallContent::Markdown { markdown } => {
 591                MarkdownElement::new(markdown.clone(), default_markdown_style(window, cx))
 592                    .into_any_element()
 593            }
 594            ToolCallContent::Diff {
 595                diff: Diff { path, .. },
 596                ..
 597            } => self.render_diff_editor(entry_ix, path),
 598        }
 599    }
 600
 601    fn render_tool_call_confirmation(
 602        &self,
 603        entry_ix: usize,
 604        tool_call_id: ToolCallId,
 605        confirmation: &ToolCallConfirmation,
 606        content: Option<&ToolCallContent>,
 607        window: &Window,
 608        cx: &Context<Self>,
 609    ) -> AnyElement {
 610        match confirmation {
 611            ToolCallConfirmation::Edit { description } => {
 612                v_flex()
 613                    .border_color(cx.theme().colors().border)
 614                    .border_t_1()
 615                    .px_2()
 616                    .py_1p5()
 617                    .children(description.clone().map(|description| {
 618                        MarkdownElement::new(description, default_markdown_style(window, cx))
 619                    }))
 620                    .children(content.map(|content| {
 621                        self.render_tool_call_content(entry_ix, content, window, cx)
 622                    }))
 623                    .child(
 624                        h_flex()
 625                            .justify_end()
 626                            .gap_1()
 627                            .child(
 628                                Button::new(
 629                                    ("always_allow", tool_call_id.as_u64()),
 630                                    "Always Allow Edits",
 631                                )
 632                                .icon(IconName::CheckDouble)
 633                                .icon_position(IconPosition::Start)
 634                                .icon_size(IconSize::Small)
 635                                .icon_color(Color::Success)
 636                                .on_click(cx.listener({
 637                                    let id = tool_call_id;
 638                                    move |this, _, _, cx| {
 639                                        this.authorize_tool_call(
 640                                            id,
 641                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
 642                                            cx,
 643                                        );
 644                                    }
 645                                })),
 646                            )
 647                            .child(
 648                                Button::new(("allow", tool_call_id.as_u64()), "Allow")
 649                                    .icon(IconName::Check)
 650                                    .icon_position(IconPosition::Start)
 651                                    .icon_size(IconSize::Small)
 652                                    .icon_color(Color::Success)
 653                                    .on_click(cx.listener({
 654                                        let id = tool_call_id;
 655                                        move |this, _, _, cx| {
 656                                            this.authorize_tool_call(
 657                                                id,
 658                                                acp::ToolCallConfirmationOutcome::Allow,
 659                                                cx,
 660                                            );
 661                                        }
 662                                    })),
 663                            )
 664                            .child(
 665                                Button::new(("reject", tool_call_id.as_u64()), "Reject")
 666                                    .icon(IconName::X)
 667                                    .icon_position(IconPosition::Start)
 668                                    .icon_size(IconSize::Small)
 669                                    .icon_color(Color::Error)
 670                                    .on_click(cx.listener({
 671                                        let id = tool_call_id;
 672                                        move |this, _, _, cx| {
 673                                            this.authorize_tool_call(
 674                                                id,
 675                                                acp::ToolCallConfirmationOutcome::Reject,
 676                                                cx,
 677                                            );
 678                                        }
 679                                    })),
 680                            ),
 681                    )
 682                    .into_any()
 683            }
 684            ToolCallConfirmation::Execute {
 685                command,
 686                root_command,
 687                description,
 688            } => {
 689                v_flex()
 690                    .border_color(cx.theme().colors().border)
 691                    .border_t_1()
 692                    .px_2()
 693                    .py_1p5()
 694                    // todo! nicer rendering
 695                    .child(command.clone())
 696                    .children(description.clone().map(|description| {
 697                        MarkdownElement::new(description, default_markdown_style(window, cx))
 698                    }))
 699                    .children(content.map(|content| {
 700                        self.render_tool_call_content(entry_ix, content, window, cx)
 701                    }))
 702                    .child(
 703                        h_flex()
 704                            .justify_end()
 705                            .gap_1()
 706                            .child(
 707                                Button::new(
 708                                    ("always_allow", tool_call_id.as_u64()),
 709                                    format!("Always Allow {root_command}"),
 710                                )
 711                                .icon(IconName::CheckDouble)
 712                                .icon_position(IconPosition::Start)
 713                                .icon_size(IconSize::Small)
 714                                .icon_color(Color::Success)
 715                                .on_click(cx.listener({
 716                                    let id = tool_call_id;
 717                                    move |this, _, _, cx| {
 718                                        this.authorize_tool_call(
 719                                            id,
 720                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
 721                                            cx,
 722                                        );
 723                                    }
 724                                })),
 725                            )
 726                            .child(
 727                                Button::new(("allow", tool_call_id.as_u64()), "Allow")
 728                                    .icon(IconName::Check)
 729                                    .icon_position(IconPosition::Start)
 730                                    .icon_size(IconSize::Small)
 731                                    .icon_color(Color::Success)
 732                                    .on_click(cx.listener({
 733                                        let id = tool_call_id;
 734                                        move |this, _, _, cx| {
 735                                            this.authorize_tool_call(
 736                                                id,
 737                                                acp::ToolCallConfirmationOutcome::Allow,
 738                                                cx,
 739                                            );
 740                                        }
 741                                    })),
 742                            )
 743                            .child(
 744                                Button::new(("reject", tool_call_id.as_u64()), "Reject")
 745                                    .icon(IconName::X)
 746                                    .icon_position(IconPosition::Start)
 747                                    .icon_size(IconSize::Small)
 748                                    .icon_color(Color::Error)
 749                                    .on_click(cx.listener({
 750                                        let id = tool_call_id;
 751                                        move |this, _, _, cx| {
 752                                            this.authorize_tool_call(
 753                                                id,
 754                                                acp::ToolCallConfirmationOutcome::Reject,
 755                                                cx,
 756                                            );
 757                                        }
 758                                    })),
 759                            ),
 760                    )
 761                    .into_any()
 762            }
 763            ToolCallConfirmation::Mcp {
 764                server_name,
 765                tool_name: _,
 766                tool_display_name,
 767                description,
 768            } => {
 769                v_flex()
 770                    .border_color(cx.theme().colors().border)
 771                    .border_t_1()
 772                    .px_2()
 773                    .py_1p5()
 774                    // todo! nicer rendering
 775                    .child(format!("{server_name} - {tool_display_name}"))
 776                    .children(description.clone().map(|description| {
 777                        MarkdownElement::new(description, default_markdown_style(window, cx))
 778                    }))
 779                    .children(content.map(|content| {
 780                        self.render_tool_call_content(entry_ix, content, window, cx)
 781                    }))
 782                    .child(
 783                        h_flex()
 784                            .justify_end()
 785                            .gap_1()
 786                            .child(
 787                                Button::new(
 788                                    ("always_allow_server", tool_call_id.as_u64()),
 789                                    format!("Always Allow {server_name}"),
 790                                )
 791                                .icon(IconName::CheckDouble)
 792                                .icon_position(IconPosition::Start)
 793                                .icon_size(IconSize::Small)
 794                                .icon_color(Color::Success)
 795                                .on_click(cx.listener({
 796                                    let id = tool_call_id;
 797                                    move |this, _, _, cx| {
 798                                        this.authorize_tool_call(
 799                                            id,
 800                                            acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
 801                                            cx,
 802                                        );
 803                                    }
 804                                })),
 805                            )
 806                            .child(
 807                                Button::new(
 808                                    ("always_allow_tool", tool_call_id.as_u64()),
 809                                    format!("Always Allow {tool_display_name}"),
 810                                )
 811                                .icon(IconName::CheckDouble)
 812                                .icon_position(IconPosition::Start)
 813                                .icon_size(IconSize::Small)
 814                                .icon_color(Color::Success)
 815                                .on_click(cx.listener({
 816                                    let id = tool_call_id;
 817                                    move |this, _, _, cx| {
 818                                        this.authorize_tool_call(
 819                                            id,
 820                                            acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
 821                                            cx,
 822                                        );
 823                                    }
 824                                })),
 825                            )
 826                            .child(
 827                                Button::new(("allow", tool_call_id.as_u64()), "Allow")
 828                                    .icon(IconName::Check)
 829                                    .icon_position(IconPosition::Start)
 830                                    .icon_size(IconSize::Small)
 831                                    .icon_color(Color::Success)
 832                                    .on_click(cx.listener({
 833                                        let id = tool_call_id;
 834                                        move |this, _, _, cx| {
 835                                            this.authorize_tool_call(
 836                                                id,
 837                                                acp::ToolCallConfirmationOutcome::Allow,
 838                                                cx,
 839                                            );
 840                                        }
 841                                    })),
 842                            )
 843                            .child(
 844                                Button::new(("reject", tool_call_id.as_u64()), "Reject")
 845                                    .icon(IconName::X)
 846                                    .icon_position(IconPosition::Start)
 847                                    .icon_size(IconSize::Small)
 848                                    .icon_color(Color::Error)
 849                                    .on_click(cx.listener({
 850                                        let id = tool_call_id;
 851                                        move |this, _, _, cx| {
 852                                            this.authorize_tool_call(
 853                                                id,
 854                                                acp::ToolCallConfirmationOutcome::Reject,
 855                                                cx,
 856                                            );
 857                                        }
 858                                    })),
 859                            ),
 860                    )
 861                    .into_any()
 862            }
 863            ToolCallConfirmation::Fetch { description, urls } => v_flex()
 864                .border_color(cx.theme().colors().border)
 865                .border_t_1()
 866                .px_2()
 867                .py_1p5()
 868                // todo! nicer rendering
 869                .children(urls.clone())
 870                .children(description.clone().map(|description| {
 871                    MarkdownElement::new(description, default_markdown_style(window, cx))
 872                }))
 873                .children(
 874                    content.map(|content| {
 875                        self.render_tool_call_content(entry_ix, content, window, cx)
 876                    }),
 877                )
 878                .child(
 879                    h_flex()
 880                        .justify_end()
 881                        .gap_1()
 882                        .child(
 883                            Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
 884                                .icon(IconName::CheckDouble)
 885                                .icon_position(IconPosition::Start)
 886                                .icon_size(IconSize::Small)
 887                                .icon_color(Color::Success)
 888                                .on_click(cx.listener({
 889                                    let id = tool_call_id;
 890                                    move |this, _, _, cx| {
 891                                        this.authorize_tool_call(
 892                                            id,
 893                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
 894                                            cx,
 895                                        );
 896                                    }
 897                                })),
 898                        )
 899                        .child(
 900                            Button::new(("allow", tool_call_id.as_u64()), "Allow")
 901                                .icon(IconName::Check)
 902                                .icon_position(IconPosition::Start)
 903                                .icon_size(IconSize::Small)
 904                                .icon_color(Color::Success)
 905                                .on_click(cx.listener({
 906                                    let id = tool_call_id;
 907                                    move |this, _, _, cx| {
 908                                        this.authorize_tool_call(
 909                                            id,
 910                                            acp::ToolCallConfirmationOutcome::Allow,
 911                                            cx,
 912                                        );
 913                                    }
 914                                })),
 915                        )
 916                        .child(
 917                            Button::new(("reject", tool_call_id.as_u64()), "Reject")
 918                                .icon(IconName::X)
 919                                .icon_position(IconPosition::Start)
 920                                .icon_size(IconSize::Small)
 921                                .icon_color(Color::Error)
 922                                .on_click(cx.listener({
 923                                    let id = tool_call_id;
 924                                    move |this, _, _, cx| {
 925                                        this.authorize_tool_call(
 926                                            id,
 927                                            acp::ToolCallConfirmationOutcome::Reject,
 928                                            cx,
 929                                        );
 930                                    }
 931                                })),
 932                        ),
 933                )
 934                .into_any(),
 935            ToolCallConfirmation::Other { description } => v_flex()
 936                .border_color(cx.theme().colors().border)
 937                .border_t_1()
 938                .px_2()
 939                .py_1p5()
 940                // todo! nicer rendering
 941                .child(MarkdownElement::new(
 942                    description.clone(),
 943                    default_markdown_style(window, cx),
 944                ))
 945                .children(
 946                    content.map(|content| {
 947                        self.render_tool_call_content(entry_ix, content, window, cx)
 948                    }),
 949                )
 950                .child(
 951                    h_flex()
 952                        .justify_end()
 953                        .gap_1()
 954                        .child(
 955                            Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow")
 956                                .icon(IconName::CheckDouble)
 957                                .icon_position(IconPosition::Start)
 958                                .icon_size(IconSize::Small)
 959                                .icon_color(Color::Success)
 960                                .on_click(cx.listener({
 961                                    let id = tool_call_id;
 962                                    move |this, _, _, cx| {
 963                                        this.authorize_tool_call(
 964                                            id,
 965                                            acp::ToolCallConfirmationOutcome::AlwaysAllow,
 966                                            cx,
 967                                        );
 968                                    }
 969                                })),
 970                        )
 971                        .child(
 972                            Button::new(("allow", tool_call_id.as_u64()), "Allow")
 973                                .icon(IconName::Check)
 974                                .icon_position(IconPosition::Start)
 975                                .icon_size(IconSize::Small)
 976                                .icon_color(Color::Success)
 977                                .on_click(cx.listener({
 978                                    let id = tool_call_id;
 979                                    move |this, _, _, cx| {
 980                                        this.authorize_tool_call(
 981                                            id,
 982                                            acp::ToolCallConfirmationOutcome::Allow,
 983                                            cx,
 984                                        );
 985                                    }
 986                                })),
 987                        )
 988                        .child(
 989                            Button::new(("reject", tool_call_id.as_u64()), "Reject")
 990                                .icon(IconName::X)
 991                                .icon_position(IconPosition::Start)
 992                                .icon_size(IconSize::Small)
 993                                .icon_color(Color::Error)
 994                                .on_click(cx.listener({
 995                                    let id = tool_call_id;
 996                                    move |this, _, _, cx| {
 997                                        this.authorize_tool_call(
 998                                            id,
 999                                            acp::ToolCallConfirmationOutcome::Reject,
1000                                            cx,
1001                                        );
1002                                    }
1003                                })),
1004                        ),
1005                )
1006                .into_any(),
1007        }
1008    }
1009
1010    fn render_diff_editor(&self, entry_ix: usize, path: &Path) -> AnyElement {
1011        v_flex()
1012            .h_full()
1013            .child(path.to_string_lossy().to_string())
1014            .child(
1015                if let Some(Some(ThreadEntryView::Diff { editor })) =
1016                    self.thread_entry_views.get(entry_ix)
1017                {
1018                    editor.clone().into_any_element()
1019                } else {
1020                    Empty.into_any()
1021                },
1022            )
1023            .into_any()
1024    }
1025}
1026
1027impl Focusable for AcpThreadView {
1028    fn focus_handle(&self, cx: &App) -> FocusHandle {
1029        self.message_editor.focus_handle(cx)
1030    }
1031}
1032
1033impl Render for AcpThreadView {
1034    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1035        let text = self.message_editor.read(cx).text(cx);
1036        let is_editor_empty = text.is_empty();
1037        let focus_handle = self.message_editor.focus_handle(cx);
1038
1039        v_flex()
1040            .key_context("MessageEditor")
1041            .on_action(cx.listener(Self::chat))
1042            .h_full()
1043            .child(match &self.thread_state {
1044                ThreadState::Unauthenticated => v_flex()
1045                    .p_2()
1046                    .flex_1()
1047                    .justify_end()
1048                    .child(Label::new("Not authenticated"))
1049                    .child(Button::new("sign-in", "Sign in via Gemini CLI").on_click(
1050                        cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
1051                    )),
1052                ThreadState::Loading { .. } => v_flex()
1053                    .p_2()
1054                    .flex_1()
1055                    .justify_end()
1056                    .child(Label::new("Connecting to Gemini...")),
1057                ThreadState::LoadError(e) => div()
1058                    .p_2()
1059                    .flex_1()
1060                    .justify_end()
1061                    .child(Label::new(format!("Failed to load: {e}")).into_any_element()),
1062                ThreadState::Ready { thread, .. } => v_flex()
1063                    .flex_1()
1064                    .gap_2()
1065                    .pb_2()
1066                    .child(
1067                        list(self.list_state.clone())
1068                            .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
1069                            .flex_grow(),
1070                    )
1071                    .child(
1072                        div().px_3().children(match thread.read(cx).status() {
1073                            ThreadStatus::Idle => None,
1074                            ThreadStatus::WaitingForToolConfirmation => {
1075                                Label::new("Waiting for tool confirmation")
1076                                    .color(Color::Muted)
1077                                    .size(LabelSize::Small)
1078                                    .into()
1079                            }
1080                            ThreadStatus::Generating => Label::new("Generating...")
1081                                .color(Color::Muted)
1082                                .size(LabelSize::Small)
1083                                .into(),
1084                        }),
1085                    ),
1086            })
1087            .when_some(self.last_error.clone(), |el, error| {
1088                el.child(
1089                    div()
1090                        .text_xs()
1091                        .p_2()
1092                        .gap_2()
1093                        .border_t_1()
1094                        .border_color(cx.theme().status().error_border)
1095                        .bg(cx.theme().status().error_background)
1096                        .child(MarkdownElement::new(
1097                            error,
1098                            default_markdown_style(window, cx),
1099                        )),
1100                )
1101            })
1102            .child(
1103                v_flex()
1104                    .bg(cx.theme().colors().editor_background)
1105                    .border_t_1()
1106                    .border_color(cx.theme().colors().border)
1107                    .p_2()
1108                    .gap_2()
1109                    .child(self.message_editor.clone())
1110                    .child({
1111                        let thread = self.thread();
1112
1113                        h_flex().justify_end().child(
1114                            if thread.map_or(true, |thread| {
1115                                thread.read(cx).status() == ThreadStatus::Idle
1116                            }) {
1117                                IconButton::new("send-message", IconName::Send)
1118                                    .icon_color(Color::Accent)
1119                                    .style(ButtonStyle::Filled)
1120                                    .disabled(thread.is_none() || is_editor_empty)
1121                                    .on_click({
1122                                        let focus_handle = focus_handle.clone();
1123                                        move |_event, window, cx| {
1124                                            focus_handle.dispatch_action(&Chat, window, cx);
1125                                        }
1126                                    })
1127                                    .when(!is_editor_empty, |button| {
1128                                        button.tooltip(move |window, cx| {
1129                                            Tooltip::for_action("Send", &Chat, window, cx)
1130                                        })
1131                                    })
1132                                    .when(is_editor_empty, |button| {
1133                                        button.tooltip(Tooltip::text("Type a message to submit"))
1134                                    })
1135                            } else {
1136                                IconButton::new("stop-generation", IconName::StopFilled)
1137                                    .icon_color(Color::Error)
1138                                    .style(ButtonStyle::Tinted(ui::TintColor::Error))
1139                                    .tooltip(move |window, cx| {
1140                                        Tooltip::for_action(
1141                                            "Stop Generation",
1142                                            &editor::actions::Cancel,
1143                                            window,
1144                                            cx,
1145                                        )
1146                                    })
1147                                    .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx)))
1148                            },
1149                        )
1150                    }),
1151            )
1152    }
1153}
1154
1155fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1156    let mut style = default_markdown_style(window, cx);
1157    let mut text_style = window.text_style();
1158    let theme_settings = ThemeSettings::get_global(cx);
1159
1160    let buffer_font = theme_settings.buffer_font.family.clone();
1161    let buffer_font_size = TextSize::Small.rems(cx);
1162
1163    text_style.refine(&TextStyleRefinement {
1164        font_family: Some(buffer_font),
1165        font_size: Some(buffer_font_size.into()),
1166        ..Default::default()
1167    });
1168
1169    style.base_text_style = text_style;
1170    style
1171}
1172
1173fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1174    let theme_settings = ThemeSettings::get_global(cx);
1175    let colors = cx.theme().colors();
1176    let ui_font_size = TextSize::Default.rems(cx);
1177    let buffer_font_size = TextSize::Small.rems(cx);
1178    let mut text_style = window.text_style();
1179    let line_height = buffer_font_size * 1.75;
1180
1181    text_style.refine(&TextStyleRefinement {
1182        font_family: Some(theme_settings.ui_font.family.clone()),
1183        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1184        font_features: Some(theme_settings.ui_font.features.clone()),
1185        font_size: Some(ui_font_size.into()),
1186        line_height: Some(line_height.into()),
1187        color: Some(cx.theme().colors().text),
1188        ..Default::default()
1189    });
1190
1191    MarkdownStyle {
1192        base_text_style: text_style.clone(),
1193        syntax: cx.theme().syntax().clone(),
1194        selection_background_color: cx.theme().colors().element_selection_background,
1195        code_block_overflow_x_scroll: true,
1196        table_overflow_x_scroll: true,
1197        heading_level_styles: Some(HeadingLevelStyles {
1198            h1: Some(TextStyleRefinement {
1199                font_size: Some(rems(1.15).into()),
1200                ..Default::default()
1201            }),
1202            h2: Some(TextStyleRefinement {
1203                font_size: Some(rems(1.1).into()),
1204                ..Default::default()
1205            }),
1206            h3: Some(TextStyleRefinement {
1207                font_size: Some(rems(1.05).into()),
1208                ..Default::default()
1209            }),
1210            h4: Some(TextStyleRefinement {
1211                font_size: Some(rems(1.).into()),
1212                ..Default::default()
1213            }),
1214            h5: Some(TextStyleRefinement {
1215                font_size: Some(rems(0.95).into()),
1216                ..Default::default()
1217            }),
1218            h6: Some(TextStyleRefinement {
1219                font_size: Some(rems(0.875).into()),
1220                ..Default::default()
1221            }),
1222        }),
1223        code_block: StyleRefinement {
1224            padding: EdgesRefinement {
1225                top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1226                left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1227                right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1228                bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
1229            },
1230            background: Some(colors.editor_background.into()),
1231            text: Some(TextStyleRefinement {
1232                font_family: Some(theme_settings.buffer_font.family.clone()),
1233                font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1234                font_features: Some(theme_settings.buffer_font.features.clone()),
1235                font_size: Some(buffer_font_size.into()),
1236                ..Default::default()
1237            }),
1238            ..Default::default()
1239        },
1240        inline_code: TextStyleRefinement {
1241            font_family: Some(theme_settings.buffer_font.family.clone()),
1242            font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
1243            font_features: Some(theme_settings.buffer_font.features.clone()),
1244            font_size: Some(buffer_font_size.into()),
1245            background_color: Some(colors.editor_foreground.opacity(0.08)),
1246            ..Default::default()
1247        },
1248        link: TextStyleRefinement {
1249            background_color: Some(colors.editor_foreground.opacity(0.025)),
1250            underline: Some(UnderlineStyle {
1251                color: Some(colors.text_accent.opacity(0.5)),
1252                thickness: px(1.),
1253                ..Default::default()
1254            }),
1255            ..Default::default()
1256        },
1257        link_callback: Some(Rc::new(move |_url, _cx| {
1258            // todo!()
1259            // if MentionLink::is_valid(url) {
1260            //     let colors = cx.theme().colors();
1261            //     Some(TextStyleRefinement {
1262            //         background_color: Some(colors.element_background),
1263            //         ..Default::default()
1264            //     })
1265            // } else {
1266            None
1267            // }
1268        })),
1269        ..Default::default()
1270    }
1271}