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