//! 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>();
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) {
(_, 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() {
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.);
}
render_assistant(ix, &answer.text, &answer.view, 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>,
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, 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 }),
)
}))
}