Vectrum
Sign In
1213 lines · 50.7 KB · Rust
//! The conversation, laid out like T3 Code's timeline: user messages as
//! bubbles with their time and a copy button on hover, the agent's answers,
//! its tool calls as compact work-log rows (consecutive ones grouped under
//! one summary, "Ran 2 commands and read 3 files"), a finished turn folded
//! under "Worked for 2m 14s", Claude's plans as cards, and notices as
//! dividers.

use std::collections::HashSet;
use std::path::Path;
use std::time::SystemTime;

use serde_json::Value;

use gpui_kit::component::{
    Icon, IconName, h_flex,
    menu::{DropdownMenu as _, PopupMenuItem},
    message_scroller::MessageScrollerState,
    text::TextView,
    tooltip::Tooltip,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::{ChatView, MAX_TOOL_OUTPUT, markdown, markdown_style, truncate};
use crate::clock;
use crate::session::{
    ChatItem, FinishedTurn, NoticeKind, PermissionState, Question, Session, TaskStatus, ToolCall, ToolState,
    UserQuestions, tool_field,
};
use crate::settings;
use crate::ui::changes;
use crate::ui::composer;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};

/// A user message longer than this folds behind "Show full message".
const LONG_MESSAGE_CHARS: usize = 600;
const LONG_MESSAGE_LINES: usize = 8;
/// A plan longer than this folds behind "Expand plan".
const LONG_PLAN_CHARS: usize = 900;
const LONG_PLAN_LINES: usize = 20;

/// What a tool call does, as T3 Code sums up a group of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
    Read,
    Edit,
    Command,
    WebSearch,
    CodeSearch,
    Other,
}

impl Action {
    /// The icon a row or group of this kind shows.
    fn icon(self) -> Icon {
        match self {
            Action::Read => Icon::new(IconName::Eye),
            Action::Edit => Icon::new(Lucide::SquarePen),
            Action::Command => Icon::new(Lucide::Terminal),
            Action::WebSearch => Icon::new(IconName::Globe),
            Action::CodeSearch => Icon::new(IconName::Search),
            Action::Other => Icon::new(Lucide::Wrench),
        }
    }

    /// `Ran 2 commands`, `Read 1 file`.
    fn summary(self, count: usize) -> String {
        let s = |one: &str, many: &str| if count == 1 { one.to_string() } else { many.to_string() };
        match self {
            Action::Read => format!("Read {count} {}", s("file", "files")),
            Action::Edit => format!("Changed {count} {}", s("file", "files")),
            Action::Command => format!("Ran {count} {}", s("command", "commands")),
            Action::WebSearch => format!("Searched the web {count} {}", s("time", "times")),
            Action::CodeSearch => format!("Searched code {count} {}", s("time", "times")),
            Action::Other => format!("Used {count} {}", s("tool", "tools")),
        }
    }

    /// Commands and edits lead a group's summary, odds and ends trail it.
    fn priority(self) -> u8 {
        match self {
            Action::Command | Action::Edit => 0,
            Action::Other => 2,
            _ => 1,
        }
    }
}

/// How a tool call reads in the timeline.
struct Presentation {
    icon: Icon,
    label: String,
    action: Action,
}

/// A path relative to the thread's folder.
fn relative_path(path: &str, cwd: &Path) -> String {
    Path::new(path).strip_prefix(cwd).map(|relative| relative.display().to_string()).unwrap_or_else(|_| path.to_string())
}

fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
    present_call(&tool.name, &tool.input, cwd)
}

/// A tool call's icon and label as the timeline shows them, for a call
/// known only by its name and input (a subagent's).
pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, String) {
    let presentation = present_call(name, input, cwd);
    (presentation.icon, presentation.label)
}

fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
    let field = |keys: &[&str]| tool_field(input, keys);
    let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>();
    // The app's own subagent tools, under whatever prefix the agent gives them.
    if let Some(tool) = crate::orchestrator::own_tool(name) {
        use crate::orchestrator::{CAPABILITIES, DELEGATE, TASK_CANCEL};
        let label = match tool {
            DELEGATE => field(&["title"]).or_else(|| field(&["task"])).map(first_line).unwrap_or_else(|| "Subagent".into()),
            TASK_CANCEL => "Stopped a subagent".into(),
            CAPABILITIES => "Looked up agents and models".into(),
            _ => "Checked on a subagent".into(),
        };
        return Presentation { icon: Icon::new(IconName::Bot), label, action: Action::Other };
    }
    // The app's device tools: device activity in the thread.
    if let Some(tool) = crate::orchestrator::device_tool(name) {
        use crate::orchestrator::{DEVICE_CLOSE, DEVICE_LIST, DEVICE_OPEN};
        let target = field(&["deviceId"]).or_else(|| field(&["platform"]).map(|platform| match platform.as_str() {
            "ios" => "an iOS Simulator".to_string(),
            "android" => "an Android Emulator".to_string(),
            _ => platform,
        }));
        let label = match tool {
            DEVICE_LIST => "Listed devices".into(),
            DEVICE_OPEN => target.map(|target| format!("Opened {target}")).unwrap_or_else(|| "Opened a device".into()),
            DEVICE_CLOSE if input.get("shutdown").and_then(Value::as_bool) == Some(true) => {
                target.map(|target| format!("Powered off {target}")).unwrap_or_else(|| "Powered off devices".into())
            }
            DEVICE_CLOSE => target.map(|target| format!("Closed {target}")).unwrap_or_else(|| "Closed devices".into()),
            _ => "Took a device screenshot".into(),
        };
        return Presentation { icon: Icon::new(Lucide::Smartphone), label, action: Action::Other };
    }
    let (action, label) = match name.to_ascii_lowercase().as_str() {
        "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
        "bashoutput" | "killshell" => (Action::Command, name.to_string()),
        "read" | "notebookread" => (
            Action::Read,
            field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()),
        ),
        "write" | "edit" | "multiedit" | "notebookedit" => (
            Action::Edit,
            field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()),
        ),
        "grep" | "glob" | "find" => (
            Action::CodeSearch,
            field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()),
        ),
        "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())),
        "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
        "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
        "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())),
        // Pi's `subagent` extension (one task, parallel tasks or a chain),
        // and jcode's old subagent tool.
        "subagent" => {
            let several = input.get("tasks").or_else(|| input.get("chain")).and_then(Value::as_array).map(Vec::len);
            let label = match several {
                Some(count) => format!("{count} subagents"),
                None => field(&["description", "task"]).map(first_line).unwrap_or_else(|| "Subagent".into()),
            };
            (Action::Other, label)
        }
        // jcode's swarm: spawning an agent, or talking to the swarm.
        "swarm" => {
            let action = field(&["action"]).unwrap_or_default();
            let label = match field(&["label", "prompt"]).map(first_line) {
                Some(label) if action == "spawn" => format!("Spawn agent: {label}"),
                _ if action == "spawn" => "Spawn agent".into(),
                _ if action.is_empty() => "Swarm".into(),
                _ => format!("Swarm: {}", action.replace('_', " ")),
            };
            (Action::Other, label)
        }
        "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())),
        _ if name.starts_with("mcp__") => {
            let mut parts = name.trim_start_matches("mcp__").splitn(2, "__");
            let server = parts.next().unwrap_or_default().to_string();
            let tool_name = parts.next().unwrap_or_default().replace('_', " ");
            (Action::Other, format!("{server}: {tool_name}"))
        }
        _ => {
            let summary = crate::session::tool_summary(name, input, cwd);
            (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") })
        }
    };
    let icon = match name.to_ascii_lowercase().as_str() {
        "task" | "agent" | "subagent" | "swarm" => Icon::new(IconName::Bot),
        "monitor" => Icon::new(IconName::Eye),
        _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer),
        _ => action.icon(),
    };
    Presentation { icon, label, action }
}

/// The program a command runs, for "Running npm".
fn program(tool: &ToolCall) -> Option<String> {
    let command = tool_field(&tool.input, &["command"])?;
    command
        .split_whitespace()
        .find(|word| !word.contains('=') && !matches!(*word, "sudo" | "env" | "time" | "cd"))
        .map(|word| word.rsplit('/').next().unwrap_or(word).to_string())
}

/// T3 Code's summary of a group of tool calls: up to two kinds, then the
/// rest. "Ran 2 commands and read 3 files", "Changed 1 file, ran 4
/// commands, and performed 2 other actions".
fn group_summary(actions: &[Action]) -> String {
    let mut counts: Vec<(Action, usize)> = Vec::new();
    for action in actions {
        match counts.iter_mut().find(|(known, _)| known == action) {
            Some((_, count)) => *count += 1,
            None => counts.push((*action, 1)),
        }
    }
    counts.sort_by_key(|(action, _)| action.priority());
    let mut parts: Vec<String> = counts.iter().take(2).map(|(action, count)| action.summary(*count)).collect();
    let rest: usize = counts.iter().skip(2).map(|(_, count)| count).sum();
    if rest > 0 {
        parts.push(format!("Performed {rest} other {}", if rest == 1 { "action" } else { "actions" }));
    }
    let parts: Vec<String> = parts
        .into_iter()
        .enumerate()
        .map(|(ix, part)| {
            if ix == 0 {
                return part;
            }
            let mut chars = part.chars();
            chars.next().map(|first| first.to_lowercase().collect::<String>() + chars.as_str()).unwrap_or_default()
        })
        .collect();
    match parts.len() {
        0 => String::new(),
        1 => parts[0].clone(),
        2 => format!("{} and {}", parts[0], parts[1]),
        n => format!("{}, and {}", parts[..n - 1].join(", "), parts[n - 1]),
    }
}

/// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations.
pub(crate) fn format_duration(ms: u64) -> String {
    if ms < 1_000 {
        return format!("{}ms", ms.max(1));
    }
    if ms < 10_000 {
        return format!("{:.1}s", ms as f64 / 1_000.).replace(".0s", "s");
    }
    let secs = ms / 1_000;
    if secs < 60 {
        return format!("{secs}s");
    }
    let (hours, minutes, seconds) = (secs / 3_600, secs % 3_600 / 60, secs % 60);
    [(hours, "h"), (minutes, "m"), (seconds, "s")]
        .iter()
        .filter(|(value, _)| *value > 0)
        .map(|(value, unit)| format!("{value}{unit}"))
        .collect::<Vec<_>>()
        .join(" ")
}

/// Whether an item shows as a work-log row.
fn is_work(item: &ChatItem) -> bool {
    matches!(item, ChatItem::Tool(tool) if tool.ask.is_none() && !matches!(tool.name.as_str(), "ExitPlanMode" | "TodoWrite"))
}

/// Items the timeline leaves out altogether (the task list lives above the
/// composer), and which don't break a group of tool calls.
fn is_hidden(item: &ChatItem) -> bool {
    matches!(item, ChatItem::Tool(tool) if tool.name == "TodoWrite")
}

/// The run of consecutive tool calls `ix` belongs to: its work rows' indices.
pub(super) fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
    let mut start = ix;
    while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) {
        start -= 1;
    }
    let mut end = ix;
    while end + 1 < items.len() && (is_work(&items[end + 1]) || is_hidden(&items[end + 1])) {
        end += 1;
    }
    (start..=end).filter(|&member| is_work(&items[member])).collect()
}

/// A work-log row: a 24px icon slot, the label, and whatever trails it.
fn work_row(id: impl Into<ElementId>, icon: Icon, icon_color: Hsla, p: &Palette) -> Stateful<Div> {
    h_flex()
        .id(id)
        .w_full()
        .min_w_0()
        .min_h(px(24.))
        .px(px(2.))
        .gap(px(6.))
        .rounded(px(8.))
        .text_size(px(14.))
        .line_height(px(22.))
        .child(
            div()
                .flex()
                .flex_none()
                .items_center()
                .justify_center()
                .size(px(24.))
                .child(icon.size(px(16.)).text_color(icon_color)),
        )
        .text_color(p.ink2)
}

/// The chevron a row that unfolds ends with.
fn row_chevron(expanded: bool, visible: bool, p: &Palette) -> Div {
    div()
        .flex()
        .flex_none()
        .items_center()
        .justify_center()
        .size(px(16.))
        .when(visible, |this| {
            this.child(
                Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
                    .size(px(12.))
                    .text_color(p.ink3.opacity(0.7)),
            )
        })
}

/// A time that shows while the pointer is over its row.
fn hover_time(group: &'static str, time: SystemTime, p: &Palette) -> Stateful<Div> {
    let tooltip: SharedString = clock::long_form(time).into();
    div()
        .id(SharedString::from(format!("{group}-time")))
        .mr(px(4.))
        .flex_none()
        .text_size(px(12.))
        .text_color(p.ink3)
        .opacity(0.)
        .group_hover(group, |style| style.opacity(1.))
        .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
        .child(clock::day_aware(time))
}

/// The copy button under a message.
fn copy_button(id: impl Into<ElementId>, text: String) -> MetalButton {
    MetalButton::new(id).ghost().mini().size(px(24.)).icon(IconName::Copy).tooltip("Copy message").on_click(move |_, _, cx| {
        cx.write_to_clipboard(ClipboardItem::new_string(text.clone()));
    })
}

/// The ChatView's fold state for groups and long messages, and the find
/// bar's matches.
#[allow(clippy::type_complexity)]
fn view_state(chat: &WeakEntity<ChatView>, cx: &App) -> (HashSet<usize>, HashSet<usize>, Option<(Vec<usize>, Option<usize>)>) {
    chat.upgrade()
        .map(|chat| {
            let chat = chat.read(cx);
            (chat.expanded_groups.clone(), chat.expanded_messages.clone(), chat.find_marks())
        })
        .unwrap_or_default()
}

/// Renders one transcript row. Runs inside the virtual list, so it only gets
/// `App` and talks to the session entity directly.
pub(super) 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();
    };
    if is_hidden(item) {
        return div().into_any_element();
    }
    let (expanded_groups, expanded_messages, marks) = view_state(chat, cx);
    let marked = marks.as_ref().filter(|(matches, _)| matches.contains(&ix)).map(|(_, current)| *current == Some(ix));

    // 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_fold(session, scroller, turn, expanded, p));
    let visible = match (&turn, item) {
        // A subagent's result, which older threads kept as a message.
        (_, ChatItem::User { text, .. }) if text.starts_with(crate::orchestrator::RESULT_PREFIX) => false,
        (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
        (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
        (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
        (Some(turn), _) => expanded || Some(ix) == turn.answer,
        (None, _) => true,
    };
    // The live turn opens under its user message with "Working for 12s".
    let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. }));
    let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention())
        .then(|| render_working(state, p));
    if !visible && summary.is_none() && working.is_none() {
        return div().into_any_element();
    }

    let mut bottom = px(8.);
    let content: Option<AnyElement> = if !visible {
        None
    } else {
        Some(match item {
            ChatItem::User { text, attachments, at } => {
                bottom = px(16.);
                render_user(chat, ix, text, attachments, *at, expanded_messages.contains(&ix), p)
            }
            ChatItem::Assistant(answer) => {
                let meta = turn.as_ref().filter(|turn| turn.answer == Some(ix)).map(|turn| turn.at);
                if meta.is_some() {
                    bottom = px(16.);
                }
                let icx = crate::ui::inline::InlineCx { session: session.entity_id(), ix, scroller: scroller.downgrade() };
                render_assistant(ix, &answer.text, &answer.view, icx, meta, p)
            }
            ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p),
            ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => {
                bottom = px(16.);
                render_plan(session, ix, tool, &state.cwd, p)
            }
            ChatItem::Tool(_) => {
                bottom = px(0.);
                render_work(session, scroller, chat, ix, &state.items, &state.cwd, &expanded_groups, state.is_working(), p, cx)
            }
            ChatItem::Question(question) => render_extension_record(question, p),
            ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p),
            ChatItem::Notice(NoticeKind::Info, text) => render_divider(text, p),
            ChatItem::Notice(NoticeKind::Error, text) => render_error(text, p),
            ChatItem::TurnEnd { .. } => {
                bottom = px(16.);
                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(),
                }
            }
        })
    };

    let top = match item {
        ChatItem::User { .. } if ix == 0 => px(16.),
        _ => px(0.),
    };
    h_flex()
        .w_full()
        .justify_center()
        .px(px(28.))
        .pt(top)
        .pb(if content.is_none() { px(6.) } else { bottom })
        .child(
            v_flex()
                .w_full()
                .max_w(composer::content_width())
                .map(|this| match item {
                    ChatItem::TaskEvent { .. } => this.children(content).children(summary.map(|summary| div().pt(px(8.)).child(summary))),
                    _ => this.children(summary.map(|summary| div().pb(px(6.)).child(summary))).children(content.map(|content| {
                        match marked {
                            Some(current) => super::find::mark(div().p(px(4.)), current, p).child(content).into_any_element(),
                            None => content,
                        }
                    })),
                })
                .children(working),
        )
        .into_any_element()
}

/// A user message: a bubble on the right, its time and a copy button
/// under it on hover. A long one folds behind "Show full message".
fn render_user(
    chat: &WeakEntity<ChatView>,
    ix: usize,
    text: &SharedString,
    attachments: &[SharedString],
    at: SystemTime,
    unfolded: bool,
    p: &Palette,
) -> AnyElement {
    let group: &'static str = "user-row";
    let long = text.chars().count() > LONG_MESSAGE_CHARS || text.lines().count() > LONG_MESSAGE_LINES;
    let shown: String = if long && !unfolded {
        let mut shown: String = text.lines().take(LONG_MESSAGE_LINES).collect::<Vec<_>>().join("\n");
        if shown.chars().count() > LONG_MESSAGE_CHARS {
            shown = shown.chars().take(LONG_MESSAGE_CHARS).collect();
        }
        shown.push('…');
        shown
    } else {
        text.to_string()
    };
    let toggle = chat.clone();
    v_flex()
        .w_full()
        .items_end()
        .gap(px(4.))
        .group(group)
        .child(
            v_flex()
                .max_w(relative(0.8))
                .p(px(12.))
                .rounded(px(18.))
                .raised(p)
                .text_size(px(14.))
                .line_height(px(22.))
                .text_color(p.ink)
                .when(!attachments.is_empty(), |this| {
                    this.child(v_flex().mb(px(8.)).gap(px(4.)).children(attachments.iter().map(|name| {
                        h_flex()
                            .gap(px(8.))
                            .py(px(4.))
                            .text_size(px(14.))
                            .child(Icon::new(IconName::File).size(px(16.)).text_color(p.ink3))
                            .child(div().min_w_0().truncate().child(name.clone()))
                    })))
                })
                .when(!text.is_empty(), |this| this.child(shown))
                .when(long, |this| {
                    this.child(
                        h_flex().mt(px(6.)).justify_end().child(
                            MetalButton::new(("user-unfold", ix))
                                .ghost()
                                .mini()
                                .label(if unfolded { "Show less" } else { "Show full message" })
                                .on_click(move |_, _, cx| {
                                    let _ = toggle.update(cx, |this, cx| {
                                        if !this.expanded_messages.remove(&ix) {
                                            this.expanded_messages.insert(ix);
                                        }
                                        this.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
                                        cx.notify();
                                    });
                                }),
                        ),
                    )
                }),
        )
        .child(
            h_flex()
                .max_w(relative(0.8))
                .justify_end()
                .pr(px(4.))
                .gap(px(8.))
                .text_size(px(12.))
                .opacity(0.)
                .group_hover(group, |style| style.opacity(1.))
                .child(
                    div()
                        .id(("user-time", ix))
                        .text_color(p.ink3)
                        .tooltip({
                            let tooltip: SharedString = clock::long_form(at).into();
                            move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)
                        })
                        .child(clock::day_aware(at)),
                )
                .child(copy_button(("user-copy", ix), text.to_string())),
        )
        .into_any_element()
}

/// The agent's message. The turn's answer shows a copy button and its time
/// on hover.
fn render_assistant(
    ix: usize,
    text: &str,
    view: &Entity<gpui_kit::component::text::TextViewState>,
    icx: crate::ui::inline::InlineCx,
    meta: Option<SystemTime>,
    p: &Palette,
) -> AnyElement {
    let group: &'static str = "assistant-row";
    v_flex()
        .w_full()
        .group(group)
        .child(
            div()
                .w_full()
                .px(px(4.))
                .py(px(2.))
                .text_size(px(14.))
                .line_height(px(22.75))
                .text_color(p.ink)
                .child(if text.trim().is_empty() {
                    div().text_color(p.ink3).child("(empty response)").into_any_element()
                } else {
                    markdown(view, icx, p).into_any_element()
                }),
        )
        .when_some(meta, |this, at| {
            this.child(
                h_flex()
                    .mt(px(6.))
                    .px(px(4.))
                    .gap(px(8.))
                    .text_size(px(12.))
                    .opacity(0.)
                    .group_hover(group, |style| style.opacity(1.))
                    .child(copy_button(("assistant-copy", ix), text.to_string()))
                    .child(
                        div()
                            .id(("assistant-time", ix))
                            .text_color(p.ink3)
                            .tooltip({
                                let tooltip: SharedString = clock::long_form(at).into();
                                move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)
                            })
                            .child(clock::day_aware(at)),
                    ),
            )
        })
        .into_any_element()
}

/// "Worked for 2m 14s", the row a finished turn folds under.
fn render_turn_fold(
    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();
    let group: &'static str = "turn-fold";
    let cost = turn.cost.filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs));
    h_flex()
        .id(("turn-fold", end))
        .group(group)
        .w_full()
        .gap(px(4.))
        .pt(px(4.))
        .pb(px(8.))
        .pr(px(2.))
        .border_b_1()
        .border_color(p.rule)
        .child(
            h_flex()
                .id(("turn-fold-toggle", end))
                .gap(px(4.))
                .px(px(4.))
                .rounded(px(8.))
                .text_size(px(14.))
                .line_height(px(22.75))
                .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(14.)),
                        )
                        .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(cost, |this, cost| {
            this.child(
                div()
                    .text_size(px(12.))
                    .text_color(p.ink3)
                    .opacity(0.)
                    .group_hover(group, |style| style.opacity(1.))
                    .child(format!("${cost:.4}")),
            )
        })
        .child(hover_time(group, turn.at, p))
        .into_any_element()
}

/// "Working for 12s", heading the live turn.
fn render_working(state: &Session, p: &Palette) -> AnyElement {
    let elapsed = state.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0);
    let label = if state.turn_started.is_some() {
        format!("Working for {}", super::format_elapsed(elapsed))
    } else {
        "Working...".to_string()
    };
    h_flex()
        .w_full()
        .mt(px(12.))
        .min_h(px(24.))
        .px(px(4.))
        .mb(px(8.))
        .pb(px(8.))
        .gap(px(8.))
        .border_b_1()
        .border_color(p.rule)
        .text_size(px(14.))
        .text_color(p.ink3)
        .child(metal::led("working-led", Led::Live, true, p))
        .child(label)
        .into_any_element()
}

/// A tool call as a work-log row, or (heading a run of them) the run's
/// summary, which unfolds into every call.
#[allow(clippy::too_many_arguments)]
fn render_work(
    session: &Entity<Session>,
    scroller: &Entity<MessageScrollerState>,
    chat: &WeakEntity<ChatView>,
    ix: usize,
    items: &[ChatItem],
    cwd: &Path,
    expanded_groups: &HashSet<usize>,
    working: bool,
    p: &Palette,
    cx: &App,
) -> AnyElement {
    let members = group_of(items, ix);
    let Some(ChatItem::Tool(tool)) = items.get(ix) else { return div().into_any_element() };
    if members.len() < 2 {
        return v_flex().pb(px(8.)).child(render_entry(session, ix, tool, cwd, p, cx)).into_any_element();
    }
    let start = members[0];
    let open = expanded_groups.contains(&start);
    let is_last = members.last() == Some(&ix);
    let entry = open.then(|| render_entry(session, ix, tool, cwd, p, cx));
    if ix != start {
        return v_flex().when(is_last && open, |this| this.pb(px(8.))).children(entry).into_any_element();
    }
    // The live turn's trailing run shows its latest call; a settled one
    // sums them up.
    let live = working
        && items[members.last().copied().unwrap_or(ix) + 1..].iter().all(|item| is_hidden(item) || matches!(item, ChatItem::TaskEvent { .. }));
    let tools: Vec<&ToolCall> = members
        .iter()
        .filter_map(|&member| match &items[member] {
            ChatItem::Tool(tool) => Some(tool),
            _ => None,
        })
        .collect();
    let presentations: Vec<Presentation> = tools.iter().map(|tool| present(tool, cwd)).collect();
    let failed = tools.iter().any(|tool| tool.state == ToolState::Error);
    let (icon, label, running) = if live {
        let latest = tools.iter().rposition(|tool| tool.state == ToolState::Running).unwrap_or(tools.len() - 1);
        let tool = tools[latest];
        let running = tool.state == ToolState::Running;
        let label = match (tool.name.as_str(), running, program(tool)) {
            ("Bash", true, Some(program)) => format!("Running {program}"),
            ("Bash", false, Some(program)) if tool.state == ToolState::Error => format!("Failed {program}"),
            ("Bash", false, Some(program)) => format!("Ran {program}"),
            _ => presentations[latest].label.clone(),
        };
        (present(tool, cwd).icon, label, running)
    } else {
        let actions: Vec<Action> = presentations.iter().map(|presentation| presentation.action).collect();
        let dominant = actions
            .iter()
            .min_by_key(|action| (action.priority(), std::cmp::Reverse(actions.iter().filter(|other| other == action).count())))
            .copied()
            .unwrap_or(Action::Other);
        (dominant.icon(), group_summary(&actions), false)
    };
    let chat = chat.clone();
    let scroller = scroller.clone();
    v_flex()
        .when(!open, |this| this.pb(px(8.)))
        .child(
            work_row(("work-group", start), icon, if failed && !running { p.red.opacity(0.4) } else { p.ink3 }, p)
                .cursor_pointer()
                .hover(|style| style.bg(p.row_hover))
                .on_click(move |_, _, cx| {
                    let _ = chat.update(cx, |this, cx| {
                        if !this.expanded_groups.remove(&start) {
                            this.expanded_groups.insert(start);
                        }
                        cx.notify();
                    });
                    scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
                })
                .child(
                    div()
                        .flex_1()
                        .min_w_0()
                        .truncate()
                        .when(running, |this| this.text_color(p.ink))
                        .child(label),
                )
                .when(running, |this| this.child(metal::led(("work-group-led", start), Led::Live, true, p))),
        )
        .children(entry)
        .into_any_element()
}

/// One tool call's row, unfolding into its input and output.
fn render_entry(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette, cx: &App) -> AnyElement {
    let presentation = present(tool, cwd);
    let pending = tool.permission.as_ref().is_some_and(|prompt| prompt.state == PermissionState::Pending);
    let running = tool.state == ToolState::Running && !pending || tool.background == Some(TaskStatus::Running);
    let failed = tool.state == ToolState::Error || tool.background == Some(TaskStatus::Failed);
    let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state {
        PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)),
        PermissionState::Denied => Some(("Declined", p.red)),
        PermissionState::Cancelled => Some(("Cancelled", p.ink3)),
        PermissionState::Allowed => None,
    });
    let toggle_session = session.clone();
    let expanded = tool.expanded;
    v_flex()
        .w_full()
        .child(
            work_row(("work-entry", ix), presentation.icon, if failed { p.red.opacity(0.4) } else { p.ink3 }, p)
                .group("work-entry")
                .cursor_pointer()
                .hover(|style| style.bg(p.row_hover))
                .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx)))
                .child(
                    div()
                        .flex_1()
                        .min_w_0()
                        .truncate()
                        .when(running, |this| this.text_color(p.ink))
                        .child(presentation.label),
                )
                .when(tool.subagent_tool_count > 0, |this| {
                    this.child(
                        div()
                            .flex_none()
                            .text_size(px(12.))
                            .text_color(p.ink3)
                            .child(format!("{} tool uses", tool.subagent_tool_count)),
                    )
                })
                .when_some(decision, |this, (label, color)| {
                    this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label))
                })
                .children(render_task_controls(session, ix, tool, p))
                .when(running, |this| this.child(metal::led(("work-led", ix), Led::Live, true, p)))
                .child(row_chevron(expanded, true, p)),
        )
        .when(expanded, |this| this.child(div().pl(px(28.)).pb(px(4.)).child(render_tool_details(ix, tool, cx))))
        .into_any_element()
}

/// Claude's plan as a card: "Plan" and its title, a menu to copy, download
/// or save it, and the plan itself (a long one folds behind "Expand plan").
fn render_plan(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette) -> AnyElement {
    let plan = tool_field(&tool.input, &["plan"]).unwrap_or_default();
    let title = super::dock::plan_title(tool);
    let long = plan.chars().count() > LONG_PLAN_CHARS || plan.lines().count() > LONG_PLAN_LINES;
    let unfolded = tool.expanded;
    let shown = if long && !unfolded { plan.lines().take(10).collect::<Vec<_>>().join("\n") } else { plan.clone() };
    let (copy, download, save) = (plan.clone(), plan.clone(), plan.clone());
    let file_name = format!("{}.md", crate::worktree::sanitize_fragment(&title).replace('/', "-"));
    let (download_name, save_name, save_dir) = (file_name.clone(), file_name, cwd.to_path_buf());
    let toggle = session.clone();
    v_flex()
        .w_full()
        .px(px(4.))
        .child(
            v_flex()
                .w_full()
                .p(px(20.))
                .rounded(px(22.))
                .raised(p)
                .child(
                    h_flex()
                        .w_full()
                        .justify_between()
                        .gap(px(12.))
                        .child(
                            h_flex()
                                .gap(px(8.))
                                .min_w_0()
                                .child(metal::tag("Plan", p))
                                .child(div().min_w_0().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title)),
                        )
                        .child(
                            MetalButton::new(("plan-actions", ix))
                                .compact()
                                .size(px(24.))
                                .icon(IconName::Ellipsis)
                                .tooltip("Plan actions")
                                .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
                                    let (copy, download, save) = (copy.clone(), download.clone(), save.clone());
                                    let (download_name, save_name, save_dir) =
                                        (download_name.clone(), save_name.clone(), save_dir.clone());
                                    menu.item(PopupMenuItem::new("Copy to clipboard").on_click(move |_, _, cx| {
                                        cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()));
                                    }))
                                    .item(PopupMenuItem::new("Download as markdown").on_click(move |_, window, cx| {
                                        let dir = std::env::var_os("HOME")
                                            .map(|home| std::path::PathBuf::from(home).join("Downloads"))
                                            .filter(|dir| dir.is_dir())
                                            .unwrap_or_else(std::env::temp_dir);
                                        save_plan(&dir.join(&download_name), &download, window, cx);
                                    }))
                                    .item(PopupMenuItem::new("Save to workspace").on_click(move |_, window, cx| {
                                        save_plan(&save_dir.join(&save_name), &save, window, cx);
                                    }))
                                }),
                        ),
                )
                .child(
                    div()
                        .mt(px(16.))
                        .text_size(px(14.))
                        .text_color(p.ink)
                        .child(TextView::markdown(("plan", ix), shown).style(markdown_style(p)).selectable(true)),
                )
                .when(long, |this| {
                    this.child(
                        h_flex().mt(px(16.)).justify_center().child(
                            MetalButton::new(("plan-unfold", ix))
                                .compact()
                                .label(if unfolded { "Collapse plan" } else { "Expand plan" })
                                .on_click(move |_, _, cx| toggle.update(cx, |session, cx| session.toggle_tool(ix, cx))),
                        ),
                    )
                }),
        )
        .into_any_element()
}

/// Writes a plan to a file, saying where (or why not).
fn save_plan(path: &Path, plan: &str, window: &mut Window, cx: &mut App) {
    use gpui_kit::component::WindowExt as _;
    use gpui_kit::component::notification::Notification;
    match std::fs::write(path, plan) {
        Ok(()) => window.push_notification(Notification::success(format!("Plan saved to {}", path.display())), cx),
        Err(err) => window.push_notification(Notification::error(format!("Could not save plan: {err}")), cx),
    }
}

/// What an AskUserQuestion call asked and what the user answered: a row
/// per question, the answer after it.
fn render_answers(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement {
    let state = tool.permission.as_ref().map(|prompt| prompt.state);
    let status = match state {
        Some(PermissionState::Pending) => Some(("Waiting for your answer", p.amber_ink)),
        Some(PermissionState::Denied) => Some(("Dismissed", p.ink3)),
        Some(PermissionState::Cancelled) => Some(("Cancelled", p.ink3)),
        _ => None,
    };
    v_flex()
        .w_full()
        .pb(px(8.))
        .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| {
            work_row(ElementId::NamedInteger(format!("answer-{ix}").into(), q as u64), Icon::new(Lucide::MessageCircle), p.ink3, p)
                .child(
                    h_flex()
                        .flex_1()
                        .min_w_0()
                        .gap(px(6.))
                        .child(div().min_w_0().truncate().child(question.question.clone()))
                        .when_some(answer.clone(), |this, answer| {
                            this.child(div().min_w_0().truncate().text_color(p.ink).child(answer))
                        }),
                )
                .when_some(status.filter(|_| q == 0), |this, (label, color)| {
                    this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label))
                })
        }))
        .into_any_element()
}

/// A Pi extension's question and its answer, as it stays in the transcript.
fn render_extension_record(question: &Question, p: &Palette) -> AnyElement {
    v_flex()
        .w_full()
        .pb(px(8.))
        .child(
            work_row("extension-question", Icon::new(Lucide::MessageCircle), p.ink3, p).child(
                h_flex()
                    .flex_1()
                    .min_w_0()
                    .gap(px(6.))
                    .child(div().min_w_0().truncate().child(question.title.clone()))
                    .child(match &question.answer {
                        Some(answer) => div().min_w_0().truncate().text_color(p.ink).child(answer.clone()),
                        None => div().flex_none().text_size(px(12.)).text_color(p.amber_ink).child("Waiting for your answer"),
                    }),
            ),
        )
        .into_any_element()
}

/// A background task's end, which wakes the agent: a row of its own.
fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement {
    let (color, outcome) = match status {
        TaskStatus::Completed => (p.ink3, "Finished"),
        TaskStatus::Running => (p.ink3, "Updated"),
        TaskStatus::Failed => (p.red, "Failed"),
        TaskStatus::Stopped => (p.ink3, "Stopped"),
    };
    v_flex()
        .w_full()
        .pb(px(8.))
        .child(
            work_row(("task-event", ix), Icon::new(Lucide::Zap), color, p)
                .child(div().flex_1().min_w_0().truncate().child(summary.clone()))
                .child(div().flex_none().text_size(px(12.)).text_color(color).child(outcome)),
        )
        .into_any_element()
}

/// An informational notice, as a divider across the conversation.
fn render_divider(text: &SharedString, p: &Palette) -> AnyElement {
    let compacted = text.starts_with("Conversation compacted");
    let label: SharedString = if compacted { "Context compacted".into() } else { text.clone() };
    h_flex()
        .w_full()
        .gap(px(8.))
        .py(px(8.))
        .text_size(px(11.))
        .text_color(p.ink3)
        .child(div().flex_1().h(px(1.)).bg(p.rule))
        .child(
            h_flex()
                .max_w(relative(0.8))
                .gap(px(6.))
                .px(px(8.))
                .when(compacted, |this| this.child(Icon::new(Lucide::Minimize2).size(px(12.))))
                .child(div().min_w_0().font_weight(FontWeight::MEDIUM).child(label)),
        )
        .child(div().flex_1().h(px(1.)).bg(p.rule))
        .into_any_element()
}

/// An error: a tinted box with its message.
fn render_error(text: &SharedString, p: &Palette) -> AnyElement {
    h_flex()
        .w_full()
        .items_start()
        .gap(px(8.))
        .px(px(10.))
        .py(px(6.))
        .rounded(px(8.))
        .bg(p.red.opacity(0.05))
        .shadow(vec![BoxShadow::new(px(0.), px(0.), p.red.opacity(0.25)).spread_radius(px(1.)).inset()])
        .text_size(px(12.))
        .line_height(px(18.))
        .text_color(p.ink)
        .child(div().pt(px(2.)).child(Icon::new(IconName::CircleAlert).size(px(14.)).text_color(p.red)))
        .child(div().flex_1().min_w_0().child(text.clone()))
        .into_any_element()
}

/// Where a tool's background task stands, and the buttons to move a long
/// command to the background or stop it there.
fn render_task_controls(session: &Entity<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> {
    let session = session.clone();
    match (tool.background, &tool.task_id) {
        (Some(TaskStatus::Running), Some(task_id)) => {
            let task_id = task_id.clone();
            Some(
                h_flex()
                    .gap(px(6.))
                    .child(metal::tag("Background", p))
                    .child(
                        MetalButton::new(("tool-stop-task", ix))
                            .ghost()
                            .mini()
                            .icon(IconName::Square)
                            .tooltip("Stop this task")
                            .on_click(move |_, _, cx| {
                                cx.stop_propagation();
                                session.update(cx, |session, cx| session.stop_task(&task_id, cx));
                            }),
                    )
                    .into_any_element(),
            )
        }
        (Some(status), _) => Some(
            metal::engraved(
                match status {
                    TaskStatus::Running | TaskStatus::Completed => "Ran in background",
                    TaskStatus::Failed => "Failed in background",
                    TaskStatus::Stopped => "Stopped",
                },
                p,
            )
            .into_any_element(),
        ),
        // A long foreground command can be sent to the background.
        (None, Some(_)) if tool.state == ToolState::Running => Some(
            MetalButton::new(("tool-background", ix))
                .ghost()
                .mini()
                .label("Background")
                .tooltip("Keep it running in the background; Claude carries on and hears when it finishes")
                .on_click(move |_, _, cx| {
                    cx.stop_propagation();
                    session.update(cx, |session, cx| session.background_tool(ix, cx));
                })
                .into_any_element(),
        ),
        _ => None,
    }
}

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 }),
                )
        }))
}