| @@ -0,0 +1,1138 @@ |
| 1 | + //! The conversation, laid out like T3 Code's timeline: user messages as |
| 2 | + //! bubbles with their time and a copy button on hover, the agent's answers, |
| 3 | + //! its tool calls as compact work-log rows (consecutive ones grouped under |
| 4 | + //! one summary, "Ran 2 commands and read 3 files"), a finished turn folded |
| 5 | + //! under "Worked for 2m 14s", Claude's plans as cards, and notices as |
| 6 | + //! dividers. |
| 7 | + |
| 8 | + use std::collections::HashSet; |
| 9 | + use std::path::Path; |
| 10 | + use std::time::SystemTime; |
| 11 | + |
| 12 | + use gpui_kit::component::{ |
| 13 | + Icon, IconName, h_flex, |
| 14 | + menu::{DropdownMenu as _, PopupMenuItem}, |
| 15 | + message_scroller::MessageScrollerState, |
| 16 | + text::TextView, |
| 17 | + tooltip::Tooltip, |
| 18 | + v_flex, |
| 19 | + }; |
| 20 | + use gpui_kit::prelude::FluentBuilder as _; |
| 21 | + use gpui_kit::*; |
| 22 | + |
| 23 | + use super::{ChatView, MAX_TOOL_OUTPUT, markdown, markdown_style, truncate}; |
| 24 | + use crate::clock; |
| 25 | + use crate::session::{ |
| 26 | + ChatItem, FinishedTurn, NoticeKind, PermissionState, Question, Session, TaskStatus, ToolCall, ToolState, |
| 27 | + UserQuestions, tool_field, |
| 28 | + }; |
| 29 | + use crate::settings; |
| 30 | + use crate::ui::changes; |
| 31 | + use crate::ui::composer::CONTENT_WIDTH; |
| 32 | + use crate::ui::icons::Lucide; |
| 33 | + use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; |
| 34 | + |
| 35 | + /// A user message longer than this folds behind "Show full message". |
| 36 | + const LONG_MESSAGE_CHARS: usize = 600; |
| 37 | + const LONG_MESSAGE_LINES: usize = 8; |
| 38 | + /// A plan longer than this folds behind "Expand plan". |
| 39 | + const LONG_PLAN_CHARS: usize = 900; |
| 40 | + const LONG_PLAN_LINES: usize = 20; |
| 41 | + |
| 42 | + /// What a tool call does, as T3 Code sums up a group of them. |
| 43 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 44 | + enum Action { |
| 45 | + Read, |
| 46 | + Edit, |
| 47 | + Command, |
| 48 | + WebSearch, |
| 49 | + CodeSearch, |
| 50 | + Other, |
| 51 | + } |
| 52 | + |
| 53 | + impl Action { |
| 54 | + /// The icon a row or group of this kind shows. |
| 55 | + fn icon(self) -> Icon { |
| 56 | + match self { |
| 57 | + Action::Read => Icon::new(IconName::Eye), |
| 58 | + Action::Edit => Icon::new(Lucide::SquarePen), |
| 59 | + Action::Command => Icon::new(Lucide::Terminal), |
| 60 | + Action::WebSearch => Icon::new(IconName::Globe), |
| 61 | + Action::CodeSearch => Icon::new(IconName::Search), |
| 62 | + Action::Other => Icon::new(Lucide::Wrench), |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + /// `Ran 2 commands`, `Read 1 file`. |
| 67 | + fn summary(self, count: usize) -> String { |
| 68 | + let s = |one: &str, many: &str| if count == 1 { one.to_string() } else { many.to_string() }; |
| 69 | + match self { |
| 70 | + Action::Read => format!("Read {count} {}", s("file", "files")), |
| 71 | + Action::Edit => format!("Changed {count} {}", s("file", "files")), |
| 72 | + Action::Command => format!("Ran {count} {}", s("command", "commands")), |
| 73 | + Action::WebSearch => format!("Searched the web {count} {}", s("time", "times")), |
| 74 | + Action::CodeSearch => format!("Searched code {count} {}", s("time", "times")), |
| 75 | + Action::Other => format!("Used {count} {}", s("tool", "tools")), |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /// Commands and edits lead a group's summary, odds and ends trail it. |
| 80 | + fn priority(self) -> u8 { |
| 81 | + match self { |
| 82 | + Action::Command | Action::Edit => 0, |
| 83 | + Action::Other => 2, |
| 84 | + _ => 1, |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + /// How a tool call reads in the timeline. |
| 90 | + struct Presentation { |
| 91 | + icon: Icon, |
| 92 | + label: String, |
| 93 | + action: Action, |
| 94 | + } |
| 95 | + |
| 96 | + /// A path relative to the thread's folder. |
| 97 | + fn relative_path(path: &str, cwd: &Path) -> String { |
| 98 | + Path::new(path).strip_prefix(cwd).map(|relative| relative.display().to_string()).unwrap_or_else(|_| path.to_string()) |
| 99 | + } |
| 100 | + |
| 101 | + fn present(tool: &ToolCall, cwd: &Path) -> Presentation { |
| 102 | + let field = |keys: &[&str]| tool_field(&tool.input, keys); |
| 103 | + let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>(); |
| 104 | + let name = tool.name.as_str(); |
| 105 | + let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 106 | + "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), |
| 107 | + "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| 108 | + "read" | "notebookread" => ( |
| 109 | + Action::Read, |
| 110 | + field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()), |
| 111 | + ), |
| 112 | + "write" | "edit" | "multiedit" | "notebookedit" => ( |
| 113 | + Action::Edit, |
| 114 | + field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()), |
| 115 | + ), |
| 116 | + "grep" | "glob" | "find" => ( |
| 117 | + Action::CodeSearch, |
| 118 | + field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()), |
| 119 | + ), |
| 120 | + "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())), |
| 121 | + "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), |
| 122 | + "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), |
| 123 | + "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())), |
| 124 | + "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())), |
| 125 | + _ if name.starts_with("mcp__") => { |
| 126 | + let mut parts = name.trim_start_matches("mcp__").splitn(2, "__"); |
| 127 | + let server = parts.next().unwrap_or_default().to_string(); |
| 128 | + let tool_name = parts.next().unwrap_or_default().replace('_', " "); |
| 129 | + (Action::Other, format!("{server}: {tool_name}")) |
| 130 | + } |
| 131 | + _ => { |
| 132 | + let summary = crate::session::tool_summary(name, &tool.input, cwd); |
| 133 | + (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") }) |
| 134 | + } |
| 135 | + }; |
| 136 | + let icon = match name.to_ascii_lowercase().as_str() { |
| 137 | + "task" | "agent" => Icon::new(IconName::Bot), |
| 138 | + "monitor" => Icon::new(IconName::Eye), |
| 139 | + _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer), |
| 140 | + _ => action.icon(), |
| 141 | + }; |
| 142 | + Presentation { icon, label, action } |
| 143 | + } |
| 144 | + |
| 145 | + /// The program a command runs, for "Running npm". |
| 146 | + fn program(tool: &ToolCall) -> Option<String> { |
| 147 | + let command = tool_field(&tool.input, &["command"])?; |
| 148 | + command |
| 149 | + .split_whitespace() |
| 150 | + .find(|word| !word.contains('=') && !matches!(*word, "sudo" | "env" | "time" | "cd")) |
| 151 | + .map(|word| word.rsplit('/').next().unwrap_or(word).to_string()) |
| 152 | + } |
| 153 | + |
| 154 | + /// T3 Code's summary of a group of tool calls: up to two kinds, then the |
| 155 | + /// rest. "Ran 2 commands and read 3 files", "Changed 1 file, ran 4 |
| 156 | + /// commands, and performed 2 other actions". |
| 157 | + fn group_summary(actions: &[Action]) -> String { |
| 158 | + let mut counts: Vec<(Action, usize)> = Vec::new(); |
| 159 | + for action in actions { |
| 160 | + match counts.iter_mut().find(|(known, _)| known == action) { |
| 161 | + Some((_, count)) => *count += 1, |
| 162 | + None => counts.push((*action, 1)), |
| 163 | + } |
| 164 | + } |
| 165 | + counts.sort_by_key(|(action, _)| action.priority()); |
| 166 | + let mut parts: Vec<String> = counts.iter().take(2).map(|(action, count)| action.summary(*count)).collect(); |
| 167 | + let rest: usize = counts.iter().skip(2).map(|(_, count)| count).sum(); |
| 168 | + if rest > 0 { |
| 169 | + parts.push(format!("Performed {rest} other {}", if rest == 1 { "action" } else { "actions" })); |
| 170 | + } |
| 171 | + let parts: Vec<String> = parts |
| 172 | + .into_iter() |
| 173 | + .enumerate() |
| 174 | + .map(|(ix, part)| { |
| 175 | + if ix == 0 { |
| 176 | + return part; |
| 177 | + } |
| 178 | + let mut chars = part.chars(); |
| 179 | + chars.next().map(|first| first.to_lowercase().collect::<String>() + chars.as_str()).unwrap_or_default() |
| 180 | + }) |
| 181 | + .collect(); |
| 182 | + match parts.len() { |
| 183 | + 0 => String::new(), |
| 184 | + 1 => parts[0].clone(), |
| 185 | + 2 => format!("{} and {}", parts[0], parts[1]), |
| 186 | + n => format!("{}, and {}", parts[..n - 1].join(", "), parts[n - 1]), |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations. |
| 191 | + pub(super) fn format_duration(ms: u64) -> String { |
| 192 | + if ms < 1_000 { |
| 193 | + return format!("{}ms", ms.max(1)); |
| 194 | + } |
| 195 | + if ms < 10_000 { |
| 196 | + return format!("{:.1}s", ms as f64 / 1_000.).replace(".0s", "s"); |
| 197 | + } |
| 198 | + let secs = ms / 1_000; |
| 199 | + if secs < 60 { |
| 200 | + return format!("{secs}s"); |
| 201 | + } |
| 202 | + let (hours, minutes, seconds) = (secs / 3_600, secs % 3_600 / 60, secs % 60); |
| 203 | + [(hours, "h"), (minutes, "m"), (seconds, "s")] |
| 204 | + .iter() |
| 205 | + .filter(|(value, _)| *value > 0) |
| 206 | + .map(|(value, unit)| format!("{value}{unit}")) |
| 207 | + .collect::<Vec<_>>() |
| 208 | + .join(" ") |
| 209 | + } |
| 210 | + |
| 211 | + /// Whether an item shows as a work-log row. |
| 212 | + fn is_work(item: &ChatItem) -> bool { |
| 213 | + matches!(item, ChatItem::Tool(tool) if tool.ask.is_none() && !matches!(tool.name.as_str(), "ExitPlanMode" | "TodoWrite")) |
| 214 | + } |
| 215 | + |
| 216 | + /// Items the timeline leaves out altogether (the task list lives above the |
| 217 | + /// composer), and which don't break a group of tool calls. |
| 218 | + fn is_hidden(item: &ChatItem) -> bool { |
| 219 | + matches!(item, ChatItem::Tool(tool) if tool.name == "TodoWrite") |
| 220 | + } |
| 221 | + |
| 222 | + /// The run of consecutive tool calls `ix` belongs to: its work rows' indices. |
| 223 | + fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> { |
| 224 | + let mut start = ix; |
| 225 | + while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) { |
| 226 | + start -= 1; |
| 227 | + } |
| 228 | + let mut end = ix; |
| 229 | + while end + 1 < items.len() && (is_work(&items[end + 1]) || is_hidden(&items[end + 1])) { |
| 230 | + end += 1; |
| 231 | + } |
| 232 | + (start..=end).filter(|&member| is_work(&items[member])).collect() |
| 233 | + } |
| 234 | + |
| 235 | + /// A work-log row: a 24px icon slot, the label, and whatever trails it. |
| 236 | + fn work_row(id: impl Into<ElementId>, icon: Icon, icon_color: Hsla, p: &Palette) -> Stateful<Div> { |
| 237 | + h_flex() |
| 238 | + .id(id) |
| 239 | + .w_full() |
| 240 | + .min_w_0() |
| 241 | + .min_h(px(24.)) |
| 242 | + .px(px(2.)) |
| 243 | + .gap(px(6.)) |
| 244 | + .rounded(px(8.)) |
| 245 | + .text_size(px(14.)) |
| 246 | + .line_height(px(22.)) |
| 247 | + .child( |
| 248 | + div() |
| 249 | + .flex() |
| 250 | + .flex_none() |
| 251 | + .items_center() |
| 252 | + .justify_center() |
| 253 | + .size(px(24.)) |
| 254 | + .child(icon.size(px(16.)).text_color(icon_color)), |
| 255 | + ) |
| 256 | + .text_color(p.ink2) |
| 257 | + } |
| 258 | + |
| 259 | + /// The chevron a row that unfolds ends with. |
| 260 | + fn row_chevron(expanded: bool, visible: bool, p: &Palette) -> Div { |
| 261 | + div() |
| 262 | + .flex() |
| 263 | + .flex_none() |
| 264 | + .items_center() |
| 265 | + .justify_center() |
| 266 | + .size(px(16.)) |
| 267 | + .when(visible, |this| { |
| 268 | + this.child( |
| 269 | + Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }) |
| 270 | + .size(px(12.)) |
| 271 | + .text_color(p.ink3.opacity(0.7)), |
| 272 | + ) |
| 273 | + }) |
| 274 | + } |
| 275 | + |
| 276 | + /// A time that shows while the pointer is over its row. |
| 277 | + fn hover_time(group: &'static str, time: SystemTime, p: &Palette) -> Stateful<Div> { |
| 278 | + let tooltip: SharedString = clock::long_form(time).into(); |
| 279 | + div() |
| 280 | + .id(SharedString::from(format!("{group}-time"))) |
| 281 | + .mr(px(4.)) |
| 282 | + .flex_none() |
| 283 | + .text_size(px(12.)) |
| 284 | + .text_color(p.ink3) |
| 285 | + .opacity(0.) |
| 286 | + .group_hover(group, |style| style.opacity(1.)) |
| 287 | + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) |
| 288 | + .child(clock::day_aware(time)) |
| 289 | + } |
| 290 | + |
| 291 | + /// The copy button under a message. |
| 292 | + fn copy_button(id: impl Into<ElementId>, text: String) -> MetalButton { |
| 293 | + MetalButton::new(id).ghost().mini().size(px(24.)).icon(IconName::Copy).tooltip("Copy message").on_click(move |_, _, cx| { |
| 294 | + cx.write_to_clipboard(ClipboardItem::new_string(text.clone())); |
| 295 | + }) |
| 296 | + } |
| 297 | + |
| 298 | + /// The ChatView's fold state for groups and long messages. |
| 299 | + fn view_state(chat: &WeakEntity<ChatView>, cx: &App) -> (HashSet<usize>, HashSet<usize>) { |
| 300 | + chat.upgrade() |
| 301 | + .map(|chat| { |
| 302 | + let chat = chat.read(cx); |
| 303 | + (chat.expanded_groups.clone(), chat.expanded_messages.clone()) |
| 304 | + }) |
| 305 | + .unwrap_or_default() |
| 306 | + } |
| 307 | + |
| 308 | + /// Renders one transcript row. Runs inside the virtual list, so it only gets |
| 309 | + /// `App` and talks to the session entity directly. |
| 310 | + pub(super) fn render_item( |
| 311 | + session: &Entity<Session>, |
| 312 | + scroller: &Entity<MessageScrollerState>, |
| 313 | + chat: &WeakEntity<ChatView>, |
| 314 | + ix: usize, |
| 315 | + _: &mut Window, |
| 316 | + cx: &mut App, |
| 317 | + ) -> AnyElement { |
| 318 | + let p = metal::palette(cx); |
| 319 | + let state = session.read(cx); |
| 320 | + let Some(item) = state.items.get(ix) else { |
| 321 | + return div().into_any_element(); |
| 322 | + }; |
| 323 | + if is_hidden(item) { |
| 324 | + return div().into_any_element(); |
| 325 | + } |
| 326 | + let (expanded_groups, expanded_messages) = view_state(chat, cx); |
| 327 | + |
| 328 | + // A finished turn shows only its answer (and any errors) under one |
| 329 | + // "Worked for" row, unless the user unfolds it. Its end shows what it |
| 330 | + // changed on disk. |
| 331 | + let turn = state.finished_turn(ix); |
| 332 | + // With folding off, turns start unfolded and a click folds them. |
| 333 | + let fold = settings::get(|settings| settings.fold_turns); |
| 334 | + let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold); |
| 335 | + let summary = turn |
| 336 | + .as_ref() |
| 337 | + .filter(|turn| turn.first == ix) |
| 338 | + .map(|turn| render_turn_fold(session, scroller, turn, expanded, p)); |
| 339 | + let visible = match (&turn, item) { |
| 340 | + (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(), |
| 341 | + (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true, |
| 342 | + (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true, |
| 343 | + (Some(turn), _) => expanded || Some(ix) == turn.answer, |
| 344 | + (None, _) => true, |
| 345 | + }; |
| 346 | + // The live turn opens under its user message with "Working for 12s". |
| 347 | + let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. })); |
| 348 | + let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention()) |
| 349 | + .then(|| render_working(state, p)); |
| 350 | + if !visible && summary.is_none() { |
| 351 | + return div().into_any_element(); |
| 352 | + } |
| 353 | + |
| 354 | + let mut bottom = px(8.); |
| 355 | + let content: Option<AnyElement> = if !visible { |
| 356 | + None |
| 357 | + } else { |
| 358 | + Some(match item { |
| 359 | + ChatItem::User { text, attachments, at } => { |
| 360 | + bottom = px(16.); |
| 361 | + render_user(chat, ix, text, attachments, *at, expanded_messages.contains(&ix), p) |
| 362 | + } |
| 363 | + ChatItem::Assistant(answer) => { |
| 364 | + let meta = turn.as_ref().filter(|turn| turn.answer == Some(ix)).map(|turn| turn.at); |
| 365 | + if meta.is_some() { |
| 366 | + bottom = px(16.); |
| 367 | + } |
| 368 | + render_assistant(ix, &answer.text, &answer.view, meta, p) |
| 369 | + } |
| 370 | + ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p), |
| 371 | + ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => { |
| 372 | + bottom = px(16.); |
| 373 | + render_plan(session, ix, tool, &state.cwd, p) |
| 374 | + } |
| 375 | + ChatItem::Tool(_) => { |
| 376 | + bottom = px(0.); |
| 377 | + render_work(session, scroller, chat, ix, &state.items, &state.cwd, &expanded_groups, state.is_working(), p, cx) |
| 378 | + } |
| 379 | + ChatItem::Question(question) => render_extension_record(question, p), |
| 380 | + ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p), |
| 381 | + ChatItem::Notice(NoticeKind::Info, text) => render_divider(text, p), |
| 382 | + ChatItem::Notice(NoticeKind::Error, text) => render_error(text, p), |
| 383 | + ChatItem::TurnEnd { .. } => { |
| 384 | + bottom = px(16.); |
| 385 | + match state.turn_changes(ix) { |
| 386 | + Some(changes) => { |
| 387 | + let chat = chat.clone(); |
| 388 | + changes::render_changes( |
| 389 | + session, |
| 390 | + ix, |
| 391 | + changes, |
| 392 | + move |path, window, cx| { |
| 393 | + let _ = chat.update(cx, |this, cx| this.open_diff(ix, path, window, cx)); |
| 394 | + }, |
| 395 | + cx, |
| 396 | + ) |
| 397 | + } |
| 398 | + None => div().into_any_element(), |
| 399 | + } |
| 400 | + } |
| 401 | + }) |
| 402 | + }; |
| 403 | + |
| 404 | + let top = match item { |
| 405 | + ChatItem::User { .. } if ix == 0 => px(16.), |
| 406 | + _ => px(0.), |
| 407 | + }; |
| 408 | + h_flex() |
| 409 | + .w_full() |
| 410 | + .justify_center() |
| 411 | + .px(px(28.)) |
| 412 | + .pt(top) |
| 413 | + .pb(if content.is_none() { px(6.) } else { bottom }) |
| 414 | + .child( |
| 415 | + v_flex() |
| 416 | + .w_full() |
| 417 | + .max_w(px(CONTENT_WIDTH)) |
| 418 | + .map(|this| match item { |
| 419 | + ChatItem::TaskEvent { .. } => this.children(content).children(summary.map(|summary| div().pt(px(8.)).child(summary))), |
| 420 | + _ => this.children(summary.map(|summary| div().pb(px(6.)).child(summary))).children(content), |
| 421 | + }) |
| 422 | + .children(working), |
| 423 | + ) |
| 424 | + .into_any_element() |
| 425 | + } |
| 426 | + |
| 427 | + /// A user message: a bubble on the right, its time and a copy button |
| 428 | + /// under it on hover. A long one folds behind "Show full message". |
| 429 | + fn render_user( |
| 430 | + chat: &WeakEntity<ChatView>, |
| 431 | + ix: usize, |
| 432 | + text: &SharedString, |
| 433 | + attachments: &[SharedString], |
| 434 | + at: SystemTime, |
| 435 | + unfolded: bool, |
| 436 | + p: &Palette, |
| 437 | + ) -> AnyElement { |
| 438 | + let group: &'static str = "user-row"; |
| 439 | + let long = text.chars().count() > LONG_MESSAGE_CHARS || text.lines().count() > LONG_MESSAGE_LINES; |
| 440 | + let shown: String = if long && !unfolded { |
| 441 | + let mut shown: String = text.lines().take(LONG_MESSAGE_LINES).collect::<Vec<_>>().join("\n"); |
| 442 | + if shown.chars().count() > LONG_MESSAGE_CHARS { |
| 443 | + shown = shown.chars().take(LONG_MESSAGE_CHARS).collect(); |
| 444 | + } |
| 445 | + shown.push('…'); |
| 446 | + shown |
| 447 | + } else { |
| 448 | + text.to_string() |
| 449 | + }; |
| 450 | + let toggle = chat.clone(); |
| 451 | + v_flex() |
| 452 | + .w_full() |
| 453 | + .items_end() |
| 454 | + .gap(px(4.)) |
| 455 | + .group(group) |
| 456 | + .child( |
| 457 | + v_flex() |
| 458 | + .max_w(relative(0.8)) |
| 459 | + .p(px(12.)) |
| 460 | + .rounded(px(18.)) |
| 461 | + .raised(p) |
| 462 | + .text_size(px(14.)) |
| 463 | + .line_height(px(22.)) |
| 464 | + .text_color(p.ink) |
| 465 | + .when(!attachments.is_empty(), |this| { |
| 466 | + this.child(v_flex().mb(px(8.)).gap(px(4.)).children(attachments.iter().map(|name| { |
| 467 | + h_flex() |
| 468 | + .gap(px(8.)) |
| 469 | + .py(px(4.)) |
| 470 | + .text_size(px(14.)) |
| 471 | + .child(Icon::new(IconName::File).size(px(16.)).text_color(p.ink3)) |
| 472 | + .child(div().min_w_0().truncate().child(name.clone())) |
| 473 | + }))) |
| 474 | + }) |
| 475 | + .when(!text.is_empty(), |this| this.child(shown)) |
| 476 | + .when(long, |this| { |
| 477 | + this.child( |
| 478 | + h_flex().mt(px(6.)).justify_end().child( |
| 479 | + MetalButton::new(("user-unfold", ix)) |
| 480 | + .ghost() |
| 481 | + .mini() |
| 482 | + .label(if unfolded { "Show less" } else { "Show full message" }) |
| 483 | + .on_click(move |_, _, cx| { |
| 484 | + let _ = toggle.update(cx, |this, cx| { |
| 485 | + if !this.expanded_messages.remove(&ix) { |
| 486 | + this.expanded_messages.insert(ix); |
| 487 | + } |
| 488 | + this.scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); |
| 489 | + cx.notify(); |
| 490 | + }); |
| 491 | + }), |
| 492 | + ), |
| 493 | + ) |
| 494 | + }), |
| 495 | + ) |
| 496 | + .child( |
| 497 | + h_flex() |
| 498 | + .max_w(relative(0.8)) |
| 499 | + .justify_end() |
| 500 | + .pr(px(4.)) |
| 501 | + .gap(px(8.)) |
| 502 | + .text_size(px(12.)) |
| 503 | + .opacity(0.) |
| 504 | + .group_hover(group, |style| style.opacity(1.)) |
| 505 | + .child( |
| 506 | + div() |
| 507 | + .id(("user-time", ix)) |
| 508 | + .text_color(p.ink3) |
| 509 | + .tooltip({ |
| 510 | + let tooltip: SharedString = clock::long_form(at).into(); |
| 511 | + move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx) |
| 512 | + }) |
| 513 | + .child(clock::day_aware(at)), |
| 514 | + ) |
| 515 | + .child(copy_button(("user-copy", ix), text.to_string())), |
| 516 | + ) |
| 517 | + .into_any_element() |
| 518 | + } |
| 519 | + |
| 520 | + /// The agent's message. The turn's answer shows a copy button and its time |
| 521 | + /// on hover. |
| 522 | + fn render_assistant( |
| 523 | + ix: usize, |
| 524 | + text: &str, |
| 525 | + view: &Entity<gpui_kit::component::text::TextViewState>, |
| 526 | + meta: Option<SystemTime>, |
| 527 | + p: &Palette, |
| 528 | + ) -> AnyElement { |
| 529 | + let group: &'static str = "assistant-row"; |
| 530 | + v_flex() |
| 531 | + .w_full() |
| 532 | + .group(group) |
| 533 | + .child( |
| 534 | + div() |
| 535 | + .w_full() |
| 536 | + .px(px(4.)) |
| 537 | + .py(px(2.)) |
| 538 | + .text_size(px(14.)) |
| 539 | + .line_height(px(22.75)) |
| 540 | + .text_color(p.ink) |
| 541 | + .child(if text.trim().is_empty() { |
| 542 | + div().text_color(p.ink3).child("(empty response)").into_any_element() |
| 543 | + } else { |
| 544 | + markdown(view, p).into_any_element() |
| 545 | + }), |
| 546 | + ) |
| 547 | + .when_some(meta, |this, at| { |
| 548 | + this.child( |
| 549 | + h_flex() |
| 550 | + .mt(px(6.)) |
| 551 | + .px(px(4.)) |
| 552 | + .gap(px(8.)) |
| 553 | + .text_size(px(12.)) |
| 554 | + .opacity(0.) |
| 555 | + .group_hover(group, |style| style.opacity(1.)) |
| 556 | + .child(copy_button(("assistant-copy", ix), text.to_string())) |
| 557 | + .child( |
| 558 | + div() |
| 559 | + .id(("assistant-time", ix)) |
| 560 | + .text_color(p.ink3) |
| 561 | + .tooltip({ |
| 562 | + let tooltip: SharedString = clock::long_form(at).into(); |
| 563 | + move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx) |
| 564 | + }) |
| 565 | + .child(clock::day_aware(at)), |
| 566 | + ), |
| 567 | + ) |
| 568 | + }) |
| 569 | + .into_any_element() |
| 570 | + } |
| 571 | + |
| 572 | + /// "Worked for 2m 14s", the row a finished turn folds under. |
| 573 | + fn render_turn_fold( |
| 574 | + session: &Entity<Session>, |
| 575 | + scroller: &Entity<MessageScrollerState>, |
| 576 | + turn: &FinishedTurn, |
| 577 | + expanded: bool, |
| 578 | + p: &Palette, |
| 579 | + ) -> AnyElement { |
| 580 | + let end = turn.end; |
| 581 | + let toggle_session = session.clone(); |
| 582 | + let scroller = scroller.clone(); |
| 583 | + let group: &'static str = "turn-fold"; |
| 584 | + let cost = turn.cost.filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs)); |
| 585 | + h_flex() |
| 586 | + .id(("turn-fold", end)) |
| 587 | + .group(group) |
| 588 | + .w_full() |
| 589 | + .gap(px(4.)) |
| 590 | + .pt(px(4.)) |
| 591 | + .pb(px(8.)) |
| 592 | + .pr(px(2.)) |
| 593 | + .border_b_1() |
| 594 | + .border_color(p.rule) |
| 595 | + .child( |
| 596 | + h_flex() |
| 597 | + .id(("turn-fold-toggle", end)) |
| 598 | + .gap(px(4.)) |
| 599 | + .px(px(4.)) |
| 600 | + .rounded(px(8.)) |
| 601 | + .text_size(px(14.)) |
| 602 | + .line_height(px(22.75)) |
| 603 | + .text_color(p.ink3) |
| 604 | + .child(format!("Worked for {}", format_duration(turn.duration_ms))) |
| 605 | + .when(turn.has_work, |this| { |
| 606 | + this.cursor_pointer() |
| 607 | + .hover(|style| style.text_color(p.ink)) |
| 608 | + .child( |
| 609 | + Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(14.)), |
| 610 | + ) |
| 611 | + .on_click(move |_, _, cx| { |
| 612 | + toggle_session.update(cx, |session, cx| session.toggle_turn(end, cx)); |
| 613 | + // Folding changes the height of every row in the turn. |
| 614 | + scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); |
| 615 | + }) |
| 616 | + }), |
| 617 | + ) |
| 618 | + .child(div().flex_1()) |
| 619 | + .when_some(cost, |this, cost| { |
| 620 | + this.child( |
| 621 | + div() |
| 622 | + .text_size(px(12.)) |
| 623 | + .text_color(p.ink3) |
| 624 | + .opacity(0.) |
| 625 | + .group_hover(group, |style| style.opacity(1.)) |
| 626 | + .child(format!("${cost:.4}")), |
| 627 | + ) |
| 628 | + }) |
| 629 | + .child(hover_time(group, turn.at, p)) |
| 630 | + .into_any_element() |
| 631 | + } |
| 632 | + |
| 633 | + /// "Working for 12s", heading the live turn. |
| 634 | + fn render_working(state: &Session, p: &Palette) -> AnyElement { |
| 635 | + let elapsed = state.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0); |
| 636 | + let label = if state.turn_started.is_some() { |
| 637 | + format!("Working for {}", super::format_elapsed(elapsed)) |
| 638 | + } else { |
| 639 | + "Working...".to_string() |
| 640 | + }; |
| 641 | + h_flex() |
| 642 | + .w_full() |
| 643 | + .mt(px(12.)) |
| 644 | + .min_h(px(24.)) |
| 645 | + .px(px(4.)) |
| 646 | + .mb(px(8.)) |
| 647 | + .pb(px(8.)) |
| 648 | + .gap(px(8.)) |
| 649 | + .border_b_1() |
| 650 | + .border_color(p.rule) |
| 651 | + .text_size(px(14.)) |
| 652 | + .text_color(p.ink3) |
| 653 | + .child(metal::led("working-led", Led::Live, true, p)) |
| 654 | + .child(label) |
| 655 | + .into_any_element() |
| 656 | + } |
| 657 | + |
| 658 | + /// A tool call as a work-log row, or (heading a run of them) the run's |
| 659 | + /// summary, which unfolds into every call. |
| 660 | + #[allow(clippy::too_many_arguments)] |
| 661 | + fn render_work( |
| 662 | + session: &Entity<Session>, |
| 663 | + scroller: &Entity<MessageScrollerState>, |
| 664 | + chat: &WeakEntity<ChatView>, |
| 665 | + ix: usize, |
| 666 | + items: &[ChatItem], |
| 667 | + cwd: &Path, |
| 668 | + expanded_groups: &HashSet<usize>, |
| 669 | + working: bool, |
| 670 | + p: &Palette, |
| 671 | + cx: &App, |
| 672 | + ) -> AnyElement { |
| 673 | + let members = group_of(items, ix); |
| 674 | + let Some(ChatItem::Tool(tool)) = items.get(ix) else { return div().into_any_element() }; |
| 675 | + if members.len() < 2 { |
| 676 | + return v_flex().pb(px(8.)).child(render_entry(session, ix, tool, cwd, p, cx)).into_any_element(); |
| 677 | + } |
| 678 | + let start = members[0]; |
| 679 | + let open = expanded_groups.contains(&start); |
| 680 | + let is_last = members.last() == Some(&ix); |
| 681 | + let entry = open.then(|| render_entry(session, ix, tool, cwd, p, cx)); |
| 682 | + if ix != start { |
| 683 | + return v_flex().when(is_last && open, |this| this.pb(px(8.))).children(entry).into_any_element(); |
| 684 | + } |
| 685 | + // The live turn's trailing run shows its latest call; a settled one |
| 686 | + // sums them up. |
| 687 | + let live = working |
| 688 | + && items[members.last().copied().unwrap_or(ix) + 1..].iter().all(|item| is_hidden(item) || matches!(item, ChatItem::TaskEvent { .. })); |
| 689 | + let tools: Vec<&ToolCall> = members |
| 690 | + .iter() |
| 691 | + .filter_map(|&member| match &items[member] { |
| 692 | + ChatItem::Tool(tool) => Some(tool), |
| 693 | + _ => None, |
| 694 | + }) |
| 695 | + .collect(); |
| 696 | + let presentations: Vec<Presentation> = tools.iter().map(|tool| present(tool, cwd)).collect(); |
| 697 | + let failed = tools.iter().any(|tool| tool.state == ToolState::Error); |
| 698 | + let (icon, label, running) = if live { |
| 699 | + let latest = tools.iter().rposition(|tool| tool.state == ToolState::Running).unwrap_or(tools.len() - 1); |
| 700 | + let tool = tools[latest]; |
| 701 | + let running = tool.state == ToolState::Running; |
| 702 | + let label = match (tool.name.as_str(), running, program(tool)) { |
| 703 | + ("Bash", true, Some(program)) => format!("Running {program}"), |
| 704 | + ("Bash", false, Some(program)) if tool.state == ToolState::Error => format!("Failed {program}"), |
| 705 | + ("Bash", false, Some(program)) => format!("Ran {program}"), |
| 706 | + _ => presentations[latest].label.clone(), |
| 707 | + }; |
| 708 | + (present(tool, cwd).icon, label, running) |
| 709 | + } else { |
| 710 | + let actions: Vec<Action> = presentations.iter().map(|presentation| presentation.action).collect(); |
| 711 | + let dominant = actions |
| 712 | + .iter() |
| 713 | + .min_by_key(|action| (action.priority(), std::cmp::Reverse(actions.iter().filter(|other| other == action).count()))) |
| 714 | + .copied() |
| 715 | + .unwrap_or(Action::Other); |
| 716 | + (dominant.icon(), group_summary(&actions), false) |
| 717 | + }; |
| 718 | + let chat = chat.clone(); |
| 719 | + let scroller = scroller.clone(); |
| 720 | + v_flex() |
| 721 | + .when(!open, |this| this.pb(px(8.))) |
| 722 | + .child( |
| 723 | + work_row(("work-group", start), icon, if failed && !running { p.red.opacity(0.4) } else { p.ink3 }, p) |
| 724 | + .cursor_pointer() |
| 725 | + .hover(|style| style.bg(p.row_hover)) |
| 726 | + .on_click(move |_, _, cx| { |
| 727 | + let _ = chat.update(cx, |this, cx| { |
| 728 | + if !this.expanded_groups.remove(&start) { |
| 729 | + this.expanded_groups.insert(start); |
| 730 | + } |
| 731 | + cx.notify(); |
| 732 | + }); |
| 733 | + scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); |
| 734 | + }) |
| 735 | + .child( |
| 736 | + div() |
| 737 | + .flex_1() |
| 738 | + .min_w_0() |
| 739 | + .truncate() |
| 740 | + .when(running, |this| this.text_color(p.ink)) |
| 741 | + .child(label), |
| 742 | + ) |
| 743 | + .when(running, |this| this.child(metal::led(("work-group-led", start), Led::Live, true, p))), |
| 744 | + ) |
| 745 | + .children(entry) |
| 746 | + .into_any_element() |
| 747 | + } |
| 748 | + |
| 749 | + /// One tool call's row, unfolding into its input and output. |
| 750 | + fn render_entry(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette, cx: &App) -> AnyElement { |
| 751 | + let presentation = present(tool, cwd); |
| 752 | + let pending = tool.permission.as_ref().is_some_and(|prompt| prompt.state == PermissionState::Pending); |
| 753 | + let running = tool.state == ToolState::Running && !pending || tool.background == Some(TaskStatus::Running); |
| 754 | + let failed = tool.state == ToolState::Error || tool.background == Some(TaskStatus::Failed); |
| 755 | + let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state { |
| 756 | + PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)), |
| 757 | + PermissionState::Denied => Some(("Declined", p.red)), |
| 758 | + PermissionState::Cancelled => Some(("Cancelled", p.ink3)), |
| 759 | + PermissionState::Allowed => None, |
| 760 | + }); |
| 761 | + let toggle_session = session.clone(); |
| 762 | + let expanded = tool.expanded; |
| 763 | + v_flex() |
| 764 | + .w_full() |
| 765 | + .child( |
| 766 | + work_row(("work-entry", ix), presentation.icon, if failed { p.red.opacity(0.4) } else { p.ink3 }, p) |
| 767 | + .group("work-entry") |
| 768 | + .cursor_pointer() |
| 769 | + .hover(|style| style.bg(p.row_hover)) |
| 770 | + .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx))) |
| 771 | + .child( |
| 772 | + div() |
| 773 | + .flex_1() |
| 774 | + .min_w_0() |
| 775 | + .truncate() |
| 776 | + .when(running, |this| this.text_color(p.ink)) |
| 777 | + .child(presentation.label), |
| 778 | + ) |
| 779 | + .when(tool.subagent_tool_count > 0, |this| { |
| 780 | + this.child( |
| 781 | + div() |
| 782 | + .flex_none() |
| 783 | + .text_size(px(12.)) |
| 784 | + .text_color(p.ink3) |
| 785 | + .child(format!("{} tool uses", tool.subagent_tool_count)), |
| 786 | + ) |
| 787 | + }) |
| 788 | + .when_some(decision, |this, (label, color)| { |
| 789 | + this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label)) |
| 790 | + }) |
| 791 | + .children(render_task_controls(session, ix, tool, p)) |
| 792 | + .when(running, |this| this.child(metal::led(("work-led", ix), Led::Live, true, p))) |
| 793 | + .child(row_chevron(expanded, true, p)), |
| 794 | + ) |
| 795 | + .when(expanded, |this| this.child(div().pl(px(28.)).pb(px(4.)).child(render_tool_details(ix, tool, cx)))) |
| 796 | + .into_any_element() |
| 797 | + } |
| 798 | + |
| 799 | + /// Claude's plan as a card: "Plan" and its title, a menu to copy, download |
| 800 | + /// or save it, and the plan itself (a long one folds behind "Expand plan"). |
| 801 | + fn render_plan(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette) -> AnyElement { |
| 802 | + let plan = tool_field(&tool.input, &["plan"]).unwrap_or_default(); |
| 803 | + let title = super::dock::plan_title(tool); |
| 804 | + let long = plan.chars().count() > LONG_PLAN_CHARS || plan.lines().count() > LONG_PLAN_LINES; |
| 805 | + let unfolded = tool.expanded; |
| 806 | + let shown = if long && !unfolded { plan.lines().take(10).collect::<Vec<_>>().join("\n") } else { plan.clone() }; |
| 807 | + let (copy, download, save) = (plan.clone(), plan.clone(), plan.clone()); |
| 808 | + let file_name = format!("{}.md", crate::worktree::sanitize_fragment(&title).replace('/', "-")); |
| 809 | + let (download_name, save_name, save_dir) = (file_name.clone(), file_name, cwd.to_path_buf()); |
| 810 | + let toggle = session.clone(); |
| 811 | + v_flex() |
| 812 | + .w_full() |
| 813 | + .px(px(4.)) |
| 814 | + .child( |
| 815 | + v_flex() |
| 816 | + .w_full() |
| 817 | + .p(px(20.)) |
| 818 | + .rounded(px(22.)) |
| 819 | + .raised(p) |
| 820 | + .child( |
| 821 | + h_flex() |
| 822 | + .w_full() |
| 823 | + .justify_between() |
| 824 | + .gap(px(12.)) |
| 825 | + .child( |
| 826 | + h_flex() |
| 827 | + .gap(px(8.)) |
| 828 | + .min_w_0() |
| 829 | + .child(metal::tag("Plan", p)) |
| 830 | + .child(div().min_w_0().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title)), |
| 831 | + ) |
| 832 | + .child( |
| 833 | + MetalButton::new(("plan-actions", ix)) |
| 834 | + .compact() |
| 835 | + .size(px(24.)) |
| 836 | + .icon(IconName::Ellipsis) |
| 837 | + .tooltip("Plan actions") |
| 838 | + .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| { |
| 839 | + let (copy, download, save) = (copy.clone(), download.clone(), save.clone()); |
| 840 | + let (download_name, save_name, save_dir) = |
| 841 | + (download_name.clone(), save_name.clone(), save_dir.clone()); |
| 842 | + menu.item(PopupMenuItem::new("Copy to clipboard").on_click(move |_, _, cx| { |
| 843 | + cx.write_to_clipboard(ClipboardItem::new_string(copy.clone())); |
| 844 | + })) |
| 845 | + .item(PopupMenuItem::new("Download as markdown").on_click(move |_, window, cx| { |
| 846 | + let dir = std::env::var_os("HOME") |
| 847 | + .map(|home| std::path::PathBuf::from(home).join("Downloads")) |
| 848 | + .filter(|dir| dir.is_dir()) |
| 849 | + .unwrap_or_else(std::env::temp_dir); |
| 850 | + save_plan(&dir.join(&download_name), &download, window, cx); |
| 851 | + })) |
| 852 | + .item(PopupMenuItem::new("Save to workspace").on_click(move |_, window, cx| { |
| 853 | + save_plan(&save_dir.join(&save_name), &save, window, cx); |
| 854 | + })) |
| 855 | + }), |
| 856 | + ), |
| 857 | + ) |
| 858 | + .child( |
| 859 | + div() |
| 860 | + .mt(px(16.)) |
| 861 | + .text_size(px(14.)) |
| 862 | + .text_color(p.ink) |
| 863 | + .child(TextView::markdown(("plan", ix), shown).style(markdown_style(p)).selectable(true)), |
| 864 | + ) |
| 865 | + .when(long, |this| { |
| 866 | + this.child( |
| 867 | + h_flex().mt(px(16.)).justify_center().child( |
| 868 | + MetalButton::new(("plan-unfold", ix)) |
| 869 | + .compact() |
| 870 | + .label(if unfolded { "Collapse plan" } else { "Expand plan" }) |
| 871 | + .on_click(move |_, _, cx| toggle.update(cx, |session, cx| session.toggle_tool(ix, cx))), |
| 872 | + ), |
| 873 | + ) |
| 874 | + }), |
| 875 | + ) |
| 876 | + .into_any_element() |
| 877 | + } |
| 878 | + |
| 879 | + /// Writes a plan to a file, saying where (or why not). |
| 880 | + fn save_plan(path: &Path, plan: &str, window: &mut Window, cx: &mut App) { |
| 881 | + use gpui_kit::component::WindowExt as _; |
| 882 | + use gpui_kit::component::notification::Notification; |
| 883 | + match std::fs::write(path, plan) { |
| 884 | + Ok(()) => window.push_notification(Notification::success(format!("Plan saved to {}", path.display())), cx), |
| 885 | + Err(err) => window.push_notification(Notification::error(format!("Could not save plan: {err}")), cx), |
| 886 | + } |
| 887 | + } |
| 888 | + |
| 889 | + /// What an AskUserQuestion call asked and what the user answered: a row |
| 890 | + /// per question, the answer after it. |
| 891 | + fn render_answers(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement { |
| 892 | + let state = tool.permission.as_ref().map(|prompt| prompt.state); |
| 893 | + let status = match state { |
| 894 | + Some(PermissionState::Pending) => Some(("Waiting for your answer", p.amber_ink)), |
| 895 | + Some(PermissionState::Denied) => Some(("Dismissed", p.ink3)), |
| 896 | + Some(PermissionState::Cancelled) => Some(("Cancelled", p.ink3)), |
| 897 | + _ => None, |
| 898 | + }; |
| 899 | + v_flex() |
| 900 | + .w_full() |
| 901 | + .pb(px(8.)) |
| 902 | + .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| { |
| 903 | + work_row(ElementId::NamedInteger(format!("answer-{ix}").into(), q as u64), Icon::new(Lucide::MessageCircle), p.ink3, p) |
| 904 | + .child( |
| 905 | + h_flex() |
| 906 | + .flex_1() |
| 907 | + .min_w_0() |
| 908 | + .gap(px(6.)) |
| 909 | + .child(div().min_w_0().truncate().child(question.question.clone())) |
| 910 | + .when_some(answer.clone(), |this, answer| { |
| 911 | + this.child(div().min_w_0().truncate().text_color(p.ink).child(answer)) |
| 912 | + }), |
| 913 | + ) |
| 914 | + .when_some(status.filter(|_| q == 0), |this, (label, color)| { |
| 915 | + this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label)) |
| 916 | + }) |
| 917 | + })) |
| 918 | + .into_any_element() |
| 919 | + } |
| 920 | + |
| 921 | + /// A Pi extension's question and its answer, as it stays in the transcript. |
| 922 | + fn render_extension_record(question: &Question, p: &Palette) -> AnyElement { |
| 923 | + v_flex() |
| 924 | + .w_full() |
| 925 | + .pb(px(8.)) |
| 926 | + .child( |
| 927 | + work_row("extension-question", Icon::new(Lucide::MessageCircle), p.ink3, p).child( |
| 928 | + h_flex() |
| 929 | + .flex_1() |
| 930 | + .min_w_0() |
| 931 | + .gap(px(6.)) |
| 932 | + .child(div().min_w_0().truncate().child(question.title.clone())) |
| 933 | + .child(match &question.answer { |
| 934 | + Some(answer) => div().min_w_0().truncate().text_color(p.ink).child(answer.clone()), |
| 935 | + None => div().flex_none().text_size(px(12.)).text_color(p.amber_ink).child("Waiting for your answer"), |
| 936 | + }), |
| 937 | + ), |
| 938 | + ) |
| 939 | + .into_any_element() |
| 940 | + } |
| 941 | + |
| 942 | + /// A background task's end, which wakes the agent: a row of its own. |
| 943 | + fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement { |
| 944 | + let (color, outcome) = match status { |
| 945 | + TaskStatus::Completed => (p.ink3, "Finished"), |
| 946 | + TaskStatus::Running => (p.ink3, "Updated"), |
| 947 | + TaskStatus::Failed => (p.red, "Failed"), |
| 948 | + TaskStatus::Stopped => (p.ink3, "Stopped"), |
| 949 | + }; |
| 950 | + v_flex() |
| 951 | + .w_full() |
| 952 | + .pb(px(8.)) |
| 953 | + .child( |
| 954 | + work_row(("task-event", ix), Icon::new(Lucide::Zap), color, p) |
| 955 | + .child(div().flex_1().min_w_0().truncate().child(summary.clone())) |
| 956 | + .child(div().flex_none().text_size(px(12.)).text_color(color).child(outcome)), |
| 957 | + ) |
| 958 | + .into_any_element() |
| 959 | + } |
| 960 | + |
| 961 | + /// An informational notice, as a divider across the conversation. |
| 962 | + fn render_divider(text: &SharedString, p: &Palette) -> AnyElement { |
| 963 | + let compacted = text.starts_with("Conversation compacted"); |
| 964 | + let label: SharedString = if compacted { "Context compacted".into() } else { text.clone() }; |
| 965 | + h_flex() |
| 966 | + .w_full() |
| 967 | + .gap(px(8.)) |
| 968 | + .py(px(8.)) |
| 969 | + .text_size(px(11.)) |
| 970 | + .text_color(p.ink3) |
| 971 | + .child(div().flex_1().h(px(1.)).bg(p.rule)) |
| 972 | + .child( |
| 973 | + h_flex() |
| 974 | + .max_w(relative(0.8)) |
| 975 | + .gap(px(6.)) |
| 976 | + .px(px(8.)) |
| 977 | + .when(compacted, |this| this.child(Icon::new(Lucide::Minimize2).size(px(12.)))) |
| 978 | + .child(div().min_w_0().font_weight(FontWeight::MEDIUM).child(label)), |
| 979 | + ) |
| 980 | + .child(div().flex_1().h(px(1.)).bg(p.rule)) |
| 981 | + .into_any_element() |
| 982 | + } |
| 983 | + |
| 984 | + /// An error: a tinted box with its message. |
| 985 | + fn render_error(text: &SharedString, p: &Palette) -> AnyElement { |
| 986 | + h_flex() |
| 987 | + .w_full() |
| 988 | + .items_start() |
| 989 | + .gap(px(8.)) |
| 990 | + .px(px(10.)) |
| 991 | + .py(px(6.)) |
| 992 | + .rounded(px(8.)) |
| 993 | + .bg(p.red.opacity(0.05)) |
| 994 | + .shadow(vec![BoxShadow::new(px(0.), px(0.), p.red.opacity(0.25)).spread_radius(px(1.)).inset()]) |
| 995 | + .text_size(px(12.)) |
| 996 | + .line_height(px(18.)) |
| 997 | + .text_color(p.ink) |
| 998 | + .child(div().pt(px(2.)).child(Icon::new(IconName::CircleAlert).size(px(14.)).text_color(p.red))) |
| 999 | + .child(div().flex_1().min_w_0().child(text.clone())) |
| 1000 | + .into_any_element() |
| 1001 | + } |
| 1002 | + |
| 1003 | + /// Where a tool's background task stands, and the buttons to move a long |
| 1004 | + /// command to the background or stop it there. |
| 1005 | + fn render_task_controls(session: &Entity<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> { |
| 1006 | + let session = session.clone(); |
| 1007 | + match (tool.background, &tool.task_id) { |
| 1008 | + (Some(TaskStatus::Running), Some(task_id)) => { |
| 1009 | + let task_id = task_id.clone(); |
| 1010 | + Some( |
| 1011 | + h_flex() |
| 1012 | + .gap(px(6.)) |
| 1013 | + .child(metal::tag("Background", p)) |
| 1014 | + .child( |
| 1015 | + MetalButton::new(("tool-stop-task", ix)) |
| 1016 | + .ghost() |
| 1017 | + .mini() |
| 1018 | + .icon(IconName::Square) |
| 1019 | + .tooltip("Stop this task") |
| 1020 | + .on_click(move |_, _, cx| { |
| 1021 | + cx.stop_propagation(); |
| 1022 | + session.update(cx, |session, cx| session.stop_task(&task_id, cx)); |
| 1023 | + }), |
| 1024 | + ) |
| 1025 | + .into_any_element(), |
| 1026 | + ) |
| 1027 | + } |
| 1028 | + (Some(status), _) => Some( |
| 1029 | + metal::engraved( |
| 1030 | + match status { |
| 1031 | + TaskStatus::Running | TaskStatus::Completed => "Ran in background", |
| 1032 | + TaskStatus::Failed => "Failed in background", |
| 1033 | + TaskStatus::Stopped => "Stopped", |
| 1034 | + }, |
| 1035 | + p, |
| 1036 | + ) |
| 1037 | + .into_any_element(), |
| 1038 | + ), |
| 1039 | + // A long foreground command can be sent to the background. |
| 1040 | + (None, Some(_)) if tool.state == ToolState::Running => Some( |
| 1041 | + MetalButton::new(("tool-background", ix)) |
| 1042 | + .ghost() |
| 1043 | + .mini() |
| 1044 | + .label("Background") |
| 1045 | + .tooltip("Keep it running in the background; Claude carries on and hears when it finishes") |
| 1046 | + .on_click(move |_, _, cx| { |
| 1047 | + cx.stop_propagation(); |
| 1048 | + session.update(cx, |session, cx| session.background_tool(ix, cx)); |
| 1049 | + }) |
| 1050 | + .into_any_element(), |
| 1051 | + ), |
| 1052 | + _ => None, |
| 1053 | + } |
| 1054 | + } |
| 1055 | + |
| 1056 | + fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement { |
| 1057 | + let p = metal::palette(cx); |
| 1058 | + // Claude and Pi name the same arguments differently. |
| 1059 | + let field = |keys: &[&str]| tool_field(&tool.input, keys); |
| 1060 | + let old_new = (field(&["old_string", "oldText"]), field(&["new_string", "newText"])); |
| 1061 | + |
| 1062 | + let mut sections: Vec<(&'static str, String)> = Vec::new(); |
| 1063 | + let mut plan: Option<String> = None; |
| 1064 | + match tool.name.to_ascii_lowercase().as_str() { |
| 1065 | + "bash" => { |
| 1066 | + if let Some(description) = field(&["description"]) { |
| 1067 | + sections.push(("Description", description)); |
| 1068 | + } |
| 1069 | + sections.push(("Command", field(&["command"]).unwrap_or_default())); |
| 1070 | + } |
| 1071 | + "edit" if old_new.0.is_some() || old_new.1.is_some() => { |
| 1072 | + sections.push(("File", field(&["file_path", "path"]).unwrap_or_default())); |
| 1073 | + sections.push(("Replace", old_new.0.unwrap_or_default())); |
| 1074 | + sections.push(("With", old_new.1.unwrap_or_default())); |
| 1075 | + } |
| 1076 | + "write" => { |
| 1077 | + sections.push(("File", field(&["file_path", "path"]).unwrap_or_default())); |
| 1078 | + sections.push(("Content", field(&["content"]).unwrap_or_default())); |
| 1079 | + } |
| 1080 | + "exitplanmode" => plan = field(&["plan"]), |
| 1081 | + _ => { |
| 1082 | + let input = if tool.input.is_null() && !tool.partial_input.is_empty() { |
| 1083 | + tool.partial_input.clone() |
| 1084 | + } else { |
| 1085 | + serde_json::to_string_pretty(&tool.input).unwrap_or_default() |
| 1086 | + }; |
| 1087 | + sections.push(("Input", input)); |
| 1088 | + } |
| 1089 | + } |
| 1090 | + if let Some(result) = &tool.result { |
| 1091 | + sections.push(( |
| 1092 | + if tool.state == ToolState::Error { "Error" } else { "Output" }, |
| 1093 | + truncate(result, MAX_TOOL_OUTPUT), |
| 1094 | + )); |
| 1095 | + } |
| 1096 | + |
| 1097 | + v_flex() |
| 1098 | + .gap(px(10.)) |
| 1099 | + .px(px(12.)) |
| 1100 | + .pb(px(12.)) |
| 1101 | + .pt(px(2.)) |
| 1102 | + .when_some(plan, |this, plan| { |
| 1103 | + this.child( |
| 1104 | + div() |
| 1105 | + .px(px(16.)) |
| 1106 | + .py(px(12.)) |
| 1107 | + .rounded(px(12.)) |
| 1108 | + .well(p) |
| 1109 | + .text_size(px(14.)) |
| 1110 | + .text_color(p.ink) |
| 1111 | + .child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)), |
| 1112 | + ) |
| 1113 | + }) |
| 1114 | + .children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| { |
| 1115 | + v_flex() |
| 1116 | + .gap(px(6.)) |
| 1117 | + .child(div().px(px(2.)).child(metal::engraved(label, p))) |
| 1118 | + .child( |
| 1119 | + div() |
| 1120 | + .id(ElementId::NamedInteger( |
| 1121 | + format!("tool-{ix}-section").into(), |
| 1122 | + section_ix as u64, |
| 1123 | + )) |
| 1124 | + .max_h(px(280.)) |
| 1125 | + .overflow_y_scroll() |
| 1126 | + .px(px(12.)) |
| 1127 | + .py(px(9.)) |
| 1128 | + .rounded(px(12.)) |
| 1129 | + .well(p) |
| 1130 | + .font_family(metal::MONO) |
| 1131 | + .text_size(px(11.5)) |
| 1132 | + .line_height(px(17.)) |
| 1133 | + .text_color(p.ink2) |
| 1134 | + .child(if body.is_empty() { "(empty)".to_string() } else { body }), |
| 1135 | + ) |
| 1136 | + })) |
| 1137 | + } |
| 1138 | + |