Vectrum
Sign In
1773 lines · 71.7 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, SystemTime},
};

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::clock;
use crate::git;
use crate::models::{Catalog, ModelEntry, Provider, SlashCommand};
use crate::session::{
    ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session,
    SessionEvent, ToolCall, ToolState, tool_field, tool_summary,
};
use crate::settings::{self, FollowUp, WorkspaceMode};
use crate::ui::changes::{self, DiffAction, 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: its folder, and
    /// the remote connection it's on (`None` here).
    SwitchProject(PathBuf, Option<String>),
    AddProject,
    StartWithoutProject,
    SwitchBranch(String),
    /// Run this (still empty) thread in the project folder or a new worktree.
    SetWorkspace(WorkspaceMode),
}

/// Proactive panels open the diff after a turn that changed at least this
/// many files or lines.
const PROACTIVE_FILES: usize = 3;
const PROACTIVE_LINES: u64 = 50;
/// The most rows the `/` menu lists.
const SLASH_ROWS: usize = 40;

#[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>,
    /// The highlighted row of the `/` menu.
    slash_selected: usize,
    /// The composer text the `/` menu was dismissed at, with Esc.
    slash_dismissed: Option<String>,
    /// The composer rests on one line while the conversation scrolls.
    collapsed: bool,
    _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(!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| match event {
                InputEvent::PressEnter { shift: false, secondary } => {
                    let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
                    if *secondary || !ctrl_enter {
                        // With Enter sending, Ctrl+Enter does the opposite
                        // of the follow-up setting.
                        this.submit(*secondary && !ctrl_enter, window, cx);
                    }
                }
                InputEvent::Change => {
                    this.slash_selected = 0;
                    this.expand(cx);
                    cx.notify();
                }
                InputEvent::Focus => this.expand(cx),
                _ => {}
            }),
            cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| {
                let SessionEvent::TurnChanges(end) = event;
                let big = session.read(cx).turn_changes(*end).is_some_and(|changes| {
                    changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES
                });
                if big && this.diff.is_none() && settings::get(|settings| settings.proactive_panels) {
                    this.open_diff(*end, None, 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,
            slash_selected: 0,
            slash_dismissed: None,
            collapsed: false,
            _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 = 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));
    }

    /// Sends what's in the composer. While the agent works, the follow-up
    /// setting decides between steering it now and queueing for after the
    /// turn; `opposite` flips that for this one message.
    fn submit(&mut self, opposite: bool, 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));
        let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
        self.session.update(cx, |session, cx| {
            if (session.is_working() || session.preparing) && queue_mode != opposite {
                session.queue_message(text, attachments, cx);
            } else {
                session.send_message(text, attachments, cx);
            }
        });
        self.slash_dismissed = None;
        self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
    }

    fn expand(&mut self, cx: &mut Context<Self>) {
        if self.collapsed {
            self.collapsed = false;
            cx.notify();
        }
    }

    // ---- the `/` menu ------------------------------------------------------

    /// The commands the `/` menu offers for what's typed: `/` lists commands
    /// (and skills, when set to), `$` lists skills. `None` when the composer
    /// doesn't hold a lone `/word` or `$word`.
    fn slash_matches(&self, cx: &App) -> Option<Vec<SlashCommand>> {
        let text = self.composer.read(cx).value();
        if self.slash_dismissed.as_deref() == Some(text.as_ref()) {
            return None;
        }
        let trigger = text.chars().next().filter(|c| matches!(c, '/' | '$'))?;
        let query = &text[1..];
        if query.contains(char::is_whitespace) {
            return None;
        }
        let query = query.to_lowercase();
        let skills_too = settings::get(|settings| settings.slash_skills);
        let instance = self.session.read(cx).instance.clone();
        let commands = Catalog::global(cx).commands(&instance);
        let wanted = |command: &&SlashCommand| match trigger {
            '$' => command.skill,
            _ => !command.skill || skills_too,
        };
        let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands
            .iter()
            .filter(wanted)
            .filter(|command| command.name.to_lowercase().contains(&query))
            .cloned()
            .partition(|command| command.name.to_lowercase().starts_with(&query));
        prefix.append(&mut inside);
        prefix.truncate(SLASH_ROWS);
        Some(prefix).filter(|matches| !matches.is_empty())
    }

    /// Puts the picked command in the composer, ready for its arguments.
    fn accept_slash(&mut self, command: &SlashCommand, window: &mut Window, cx: &mut Context<Self>) {
        let text = format!("/{} ", command.name);
        self.composer.update(cx, |composer, cx| {
            composer.set_value(text, window, cx);
            composer.focus(window, cx);
        });
        self.slash_selected = 0;
        cx.notify();
    }

    /// Keys the `/` menu takes before the composer sees them, and
    /// Ctrl+Shift+Enter, which sends against the follow-up setting when
    /// Ctrl+Enter is the send key.
    fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        let keystroke = &event.keystroke;
        let modifiers = &keystroke.modifiers;
        if keystroke.key == "enter" && modifiers.control && modifiers.shift {
            cx.stop_propagation();
            self.submit(true, window, cx);
            return;
        }
        let Some(matches) = self.slash_matches(cx) else { return };
        if modifiers.control || modifiers.alt || modifiers.shift {
            return;
        }
        match keystroke.key.as_str() {
            "up" => self.slash_selected = (self.slash_selected + matches.len() - 1) % matches.len(),
            "down" => self.slash_selected = (self.slash_selected + 1) % matches.len(),
            "enter" | "tab" => {
                let command = matches[self.slash_selected.min(matches.len() - 1)].clone();
                cx.stop_propagation();
                self.accept_slash(&command, window, cx);
                return;
            }
            "escape" => self.slash_dismissed = Some(self.composer.read(cx).value().to_string()),
            _ => return,
        }
        cx.stop_propagation();
        cx.notify();
    }

    fn render_slash_menu(&self, matches: Vec<SlashCommand>, cx: &mut Context<Self>) -> Div {
        let p = metal::palette(cx);
        let selected = self.slash_selected.min(matches.len().saturating_sub(1));
        pickers::panel(p)
            .w_full()
            .mb(px(8.))
            .child(
                v_flex()
                    .id("slash-menu")
                    .max_h(px(280.))
                    .overflow_y_scroll()
                    .gap(px(1.))
                    .children(matches.into_iter().enumerate().map(|(ix, command)| {
                        let pick = command.clone();
                        h_flex()
                            .id(("slash-command", ix))
                            .gap(px(10.))
                            .px(px(10.))
                            .py(px(6.))
                            .rounded(px(10.))
                            .cursor_pointer()
                            .when(ix == selected, |this| this.bg(p.menu_row_hover))
                            .hover(|style| style.bg(p.menu_row_hover))
                            .on_click(cx.listener(move |this, _, window, cx| this.accept_slash(&pick, window, cx)))
                            .child(
                                div()
                                    .flex_none()
                                    .font_family(metal::MONO)
                                    .text_size(px(12.5))
                                    .text_color(p.ink)
                                    .child(format!("/{}", command.name)),
                            )
                            .when(!command.argument_hint.is_empty(), |this| {
                                this.child(
                                    div()
                                        .flex_none()
                                        .font_family(metal::MONO)
                                        .text_size(px(11.5))
                                        .text_color(p.ink3)
                                        .child(command.argument_hint.clone()),
                                )
                            })
                            .child(
                                div()
                                    .flex_1()
                                    .min_w_0()
                                    .truncate()
                                    .text_size(px(12.5))
                                    .text_color(p.ink3)
                                    .child(command.description.clone()),
                            )
                            .when(command.skill, |this| this.child(metal::tag("Skill", p)))
                    })),
            )
    }

    fn escape(&mut self, cx: &mut Context<Self>) {
        if self.slash_matches(cx).is_some() {
            self.slash_dismissed = Some(self.composer.read(cx).value().to_string());
            cx.notify();
            return;
        }
        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 Some(changes) = self.session.read(cx).turn_changes(end) else { return };
        let title = match &path {
            Some(path) => path.clone(),
            None => format!("{} changed files", changes.files.len()),
        };
        let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| {
            (settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed)
        });
        self.diff = Some(DiffView {
            title: title.into(),
            end,
            path,
            ignore_whitespace,
            side_by_side,
            start_collapsed: collapsed,
            ..Default::default()
        });
        self.load_diff(window, cx);
        cx.notify();
    }

    /// Reads the open diff in the background, as its whitespace toggle asks.
    fn load_diff(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(diff) = &self.diff else { return };
        let session = self.session.read(cx);
        let Some(changes) = session.turn_changes(diff.end) else { return };
        if session.is_remote() {
            // The snapshots live in the host's repository.
            let note = Err("This thread runs on another device; its diffs open there.".to_string());
            if let Some(diff) = self.diff.as_mut() {
                diff.set_files(note);
            }
            return;
        }
        let (base, head) = (changes.base.clone(), changes.head.clone());
        let cwd = session.cwd.clone();
        let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace);
        let task = cx.background_executor().spawn({
            let path = path.clone();
            async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) }
        });
        cx.spawn_in(window, async move |this, cx| {
            let files = task.await;
            let _ = this.update(cx, |this, cx| {
                // Unless the diff was closed, replaced or toggled meanwhile.
                if let Some(diff) = this.diff.as_mut()
                    && (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace)
                {
                    diff.set_files(files);
                    cx.notify();
                }
            });
        })
        .detach();
    }

    fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context<Self>) {
        let Some(diff) = self.diff.as_mut() else { return };
        match action {
            DiffAction::Close => self.diff = None,
            DiffAction::ToggleWhitespace => {
                diff.ignore_whitespace = !diff.ignore_whitespace;
                self.load_diff(window, cx);
            }
            DiffAction::SetSideBySide(side_by_side) => {
                diff.side_by_side = side_by_side;
                settings::update(|settings| settings.diff_side_by_side = side_by_side);
            }
            DiffAction::ToggleFile(path) => diff.toggle_file(path),
            DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed),
        }
        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.project.clone();
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let directory = cx.try_global::<ProjectDirectory>();
        let current = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
        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, Option<String>)> = directory
            .map(|directory| {
                directory
                    .projects
                    .iter()
                    .filter(|project| !project.scratch)
                    .map(|project| {
                        let name = match &project.host {
                            Some(host) => format!("{} · {host}", project.name).into(),
                            None => project.name.clone(),
                        };
                        (name, project.path.clone(), project.environment.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, project_env) in projects.clone() {
                let chat = chat.clone();
                let checked = path == cwd && project_env == env;
                menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| {
                    let (path, project_env) = (path.clone(), project_env.clone());
                    let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path, project_env)));
                }));
            }
            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();
        div()
            .flex_1()
            .min_h_0()
            .w_full()
            .flex()
            .flex_col()
            // Scrolling rests the composer, when set to.
            .on_scroll_wheel(cx.listener(|this, _, _, cx| {
                let empty = this.composer.read(cx).value().is_empty() && this.attachments.is_empty();
                if !this.collapsed && empty && settings::get(|settings| settings.collapse_composer) {
                    this.collapsed = true;
                    cx.notify();
                }
            }))
            .child(
                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),
            )
    }

    /// Above the composer: the usage limit that stopped the thread, and when
    /// it continues.
    fn render_limit(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let resets = session.limited_until()?;
        let resume_at = session.resume_at;
        let session = self.session.clone();
        Some(
            h_flex()
                .w_full()
                .max_w(px(CONTENT_WIDTH))
                .px(px(14.))
                .py(px(9.))
                .gap(px(10.))
                .rounded(px(14.))
                .region(p)
                .child(div().pt(px(1.)).child(metal::led("limit-led", Led::Waiting, false, p)))
                .child(
                    div()
                        .flex_1()
                        .min_w_0()
                        .text_size(px(13.))
                        .text_color(p.ink2)
                        .child(match resume_at {
                            Some(at) => format!("Usage limit reached. The thread continues at {}.", clock::when(at)),
                            None => format!("Usage limit reached. It resets at {}.", clock::when(resets)),
                        }),
                )
                .child(match resume_at {
                    Some(_) => MetalButton::new("cancel-resume")
                        .ghost()
                        .compact()
                        .label("Cancel")
                        .tooltip("Don't continue on its own")
                        .on_click(move |_, _, cx| session.update(cx, |session, cx| session.cancel_resume(cx))),
                    None => MetalButton::new("schedule-resume")
                        .compact()
                        .icon(Lucide::AlarmClock)
                        .label("Continue then")
                        .on_click(move |_, _, cx| {
                            session.update(cx, |session, cx| {
                                session.schedule_resume(resets + Duration::from_secs(60), cx)
                            })
                        }),
                })
                .into_any_element(),
        )
    }

    /// Messages waiting for the turn to end, each with a way to send it now.
    fn render_queue(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
        let p = metal::palette(cx);
        let queue = self.session.read(cx).queue.clone();
        if queue.is_empty() {
            return None;
        }
        Some(
            v_flex()
                .w_full()
                .max_w(px(CONTENT_WIDTH))
                .gap(px(4.))
                .children(queue.into_iter().enumerate().map(|(ix, message)| {
                    let steer = self.session.clone();
                    let remove = self.session.clone();
                    let text = message.text.lines().next().unwrap_or_default().to_string();
                    h_flex()
                        .h(px(32.))
                        .pl(px(12.))
                        .pr(px(4.))
                        .gap(px(8.))
                        .rounded(px(12.))
                        .well(p)
                        .child(Icon::new(Lucide::ListOrdered).size(px(13.)).text_color(p.ink3))
                        .child(metal::engraved("Queued", p))
                        .child(div().flex_1().min_w_0().truncate().text_size(px(13.)).text_color(p.ink2).child(text))
                        .when(!message.attachments.is_empty(), |this| {
                            this.child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3))
                        })
                        .child(
                            MetalButton::new(("steer-queued", ix))
                                .ghost()
                                .mini()
                                .label("Steer")
                                .tooltip("Send it now, mid-turn")
                                .on_click(move |_, _, cx| steer.update(cx, |session, cx| session.steer_queued(ix, cx))),
                        )
                        .child(
                            MetalButton::new(("remove-queued", ix))
                                .ghost()
                                .mini()
                                .icon(IconName::Close)
                                .tooltip("Remove")
                                .on_click(move |_, _, cx| remove.update(cx, |session, cx| session.remove_queued(ix, cx))),
                        )
                }))
                .into_any_element(),
        )
    }

    /// 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();
        let preparing = session.preparing;

        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 preparing {
                    (Led::Live, "Creating worktree")
                } 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 catalog = Catalog::global(cx);
        let model = catalog.find(&session.instance, 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", catalog.label(&session.instance)).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 collapsed = self.collapsed && !start;
        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,
                        settings::get(|settings| settings.follow_up),
                        settings::get(|settings| settings.send_with_ctrl_enter),
                    ) {
                        (true, FollowUp::Queue, false) => "Queue for after this turn (Enter)",
                        (true, FollowUp::Queue, true) => "Queue for after this turn (Ctrl+Enter)",
                        (true, FollowUp::Steer, false) => "Steer the running turn (Enter)",
                        (true, FollowUp::Steer, true) => "Steer the running turn (Ctrl+Enter)",
                        (false, _, false) => "Send (Enter)",
                        (false, _, true) => "Send (Ctrl+Enter)",
                    },
                )
                .on_click(cx.listener(|this, _, window, cx| this.submit(false, 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.)));

        let textarea = div()
            .px(px(12.))
            .text_size(px(14.))
            .text_color(p.ink)
            .child(Textarea::new(&self.composer).appearance(false));
        let card = if collapsed {
            // Resting: one line, a click (or typing) away from the full composer.
            h_flex()
                .id("composer-collapsed")
                .w_full()
                .h(px(48.))
                .pr(px(8.))
                .rounded(px(24.))
                .bg(metal::vertical((p.s_hi, p.s)))
                .shadow(card_shadow)
                .cursor_text()
                .on_click(cx.listener(|this, _, window, cx| {
                    this.expand(cx);
                    this.focus_composer(window, cx);
                }))
                .child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden())
                .child(send_button)
                .into_any_element()
        } else {
            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(textarea.pt(px(10.)))
                .child(toolbar.child(send_button))
                .into_any_element()
        };
        let slash_menu = match self.slash_matches(cx) {
            Some(matches) => Some(self.render_slash_menu(matches, cx).into_any_element()),
            None => None,
        };
        let above: Vec<AnyElement> = if start {
            Vec::new()
        } else {
            let mut above: Vec<AnyElement> = self.render_limit(cx).into_iter().chain(self.render_queue(cx)).collect();
            above.push(self.render_activity(cx).into_any_element());
            above
        };

        v_flex()
            .flex_none()
            .w_full()
            .items_center()
            .px(px(24.))
            .pb(px(if start { 0. } else { 20. }))
            .gap(px(8.))
            .children(above)
            .child(
                v_flex()
                    .w_full()
                    .max_w(px(CONTENT_WIDTH))
                    .items_center()
                    .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
                        this.composer_key(event, window, cx)
                    }))
                    .children(slash_menu)
                    .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 session = self.session.read(cx);
        let cwd = session.project.clone();
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let directory = cx.try_global::<ProjectDirectory>();
        let project = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
        let remote_host = project.and_then(|project| project.host.clone());
        let is_remote = remote_host.is_some();
        let hostname = remote_host.unwrap_or_else(|| directory.map(|directory| directory.hostname.clone()).unwrap_or_default());
        let plan = self.session.read(cx).pending_worktree.clone();
        let branch = match &plan {
            Some(plan) => plan.base.clone().or_else(|| project.and_then(|project| project.branch.clone())),
            None => project.and_then(|project| project.branch.clone()),
        };
        let branches = project.map(|project| project.branches.clone()).unwrap_or_default();
        let can_worktree = project.is_some_and(|project| project.git && !project.scratch);
        let mode = if plan.is_some() { WorkspaceMode::Worktree } else { WorkspaceMode::Local };
        let chat = cx.entity().downgrade();
        let workspace_chat = chat.clone();
        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(if is_remote { Lucide::Server } else { Lucide::Laptop }).size(px(13.)))
                    .child(Icon::new(IconName::Folder).size(px(13.)))
                    .child(hostname)
                    .child(metal::tag(if is_remote { "Remote" } else { "Local" }, p)),
            )
            .child(div().flex_1())
            .when(can_worktree, |this| {
                this.child(metal::segmented(
                    "start-workspace",
                    &[(WorkspaceMode::Local, "Local"), (WorkspaceMode::Worktree, "New worktree")],
                    mode,
                    p,
                    move |mode, _, cx| {
                        let _ = workspace_chat.update(cx, |_, cx| cx.emit(ChatEvent::SetWorkspace(mode)));
                    },
                ))
            })
            .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 on_diff = {
            let chat = cx.entity().downgrade();
            move |action: DiffAction, window: &mut Window, cx: &mut App| {
                let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx));
            }
        };
        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, on_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(),
    }
}

/// A turn's end time: the clock today, and the day before that.
fn clock_short(time: SystemTime) -> String {
    let age = time.elapsed().unwrap_or_default();
    if age < Duration::from_secs(12 * 3_600) { clock::clock(time) } else { clock::when(time) }
}

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)))
                .child(div().text_size(px(12.)).child(format!("· {}", clock_short(turn.at))))
                .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. && 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 = 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(answer) => div()
            .w_full()
            .px(px(4.))
            .text_size(px(14.))
            .line_height(px(22.))
            .text_color(p.ink)
            .child(markdown(&answer.view, 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)
    }
}