//! 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 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 { let field = |keys: &[&str]| tool_field(&tool.input, keys); let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::(); let name = tool.name.as_str(); 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())), "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, &tool.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" => 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 { 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 = 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 = 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::() + 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(super) 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::>() .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. fn group_of(items: &[ChatItem], ix: usize) -> Vec { 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, icon: Icon, icon_color: Hsla, p: &Palette) -> Stateful
{ 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
{ 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, 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, cx: &App) -> (HashSet, HashSet, Option<(Vec, Option)>) { 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, scroller: &Entity, chat: &WeakEntity, 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 = 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, 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::>().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, meta: Option, 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, scroller: &Entity, 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, scroller: &Entity, chat: &WeakEntity, ix: usize, items: &[ChatItem], cwd: &Path, expanded_groups: &HashSet, 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 = 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 = 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, 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, 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::>().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, ix: usize, tool: &ToolCall, p: &Palette) -> Option { 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 = 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 }), ) })) }