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