Vectrum
Sign In
1333 lines · 51.8 KB · Rust
//! The conversation pane: transcript and composer, set on the sheet. A new
//! thread starts centered, T3 Code style; once it has messages the composer
//! moves to the bottom.

use std::{path::PathBuf, time::Duration};

use gpui_kit::component::{
    Icon, IconName, Sizable as _, h_flex,
    input::{self, InputEvent, Textarea, TextareaState},
    menu::{DropdownMenu as _, PopupMenuItem},
    message_scroller::{MessageScroller, MessageScrollerState},
    popover::Popover,
    progress::ProgressCircle,
    text::{TextView, TextViewStyle},
    tooltip::Tooltip,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use crate::Interrupt;
use crate::git;
use crate::models::{Catalog, ModelEntry, Provider};
use crate::session::{
    ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session,
    ToolCall, ToolState, tool_field, tool_summary,
};
use crate::ui::changes::{self, DiffView};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent};
use crate::ui::workspace::ProjectDirectory;

const CONTENT_WIDTH: f32 = 760.;
const MAX_TOOL_OUTPUT: usize = 6_000;

/// What the conversation asks of the workspace around it.
pub enum ChatEvent {
    /// Start a thread on this model; it belongs to another provider than this
    /// thread's.
    NewThreadWithModel(ModelEntry),
    /// Move this (still empty) thread to another project.
    SwitchProject(PathBuf),
    AddProject,
    StartWithoutProject,
    SwitchBranch(String),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Picker {
    Model,
    Reasoning,
    Access,
}

pub struct ChatView {
    session: Entity<Session>,
    composer: Entity<TextareaState>,
    scroller: Entity<MessageScrollerState>,
    model_picker: Entity<ModelPicker>,
    open_picker: Option<Picker>,
    attachments: Vec<PathBuf>,
    diff: Option<DiffView>,
    _subscriptions: Vec<Subscription>,
    _ticker: Task<()>,
}

impl EventEmitter<ChatEvent> for ChatView {}

impl ChatView {
    pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let composer = cx.new(|cx| {
            TextareaState::new(window, cx)
                .auto_grow(2, 12)
                .submit_on_enter(!crate::settings::get(|settings| settings.send_with_ctrl_enter))
                .placeholder("Ask for changes, send follow-ups, or attach images")
        });
        let item_count = session.read(cx).items.len();
        let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx));
        let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx));

        let subscriptions = vec![
            cx.observe(&session, |this, session, cx| {
                let count = session.read(cx).items.len();
                this.scroller.update(cx, |scroller, cx| {
                    let current = scroller.item_count();
                    if count > current {
                        scroller.append(count - current, cx);
                    } else if count < current {
                        scroller.reset(count, cx);
                    }
                });
                cx.notify();
            }),
            cx.subscribe_in(&composer, window, |this, _, event, window, cx| {
                if let InputEvent::PressEnter { shift: false, secondary } = event
                    && (*secondary || !crate::settings::get(|settings| settings.send_with_ctrl_enter))
                {
                    this.submit(window, cx);
                }
            }),
            cx.subscribe_in(&model_picker, window, |this, _, event, window, cx| {
                let ModelPickerEvent::Picked(model) = event;
                this.open_picker = None;
                if model.provider == this.session.read(cx).provider {
                    let id = model.id.clone();
                    this.session.update(cx, |session, cx| session.set_model(Some(id), cx));
                    this.focus_composer(window, cx);
                } else {
                    cx.emit(ChatEvent::NewThreadWithModel(model.clone()));
                }
                cx.notify();
            }),
        ];

        // Re-render once a second while the agent works so the timer advances.
        let ticker = cx.spawn(async move |this, cx| {
            loop {
                cx.background_executor().timer(Duration::from_secs(1)).await;
                let alive = this.update(cx, |this, cx| {
                    if this.session.read(cx).is_working() {
                        cx.notify();
                    }
                });
                if alive.is_err() {
                    break;
                }
            }
        });

        Self {
            session,
            composer,
            scroller,
            model_picker,
            open_picker: None,
            attachments: Vec::new(),
            diff: None,
            _subscriptions: subscriptions,
            _ticker: ticker,
        }
    }

    pub fn session(&self) -> &Entity<Session> {
        &self.session
    }

    /// Picks up changed settings: the send key, and folding, which changes
    /// the height of transcript rows.
    pub fn settings_changed(&mut self, _: &mut Window, cx: &mut Context<Self>) {
        let ctrl_enter = crate::settings::get(|settings| settings.send_with_ctrl_enter);
        self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx));
        self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
        cx.notify();
    }

    pub fn focus_composer(&self, window: &mut Window, cx: &mut App) {
        self.composer.update(cx, |composer, cx| composer.focus(window, cx));
    }

    /// Carries what the user typed over from another thread, when this one
    /// replaces it (a provider or project switch before the first message).
    pub fn take_draft_from(&mut self, other: &Entity<ChatView>, window: &mut Window, cx: &mut Context<Self>) {
        let (text, attachments) = other.update(cx, |other, cx| {
            (other.composer.read(cx).value().to_string(), std::mem::take(&mut other.attachments))
        });
        self.attachments = attachments;
        self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx));
    }

    fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let text = self.composer.read(cx).value().trim().to_string();
        if text.is_empty() && self.attachments.is_empty() {
            return;
        }
        let attachments = std::mem::take(&mut self.attachments);
        self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
        self.session.update(cx, |session, cx| session.send_message(text, attachments, cx));
        self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
    }

    fn escape(&mut self, cx: &mut Context<Self>) {
        if self.diff.take().is_some() {
            cx.notify();
            return;
        }
        self.session.update(cx, |session, cx| session.interrupt(cx));
    }

    fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let paths = cx.prompt_for_paths(PathPromptOptions {
            files: true,
            directories: false,
            multiple: true,
            prompt: Some("Attach".into()),
        });
        cx.spawn_in(window, async move |this, cx| {
            let Ok(Ok(Some(paths))) = paths.await else { return };
            let _ = this.update_in(cx, |this, window, cx| {
                for path in paths {
                    if !this.attachments.contains(&path) {
                        this.attachments.push(path);
                    }
                }
                this.focus_composer(window, cx);
                cx.notify();
            });
        })
        .detach();
    }

    /// Shows what a finished turn changed, all files or one.
    fn open_diff(&mut self, end: usize, path: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
        let session = self.session.read(cx);
        let Some(changes) = session.turn_changes(end) else { return };
        let (base, head) = (changes.base.clone(), changes.head.clone());
        let cwd = session.cwd.clone();
        let title = match &path {
            Some(path) => path.clone(),
            None => format!("{} changed files", changes.files.len()),
        };
        self.diff = Some(DiffView { title: title.into(), files: None });
        let task = cx
            .background_executor()
            .spawn(async move { git::diff_between(&cwd, &base, &head, path.as_deref()) });
        cx.spawn_in(window, async move |this, cx| {
            let files = task.await;
            let _ = this.update(cx, |this, cx| {
                if let Some(diff) = this.diff.as_mut() {
                    diff.files = Some(files);
                    cx.notify();
                }
            });
        })
        .detach();
        cx.notify();
    }

    /// A callback that closes whichever picker is open.
    fn close_picker(cx: &Context<Self>) -> impl Fn(&mut Window, &mut App) + Clone + 'static {
        let chat = cx.entity().downgrade();
        move |window: &mut Window, cx: &mut App| {
            let _ = chat.update(cx, |this, cx| {
                this.open_picker = None;
                this.focus_composer(window, cx);
                cx.notify();
            });
        }
    }

    fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context<Self>) {
        if open {
            self.open_picker = Some(picker);
            if picker == Picker::Model {
                self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx));
            }
        } else if self.open_picker == Some(picker) {
            self.open_picker = None;
        }
        cx.notify();
    }

    /// "What should we build in T3-2?" with the project as a switcher.
    fn render_start(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let cwd = session.cwd.clone();
        let directory = cx.try_global::<ProjectDirectory>();
        let current = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd));
        let scratch = current.is_some_and(|project| project.scratch);
        let project = current.map(|project| project.name.clone()).unwrap_or_else(|| display_path(&cwd).into());
        let projects: Vec<(SharedString, PathBuf)> = directory
            .map(|directory| {
                directory
                    .projects
                    .iter()
                    .filter(|project| !project.scratch)
                    .map(|project| (project.name.clone(), project.path.clone()))
                    .collect()
            })
            .unwrap_or_default();
        let chat = cx.entity().downgrade();
        let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context<gpui_kit::component::menu::PopupMenu>| {
            menu = menu.min_w(px(240.)).label("Project");
            for (name, path) in projects.clone() {
                let chat = chat.clone();
                let checked = path == cwd;
                menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| {
                    let path = path.clone();
                    let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path)));
                }));
            }
            let chat = chat.clone();
            menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(move |_, _, cx| {
                let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::AddProject));
            }))
        };

        v_flex()
            .flex_1()
            .items_center()
            .justify_center()
            .px(px(24.))
            .pb(px(48.))
            .child(
                h_flex()
                    .flex_wrap()
                    .justify_center()
                    .items_center()
                    .text_size(px(34.))
                    .line_height(px(44.))
                    .font_weight(FontWeight::SEMIBOLD)
                    .text_color(p.ink)
                    .map(|this| {
                        if scratch {
                            this.child("What should we build?")
                        } else {
                            this.child("What should we build in\u{a0}")
                                .child(
                                    MetalButton::new("start-project")
                                        .ghost()
                                        .label(project)
                                        .h(px(44.))
                                        .px(px(2.))
                                        .rounded(px(0.))
                                        .text_size(px(34.))
                                        .font_weight(FontWeight::SEMIBOLD)
                                        .text_color(p.ink)
                                        .border_b(px(2.))
                                        .border_dashed()
                                        .border_color(p.ink3.opacity(0.6))
                                        .dropdown_menu(project_menu.clone()),
                                )
                                .child("?")
                        }
                    }),
            )
            .map(|this| {
                if scratch {
                    this.child(
                        MetalButton::new("pick-project")
                            .ghost()
                            .compact()
                            .label("or pick a project")
                            .caret()
                            .mt(px(6.))
                            .text_size(px(15.))
                            .text_color(p.ink3)
                            .dropdown_menu(project_menu),
                    )
                } else {
                    this.child(
                        div()
                            .id("start-without-project")
                            .mt(px(6.))
                            .text_size(px(15.))
                            .text_color(p.ink3)
                            .cursor_pointer()
                            .hover(|style| style.text_color(p.ink))
                            .on_click(cx.listener(|_, _, _, cx| cx.emit(ChatEvent::StartWithoutProject)))
                            .child("or start without a project"),
                    )
                }
            })
            .child(div().h(px(30.)))
            .child(self.render_composer(true, window, cx))
    }

    fn render_transcript(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let session = self.session.clone();
        let scroller = self.scroller.clone();
        let chat = cx.entity().downgrade();
        MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| {
            render_item(&session, &scroller, &chat, ix, window, cx)
        })
        .flex_1()
        .w_full()
        .with_row_style(StyleRefinement::default().pb_0())
        .with_bottom_fade(metal::palette(cx).s)
    }

    /// The line above the composer: what the agent is doing and for how long.
    fn render_activity(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let working = session.is_working();
        let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0);
        let waiting = session.needs_attention();

        h_flex()
            .w_full()
            .max_w(px(CONTENT_WIDTH))
            .h(px(22.))
            .px(px(6.))
            .gap(px(8.))
            .when(working, |this| {
                let (led, label) = if waiting {
                    (Led::Waiting, "Waiting for you")
                } else if session.thinking {
                    (Led::Live, "Thinking")
                } else {
                    (Led::Live, "Working")
                };
                this.child(metal::led("activity-led", led, true, p))
                    .child(metal::engraved(label, p))
                    .child(
                        div()
                            .font_family(metal::PIXEL)
                            .text_size(px(15.))
                            .text_color(p.ink)
                            .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)),
                    )
                    .child(div().flex_1())
                    .child(metal::keycap("Esc", p))
                    .child(metal::engraved("Interrupt", p))
            })
    }

    fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        h_flex()
            .flex_wrap()
            .gap(px(6.))
            .px(px(12.))
            .pt(px(12.))
            .children(self.attachments.iter().enumerate().map(|(ix, path)| {
                let name = path
                    .file_name()
                    .map(|name| name.to_string_lossy().to_string())
                    .unwrap_or_else(|| path.display().to_string());
                let is_image = matches!(
                    path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(),
                    Some("png" | "jpg" | "jpeg" | "gif" | "webp")
                );
                h_flex()
                    .h(px(28.))
                    .pl(px(10.))
                    .pr(px(3.))
                    .gap(px(7.))
                    .rounded(px(10.))
                    .well(p)
                    .text_size(px(12.5))
                    .text_color(p.ink2)
                    .child(
                        Icon::new(if is_image { Lucide::Image } else { Lucide::File })
                            .size(px(13.))
                            .text_color(p.ink3),
                    )
                    .child(div().max_w(px(220.)).truncate().child(name))
                    .child(
                        MetalButton::new(("remove-attachment", ix))
                            .ghost()
                            .mini()
                            .icon(IconName::Close)
                            .tooltip("Remove")
                            .on_click(cx.listener(move |this, _, _, cx| {
                                if ix < this.attachments.len() {
                                    this.attachments.remove(ix);
                                }
                                cx.notify();
                            })),
                    )
            }))
    }

    /// A ring showing how full the context window is.
    fn render_context_ring(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let tokens = session.context_tokens.unwrap_or(0);
        let window = session.context_window;
        let percent = window.map_or(0., |window| tokens as f32 * 100. / window.max(1) as f32);
        let color = if percent >= 90. {
            p.red
        } else if percent >= 70. {
            p.amber_ink
        } else {
            p.ink3
        };
        let tooltip: SharedString = match (session.context_tokens, window) {
            (Some(tokens), Some(window)) => format!(
                "Context: {} of {} tokens ({percent:.0}%)",
                format_tokens(tokens),
                format_tokens(window)
            )
            .into(),
            (Some(tokens), None) => format!("Context: {} tokens", format_tokens(tokens)).into(),
            _ => "Context usage appears after the first reply".into(),
        };

        div()
            .id("context-ring")
            .flex()
            .items_center()
            .justify_center()
            .size(px(26.))
            .rounded_full()
            .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
            .child(
                ProgressCircle::new("context-progress")
                    .value(percent.max(if session.context_tokens.is_some() { 1. } else { 0. }))
                    .color(color)
                    .with_size(gpui_kit::component::Size::Size(px(16.))),
            )
    }

    /// A composer picker: a ghost button that opens `content` above it.
    fn picker(
        &self,
        picker: Picker,
        trigger: MetalButton,
        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
        cx: &mut Context<Self>,
    ) -> impl IntoElement {
        Popover::new(match picker {
            Picker::Model => "model-popover",
            Picker::Reasoning => "reasoning-popover",
            Picker::Access => "access-popover",
        })
        .anchor(Anchor::BottomLeft)
        .p_0()
        .bg(gpui_kit::transparent_black())
        .shadow_none()
        .open(self.open_picker == Some(picker))
        .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx)))
        .trigger(trigger)
        .content(move |_, window, cx| content(window, cx))
    }

    fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let working = session.is_working();
        let provider = session.provider;
        let mode = session.permission_mode;
        let reasoning = session.reasoning;
        let fast_mode = session.fast_mode;
        let model = Catalog::global(cx).find(provider, session.model.as_deref()).cloned();
        let model_label: SharedString = match (&model, &session.model) {
            (Some(model), _) => model.name.clone().into(),
            (None, Some(id)) => id.clone().into(),
            (None, None) => format!("{} default", provider.label()).into(),
        };
        let has_input =
            !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty();
        let focused = self.composer.read(cx).focus_handle(cx).is_focused(window);

        let send_button = if working && !has_input {
            MetalButton::new("stop")
                .danger()
                .icon(IconName::Square)
                .tooltip("Stop (Esc)")
                .on_click(cx.listener(|this, _, _, cx| this.escape(cx)))
        } else {
            MetalButton::new("send")
                .primary()
                .icon(IconName::ArrowUp)
                .disabled(!has_input)
                .tooltip(
                    match (working, crate::settings::get(|settings| settings.send_with_ctrl_enter)) {
                        (true, false) => "Queue message (Enter)",
                        (true, true) => "Queue message (Ctrl+Enter)",
                        (false, false) => "Send (Enter)",
                        (false, true) => "Send (Ctrl+Enter)",
                    },
                )
                .on_click(cx.listener(|this, _, window, cx| this.submit(window, cx)))
        };

        // MetalUI marks focus with a green-deep ring.
        let mut card_shadow = p.raise.clone();
        if focused {
            card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.)));
        }

        let model_picker = self.model_picker.clone();
        let reasoning_session = self.session.clone();
        let access_session = self.session.clone();
        let reasoning_model = model.clone();
        let access_model = model.clone();
        let close = Self::close_picker(cx);
        let close_access = close.clone();

        let model_button = MetalButton::new("model")
            .ghost()
            .compact()
            .icon(pickers::provider_icon(provider, px(14.), p))
            .label(model_label)
            .caret();
        let reasoning_button = MetalButton::new("reasoning")
            .ghost()
            .compact()
            .icon(Lucide::Brain)
            .label(if fast_mode { format!("{} · Fast", reasoning.label()) } else { reasoning.label().to_string() })
            .caret();
        let access_mode = if provider == Provider::Pi { crate::claude::PermissionMode::BypassPermissions } else { mode };
        let access_button = MetalButton::new("access")
            .ghost()
            .compact()
            .icon(pickers::access_icon(access_mode))
            .label(access_mode.label())
            .caret();

        let toolbar = h_flex()
            .px(px(8.))
            .pt(px(4.))
            .pb(px(10.))
            .gap(px(4.))
            .child(self.picker(Picker::Model, model_button, move |_, _| model_picker.clone().into_any_element(), cx))
            .child(metal::vrule(p))
            .child(self.picker(
                Picker::Reasoning,
                reasoning_button,
                move |_, cx| {
                    let p = metal::palette(cx);
                    pickers::reasoning_menu(&reasoning_session, reasoning_model.as_ref(), close.clone(), p, cx)
                        .into_any_element()
                },
                cx,
            ))
            .child(metal::vrule(p))
            .child(self.picker(
                Picker::Access,
                access_button,
                move |_, cx| {
                    let p = metal::palette(cx);
                    pickers::access_menu(&access_session, access_model.as_ref(), close_access.clone(), p, cx)
                        .into_any_element()
                },
                cx,
            ))
            .child(div().flex_1())
            .child(
                MetalButton::new("attach")
                    .ghost()
                    .compact()
                    .icon(Lucide::Paperclip)
                    .tooltip("Attach files or images")
                    .on_click(cx.listener(|this, _, window, cx| this.pick_attachments(window, cx))),
            )
            .when(!start, |this| this.child(self.render_context_ring(cx)))
            .child(div().w(px(4.)))
            .child(send_button);

        let card = v_flex()
            .w_full()
            .rounded(px(24.))
            .bg(metal::vertical((p.s_hi, p.s)))
            .shadow(card_shadow)
            .when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx)))
            .child(
                div()
                    .px(px(12.))
                    .pt(px(10.))
                    .text_size(px(14.))
                    .text_color(p.ink)
                    .child(Textarea::new(&self.composer).appearance(false)),
            )
            .child(toolbar);

        v_flex()
            .flex_none()
            .w_full()
            .items_center()
            .px(px(24.))
            .pb(px(if start { 0. } else { 20. }))
            .gap(px(8.))
            .when(!start, |this| this.child(self.render_activity(cx)))
            .child(
                v_flex()
                    .w_full()
                    .max_w(px(CONTENT_WIDTH))
                    .items_center()
                    .child(card)
                    .when(start, |this| this.child(self.render_start_footer(cx))),
            )
    }

    /// Under the start composer: where the thread will run, and on which branch.
    fn render_start_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let cwd = self.session.read(cx).cwd.clone();
        let directory = cx.try_global::<ProjectDirectory>();
        let hostname = directory.map(|directory| directory.hostname.clone()).unwrap_or_default();
        let project = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd));
        let branch = project.and_then(|project| project.branch.clone());
        let branches = project.map(|project| project.branches.clone()).unwrap_or_default();
        let chat = cx.entity().downgrade();
        let current = branch.clone();

        h_flex()
            .w(relative(0.94))
            .h(px(38.))
            .px(px(10.))
            .gap(px(6.))
            .rounded_b(px(16.))
            .region(p)
            .child(
                h_flex()
                    .gap(px(6.))
                    .pl(px(4.))
                    .text_size(px(12.5))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(p.ink3)
                    .child(Icon::new(Lucide::Laptop).size(px(13.)))
                    .child(Icon::new(IconName::Folder).size(px(13.)))
                    .child(hostname)
                    .child(metal::tag("Local", p)),
            )
            .child(div().flex_1())
            .when_some(branch, |this, branch| {
                this.child(
                    MetalButton::new("start-branch")
                        .ghost()
                        .mini()
                        .icon(Lucide::GitBranch)
                        .label(format!("From {branch}"))
                        .caret()
                        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                            menu = menu.min_w(px(220.)).label("Start from branch");
                            for name in branches.clone() {
                                let chat = chat.clone();
                                let checked = current.as_deref() == Some(name.as_str());
                                menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click(
                                    move |_, _, cx| {
                                        let name = name.clone();
                                        let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchBranch(name)));
                                    },
                                ));
                            }
                            menu
                        }),
                )
            })
    }
}

impl Render for ChatView {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let is_empty = self.session.read(cx).items.is_empty();
        let close_diff = {
            let chat = cx.entity().downgrade();
            move |_: &mut Window, cx: &mut App| {
                let _ = chat.update(cx, |this, cx| {
                    this.diff = None;
                    cx.notify();
                });
            }
        };
        v_flex()
            .key_context("ChatView")
            .size_full()
            .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
            .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx)))
            .map(|this| match &self.diff {
                Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))),
                None if is_empty => this.child(self.render_start(window, cx)),
                None => this.child(self.render_transcript(cx)),
            })
            .when(!is_empty, |this| this.child(self.render_composer(false, window, cx)))
    }
}

/// Shortens a path under the home directory to `~/…`.
pub fn display_path(path: &std::path::Path) -> String {
    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
    match home.as_deref().and_then(|home| path.strip_prefix(home).ok()) {
        Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
        Some(rest) => format!("~/{}", rest.display()),
        None => path.display().to_string(),
    }
}

fn format_tokens(tokens: u64) -> String {
    let (value, unit) = match tokens {
        0..1_000 => return tokens.to_string(),
        1_000..1_000_000 => (tokens as f64 / 1_000., "k"),
        _ => (tokens as f64 / 1_000_000., "M"),
    };
    let value = format!("{value:.1}");
    format!("{}{unit}", value.trim_end_matches(".0"))
}

/// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
/// the theme, which `metal` keeps in step with the colorway.
fn markdown_style(p: &Palette) -> TextViewStyle {
    let mut code_block = StyleRefinement::default()
        .rounded(px(14.))
        .bg(metal::vertical((p.well_top, p.well_bot)))
        .shadow(p.well.clone());
    code_block.padding.left = Some(px(14.).into());
    code_block.padding.right = Some(px(14.).into());
    TextViewStyle {
        is_dark: p.dark,
        ..TextViewStyle::default()
    }
    .code_block(code_block)
    .table(StyleRefinement::default().rounded(px(12.)))
    .inline_code(HighlightStyle {
        background_color: Some(p.well_top),
        ..Default::default()
    })
}

/// Markdown with a copy button on every code block.
fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palette) -> TextView {
    TextView::new(state)
        .style(markdown_style(p))
        .selectable(true)
        .code_block_actions(|block, _, _| {
            let code = block.code();
            let id = {
                use std::hash::{Hash as _, Hasher as _};
                let mut hasher = std::collections::hash_map::DefaultHasher::new();
                code.hash(&mut hasher);
                hasher.finish()
            };
            MetalButton::new(ElementId::NamedInteger("copy-code".into(), id))
                .ghost()
                .mini()
                .icon(IconName::Copy)
                .tooltip("Copy")
                .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string())))
        })
}

/// The row a finished turn folds into: "Worked for 11s ›".
fn render_turn_summary(
    session: &Entity<Session>,
    scroller: &Entity<MessageScrollerState>,
    turn: &FinishedTurn,
    expanded: bool,
    p: &Palette,
) -> AnyElement {
    let end = turn.end;
    let toggle_session = session.clone();
    let scroller = scroller.clone();
    v_flex()
        .w_full()
        .gap(px(8.))
        .child(
            h_flex()
                .id(("turn-summary", end))
                .gap(px(6.))
                .text_size(px(13.5))
                .text_color(p.ink3)
                .child(format!("Worked for {}", format_duration(turn.duration_ms)))
                .when(turn.has_work, |this| {
                    this.cursor_pointer()
                        .hover(|style| style.text_color(p.ink))
                        .child(
                            Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
                                .size(px(13.)),
                        )
                        .on_click(move |_, _, cx| {
                            toggle_session.update(cx, |session, cx| session.toggle_turn(end, cx));
                            // Folding changes the height of every row in the turn.
                            scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
                        })
                })
                .child(div().flex_1())
                .when_some(
                    turn.cost.filter(|cost| *cost > 0. && crate::settings::get(|settings| settings.show_costs)),
                    |this, cost| this.child(metal::engraved(format!("${cost:.4}"), p)),
                ),
        )
        .child(metal::rule(p).w_full())
        .into_any_element()
}

/// A question from an agent extension, answered with one click.
fn render_question(session: &Entity<Session>, ix: usize, question: &Question, p: &Palette) -> AnyElement {
    v_flex()
        .w_full()
        .gap(px(10.))
        .px(px(14.))
        .py(px(12.))
        .rounded(px(14.))
        .bg(metal::vertical(p.row_on))
        .shadow({
            let mut shadow = p.raise_sm.clone();
            if question.answer.is_none() {
                shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.)));
            }
            shadow
        })
        .child(
            h_flex()
                .gap(px(8.))
                .child(metal::engraved("Question", p).text_color(p.amber_ink))
                .child(
                    div()
                        .text_size(px(13.5))
                        .font_weight(FontWeight::SEMIBOLD)
                        .text_color(p.ink)
                        .child(question.title.clone()),
                ),
        )
        .when_some(question.message.clone(), |this, message| {
            this.child(div().text_size(px(13.)).text_color(p.ink2).child(message))
        })
        .map(|this| match &question.answer {
            Some(answer) => this.child(metal::engraved(format!("Answered: {answer}"), p)),
            None => this.child(h_flex().flex_wrap().gap(px(6.)).children(question.options.iter().enumerate().map(
                |(option, label)| {
                    let session = session.clone();
                    MetalButton::new(ElementId::NamedInteger(format!("question-{ix}").into(), option as u64))
                        .compact()
                        .when(option == 0 && question.confirm, |this| this.primary())
                        .label(label.clone())
                        .on_click(move |_, _, cx| {
                            session.update(cx, |session, cx| session.answer_question(ix, option, cx))
                        })
                },
            ))),
        })
        .into_any_element()
}

/// Renders one transcript row. Runs inside the virtual list, so it only gets
/// `App` and talks to the session entity directly.
fn render_item(
    session: &Entity<Session>,
    scroller: &Entity<MessageScrollerState>,
    chat: &WeakEntity<ChatView>,
    ix: usize,
    _: &mut Window,
    cx: &mut App,
) -> AnyElement {
    let p = metal::palette(cx);
    let state = session.read(cx);
    let Some(item) = state.items.get(ix) else {
        return div().into_any_element();
    };

    // A finished turn shows only its answer (and any errors) under one
    // "Worked for" row, unless the user unfolds it. Its end shows what it
    // changed on disk.
    let turn = state.finished_turn(ix);
    // With folding off, turns start unfolded and a click folds them.
    let fold = crate::settings::get(|settings| settings.fold_turns);
    let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold);
    let summary = turn
        .as_ref()
        .filter(|turn| turn.first == ix)
        .map(|turn| render_turn_summary(session, scroller, turn, expanded, p));
    let visible = match (&turn, item) {
        (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
        (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
        (Some(turn), _) => expanded || Some(ix) == turn.answer,
        (None, _) => true,
    };
    if !visible && summary.is_none() {
        return div().into_any_element();
    }

    let content: Option<AnyElement> = visible.then(|| match item {
        ChatItem::User { text, attachments } => h_flex()
            .w_full()
            .justify_end()
            .child(
                v_flex()
                    .max_w(relative(0.8))
                    .gap(px(8.))
                    .px(px(16.))
                    .py(px(10.))
                    .rounded(px(18.))
                    .raised(p)
                    .text_size(px(14.))
                    .line_height(px(21.))
                    .text_color(p.ink)
                    .when(!text.is_empty(), |this| this.child(text.clone()))
                    .when(!attachments.is_empty(), |this| {
                        this.child(h_flex().flex_wrap().gap(px(6.)).children(attachments.iter().map(|name| {
                            h_flex()
                                .h(px(22.))
                                .px(px(8.))
                                .gap(px(6.))
                                .rounded(px(8.))
                                .well(p)
                                .text_size(px(12.))
                                .text_color(p.ink2)
                                .child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3))
                                .child(name.clone())
                        })))
                    }),
            )
            .into_any_element(),
        ChatItem::Assistant(state) => div()
            .w_full()
            .px(px(4.))
            .text_size(px(14.))
            .line_height(px(22.))
            .text_color(p.ink)
            .child(markdown(state, p))
            .into_any_element(),
        ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx),
        ChatItem::Question(question) => render_question(session, ix, question, p),
        ChatItem::Notice(NoticeKind::Info, text) => h_flex()
            .items_start()
            .gap(px(8.))
            .px(px(6.))
            .text_size(px(12.5))
            .text_color(p.ink3)
            .child(Icon::new(IconName::Info).size(px(13.)).mt(px(2.)))
            .child(div().flex_1().min_w_0().child(text.clone()))
            .into_any_element(),
        ChatItem::Notice(NoticeKind::Error, text) => h_flex()
            .items_start()
            .gap(px(10.))
            .px(px(14.))
            .py(px(11.))
            .rounded(px(14.))
            .region(p)
            .bg(p.red.opacity(0.07))
            .text_size(px(13.))
            .text_color(p.ink)
            .child(div().pt(px(5.)).child(metal::led(("notice-led", ix), Led::Failed, false, p)))
            .child(div().flex_1().min_w_0().child(text.clone()))
            .into_any_element(),
        ChatItem::TurnEnd { .. } => match state.turn_changes(ix) {
            Some(changes) => {
                let chat = chat.clone();
                changes::render_changes(
                    session,
                    ix,
                    changes,
                    move |path, window, cx| {
                        let _ = chat.update(cx, |this, cx| this.open_diff(ix, path, window, cx));
                    },
                    cx,
                )
            }
            None => div().into_any_element(),
        },
    });

    // Tool cards sit close together; a new user message starts a new turn.
    let (top, bottom) = match item {
        _ if !visible => (px(10.), px(6.)),
        ChatItem::User { .. } if ix > 0 => (px(28.), px(10.)),
        ChatItem::User { .. } => (px(20.), px(10.)),
        ChatItem::Tool(_) => (px(4.), px(4.)),
        ChatItem::TurnEnd { .. } => (px(10.), px(6.)),
        _ => (px(6.), px(6.)),
    };
    h_flex()
        .w_full()
        .justify_center()
        .px(px(28.))
        .pt(top)
        .pb(bottom)
        .child(
            v_flex()
                .w_full()
                .max_w(px(CONTENT_WIDTH))
                .gap(px(12.))
                .children(summary)
                .children(content),
        )
        .into_any_element()
}

fn render_tool(
    session: &Entity<Session>,
    ix: usize,
    tool: &ToolCall,
    cwd: &std::path::Path,
    cx: &App,
) -> AnyElement {
    let p = metal::palette(cx);
    let summary = tool_summary(&tool.name, &tool.input, cwd);
    let pending = tool
        .permission
        .as_ref()
        .is_some_and(|prompt| prompt.state == PermissionState::Pending);
    let expanded = tool.expanded || pending;

    let status: AnyElement = match (tool.state, pending) {
        (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p),
        (ToolState::Running, _) => metal::led(("tool-led", ix), Led::Live, true, p),
        (ToolState::Done, _) => Icon::new(IconName::Check)
            .size(px(13.))
            .text_color(p.green_ink)
            .into_any_element(),
        (ToolState::Error, _) => Icon::new(IconName::CircleX)
            .size(px(13.))
            .text_color(p.red)
            .into_any_element(),
    };
    let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state {
        PermissionState::Allowed => Some(("Approved", p.green_ink)),
        PermissionState::Denied => Some(("Denied", p.red)),
        PermissionState::Cancelled => Some(("Cancelled", p.ink3)),
        PermissionState::Pending => None,
    });

    let toggle_session = session.clone();
    let header = h_flex()
        .id(("tool-header", ix))
        .h(px(36.))
        .px(px(12.))
        .gap(px(10.))
        .cursor_pointer()
        .rounded(px(14.))
        .hover(|this| this.bg(p.row_hover))
        .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx)))
        .child(div().w(px(14.)).flex().justify_center().child(status))
        .child(Icon::new(tool_icon(&tool.name)).size(px(14.)).text_color(p.ink3))
        .child(
            div()
                .flex_none()
                .text_size(px(13.))
                .font_weight(FontWeight::SEMIBOLD)
                .text_color(p.ink)
                .child(tool.name.clone()),
        )
        .child(
            div()
                .flex_1()
                .min_w_0()
                .truncate()
                .font_family(metal::MONO)
                .text_size(px(11.5))
                .text_color(p.ink3)
                .child(summary),
        )
        .when(tool.subagent_tool_count > 0, |this| {
            this.child(metal::engraved(format!("{} tool uses", tool.subagent_tool_count), p))
        })
        .when_some(decision, |this, (label, color)| {
            this.child(metal::engraved(label, p).text_color(color))
        })
        .child(
            Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
                .size(px(12.))
                .text_color(p.ink3),
        );

    let mut shadow = p.raise_sm.clone();
    if pending {
        shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.)));
    }

    v_flex()
        .w_full()
        .rounded(px(14.))
        .bg(metal::vertical(p.row_on))
        .shadow(shadow)
        .child(header)
        .when(expanded, |this| this.child(render_tool_details(ix, tool, cx)))
        .when(pending, |this| this.child(render_permission_bar(session, ix, tool, cx)))
        .into_any_element()
}

fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement {
    let p = metal::palette(cx);
    // Claude and Pi name the same arguments differently.
    let field = |keys: &[&str]| tool_field(&tool.input, keys);
    let old_new = (field(&["old_string", "oldText"]), field(&["new_string", "newText"]));

    let mut sections: Vec<(&'static str, String)> = Vec::new();
    let mut plan: Option<String> = None;
    match tool.name.to_ascii_lowercase().as_str() {
        "bash" => {
            if let Some(description) = field(&["description"]) {
                sections.push(("Description", description));
            }
            sections.push(("Command", field(&["command"]).unwrap_or_default()));
        }
        "edit" if old_new.0.is_some() || old_new.1.is_some() => {
            sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
            sections.push(("Replace", old_new.0.unwrap_or_default()));
            sections.push(("With", old_new.1.unwrap_or_default()));
        }
        "write" => {
            sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
            sections.push(("Content", field(&["content"]).unwrap_or_default()));
        }
        "exitplanmode" => plan = field(&["plan"]),
        _ => {
            let input = if tool.input.is_null() && !tool.partial_input.is_empty() {
                tool.partial_input.clone()
            } else {
                serde_json::to_string_pretty(&tool.input).unwrap_or_default()
            };
            sections.push(("Input", input));
        }
    }
    if let Some(result) = &tool.result {
        sections.push((
            if tool.state == ToolState::Error { "Error" } else { "Output" },
            truncate(result, MAX_TOOL_OUTPUT),
        ));
    }

    v_flex()
        .gap(px(10.))
        .px(px(12.))
        .pb(px(12.))
        .pt(px(2.))
        .when_some(plan, |this, plan| {
            this.child(
                div()
                    .px(px(16.))
                    .py(px(12.))
                    .rounded(px(12.))
                    .well(p)
                    .text_size(px(14.))
                    .text_color(p.ink)
                    .child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)),
            )
        })
        .children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| {
            v_flex()
                .gap(px(6.))
                .child(div().px(px(2.)).child(metal::engraved(label, p)))
                .child(
                    div()
                        .id(ElementId::NamedInteger(
                            format!("tool-{ix}-section").into(),
                            section_ix as u64,
                        ))
                        .max_h(px(280.))
                        .overflow_y_scroll()
                        .px(px(12.))
                        .py(px(9.))
                        .rounded(px(12.))
                        .well(p)
                        .font_family(metal::MONO)
                        .text_size(px(11.5))
                        .line_height(px(17.))
                        .text_color(p.ink2)
                        .child(if body.is_empty() { "(empty)".to_string() } else { body }),
                )
        }))
}

fn render_permission_bar(
    session: &Entity<Session>,
    ix: usize,
    tool: &ToolCall,
    cx: &App,
) -> impl IntoElement {
    let p = metal::palette(cx);
    let always_label = tool.permission.as_ref().and_then(|prompt| prompt.always_label());
    let respond = |decision: PermissionDecision| {
        let session = session.clone();
        move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
            session.update(cx, |session, cx| session.respond_permission(ix, decision, cx));
        }
    };
    let question = match tool.name.as_str() {
        "ExitPlanMode" => "Claude finished planning. Start implementing?".to_string(),
        name => format!("Allow Claude to use {name}?"),
    };

    v_flex()
        .child(metal::rule(p))
        .child(
            h_flex()
                .gap(px(8.))
                .px(px(12.))
                .py(px(10.))
                .child(metal::engraved("Approval", p).text_color(p.amber_ink))
                .child(
                    div()
                        .flex_1()
                        .min_w_0()
                        .text_size(px(13.))
                        .font_weight(FontWeight::MEDIUM)
                        .text_color(p.ink)
                        .child(question),
                )
                .child(
                    MetalButton::new(("deny", ix))
                        .ghost()
                        .compact()
                        .label("Deny")
                        .on_click(respond(PermissionDecision::Deny)),
                )
                .when_some(always_label, |this, label| {
                    this.child(
                        MetalButton::new(("allow-always", ix))
                            .compact()
                            .label(label)
                            .on_click(respond(PermissionDecision::AllowAlways)),
                    )
                })
                .child(
                    MetalButton::new(("allow", ix))
                        .primary()
                        .compact()
                        .icon(IconName::Check)
                        .label("Allow")
                        .on_click(respond(PermissionDecision::Allow)),
                ),
        )
}

fn tool_icon(name: &str) -> IconName {
    match name.to_ascii_lowercase().as_str() {
        "bash" | "bashoutput" | "killshell" => IconName::SquareTerminal,
        "read" => IconName::FileText,
        "write" | "edit" | "multiedit" | "notebookedit" => IconName::File,
        "glob" | "grep" | "find" | "ls" => IconName::Search,
        "webfetch" | "websearch" => IconName::Globe,
        "task" | "agent" => IconName::Bot,
        "todowrite" => IconName::CircleCheck,
        "exitplanmode" => IconName::Map,
        _ => IconName::Settings2,
    }
}

fn truncate(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let mut truncated: String = text.chars().take(max_chars).collect();
    truncated.push_str("\n… (truncated)");
    truncated
}

fn format_duration(ms: u64) -> String {
    let secs = ms / 1000;
    if secs < 1 {
        format!("{ms}ms")
    } else if secs < 60 {
        format!("{:.1}s", ms as f64 / 1000.)
    } else {
        format!("{}m {}s", secs / 60, secs % 60)
    }
}