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