//! The conversation pane: transcript and composer, set on the sheet. A new //! thread starts centered, T3 Code style; once it has messages the composer //! moves to the bottom. use std::{path::PathBuf, time::Duration}; use gpui_kit::component::{ Icon, IconName, Sizable as _, h_flex, input::{self, InputEvent, Textarea, TextareaState}, menu::{DropdownMenu as _, PopupMenuItem}, message_scroller::{MessageScroller, MessageScrollerState}, popover::Popover, progress::ProgressCircle, text::{TextView, TextViewStyle}, tooltip::Tooltip, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::Interrupt; use crate::git; use crate::models::{Catalog, ModelEntry, Provider}; use crate::session::{ ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, }; use crate::ui::changes::{self, DiffView}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent}; use crate::ui::workspace::ProjectDirectory; const CONTENT_WIDTH: f32 = 760.; const COMPOSER_PLACEHOLDER: &str = "Ask for changes, send follow-ups, or attach images"; const ANSWER_PLACEHOLDER: &str = "Type your own answer, or pick an option above"; const MAX_TOOL_OUTPUT: usize = 6_000; /// What the conversation asks of the workspace around it. pub enum ChatEvent { /// Start a thread on this model; it belongs to another provider than this /// thread's. NewThreadWithModel(ModelEntry), /// Move this (still empty) thread to another project. SwitchProject(PathBuf), AddProject, StartWithoutProject, SwitchBranch(String), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Picker { Model, Reasoning, Access, } pub struct ChatView { session: Entity, composer: Entity, scroller: Entity, model_picker: Entity, open_picker: Option, attachments: Vec, diff: Option, /// Whether the composer is set up to answer an AskUserQuestion form. answering: bool, _subscriptions: Vec, _ticker: Task<()>, } impl EventEmitter for ChatView {} impl ChatView { pub fn new(session: Entity, window: &mut Window, cx: &mut Context) -> Self { let composer = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 12) .submit_on_enter(!crate::settings::get(|settings| settings.send_with_ctrl_enter)) .placeholder(COMPOSER_PLACEHOLDER) }); let item_count = session.read(cx).items.len(); let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx)); let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx)); let subscriptions = vec![ cx.observe_in(&session, window, |this, session, window, cx| { // While Claude waits on a question, the composer takes a custom answer. let answering = session.read(cx).pending_ask().is_some(); if answering != this.answering { this.answering = answering; let placeholder = if answering { ANSWER_PLACEHOLDER } else { COMPOSER_PLACEHOLDER }; this.composer.update(cx, |composer, cx| composer.set_placeholder(placeholder, window, cx)); } let count = session.read(cx).items.len(); this.scroller.update(cx, |scroller, cx| { let current = scroller.item_count(); if count > current { scroller.append(count - current, cx); } else if count < current { scroller.reset(count, cx); } }); cx.notify(); }), cx.subscribe_in(&composer, window, |this, _, event, window, cx| { if let InputEvent::PressEnter { shift: false, secondary } = event && (*secondary || !crate::settings::get(|settings| settings.send_with_ctrl_enter)) { this.submit(window, cx); } }), cx.subscribe_in(&model_picker, window, |this, _, event, window, cx| { let ModelPickerEvent::Picked(model) = event; this.open_picker = None; if model.provider == this.session.read(cx).provider { let id = model.id.clone(); this.session.update(cx, |session, cx| session.set_model(Some(id), cx)); this.focus_composer(window, cx); } else { cx.emit(ChatEvent::NewThreadWithModel(model.clone())); } cx.notify(); }), ]; // Re-render once a second while the agent works so the timer advances. let ticker = cx.spawn(async move |this, cx| { loop { cx.background_executor().timer(Duration::from_secs(1)).await; let alive = this.update(cx, |this, cx| { let session = this.session.read(cx); if session.is_working() || !session.background_tasks.is_empty() { cx.notify(); } }); if alive.is_err() { break; } } }); Self { session, composer, scroller, model_picker, open_picker: None, attachments: Vec::new(), diff: None, answering: false, _subscriptions: subscriptions, _ticker: ticker, } } pub fn session(&self) -> &Entity { &self.session } /// Picks up changed settings: the send key, and folding, which changes /// the height of transcript rows. pub fn settings_changed(&mut self, _: &mut Window, cx: &mut Context) { let ctrl_enter = crate::settings::get(|settings| settings.send_with_ctrl_enter); self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx)); self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); cx.notify(); } pub fn focus_composer(&self, window: &mut Window, cx: &mut App) { self.composer.update(cx, |composer, cx| composer.focus(window, cx)); } /// Carries what the user typed over from another thread, when this one /// replaces it (a provider or project switch before the first message). pub fn take_draft_from(&mut self, other: &Entity, window: &mut Window, cx: &mut Context) { let (text, attachments) = other.update(cx, |other, cx| { (other.composer.read(cx).value().to_string(), std::mem::take(&mut other.attachments)) }); self.attachments = attachments; self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx)); } fn submit(&mut self, window: &mut Window, cx: &mut Context) { let text = self.composer.read(cx).value().trim().to_string(); // An open question takes the text as a custom answer; Enter on an // empty composer commits the options ticked on a multi-select one. if let Some((ix, _)) = self.session.read(cx).pending_ask() { if text.is_empty() { self.session.update(cx, |session, cx| session.ask_confirm(ix, cx)); } else { self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); self.session.update(cx, |session, cx| session.ask_custom(ix, text, cx)); } return; } if text.is_empty() && self.attachments.is_empty() { return; } let attachments = std::mem::take(&mut self.attachments); self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); self.session.update(cx, |session, cx| session.send_message(text, attachments, cx)); self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx)); } /// 1–9 pick an option of the open question while the composer is empty. fn pick_with_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { let keystroke = &event.keystroke; if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() { return; } let Some(digit) = keystroke.key.parse::().ok().filter(|digit| (1..=9).contains(digit)) else { return; }; let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return }; if digit > ask.questions[ask.current].options.len() { return; } self.session.update(cx, |session, cx| session.ask_pick(ix, digit - 1, cx)); cx.stop_propagation(); } fn escape(&mut self, cx: &mut Context) { if self.diff.take().is_some() { cx.notify(); return; } self.session.update(cx, |session, cx| session.interrupt(cx)); } fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context) { let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: true, prompt: Some("Attach".into()), }); cx.spawn_in(window, async move |this, cx| { let Ok(Ok(Some(paths))) = paths.await else { return }; let _ = this.update_in(cx, |this, window, cx| { for path in paths { if !this.attachments.contains(&path) { this.attachments.push(path); } } this.focus_composer(window, cx); cx.notify(); }); }) .detach(); } /// Shows what a finished turn changed, all files or one. fn open_diff(&mut self, end: usize, path: Option, window: &mut Window, cx: &mut Context) { let session = self.session.read(cx); let Some(changes) = session.turn_changes(end) else { return }; let (base, head) = (changes.base.clone(), changes.head.clone()); let cwd = session.cwd.clone(); let title = match &path { Some(path) => path.clone(), None => format!("{} changed files", changes.files.len()), }; self.diff = Some(DiffView { title: title.into(), files: None }); let task = cx .background_executor() .spawn(async move { git::diff_between(&cwd, &base, &head, path.as_deref()) }); cx.spawn_in(window, async move |this, cx| { let files = task.await; let _ = this.update(cx, |this, cx| { if let Some(diff) = this.diff.as_mut() { diff.files = Some(files); cx.notify(); } }); }) .detach(); cx.notify(); } /// A callback that closes whichever picker is open. fn close_picker(cx: &Context) -> impl Fn(&mut Window, &mut App) + Clone + 'static { let chat = cx.entity().downgrade(); move |window: &mut Window, cx: &mut App| { let _ = chat.update(cx, |this, cx| { this.open_picker = None; this.focus_composer(window, cx); cx.notify(); }); } } fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context) { if open { self.open_picker = Some(picker); if picker == Picker::Model { self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)); } } else if self.open_picker == Some(picker) { self.open_picker = None; } cx.notify(); } /// "What should we build in T3-2?" with the project as a switcher. fn render_start(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let cwd = session.cwd.clone(); let directory = cx.try_global::(); let current = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd)); let scratch = current.is_some_and(|project| project.scratch); let project = current.map(|project| project.name.clone()).unwrap_or_else(|| display_path(&cwd).into()); let projects: Vec<(SharedString, PathBuf)> = directory .map(|directory| { directory .projects .iter() .filter(|project| !project.scratch) .map(|project| (project.name.clone(), project.path.clone())) .collect() }) .unwrap_or_default(); let chat = cx.entity().downgrade(); let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context| { menu = menu.min_w(px(240.)).label("Project"); for (name, path) in projects.clone() { let chat = chat.clone(); let checked = path == cwd; menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| { let path = path.clone(); let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path))); })); } let chat = chat.clone(); menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(move |_, _, cx| { let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::AddProject)); })) }; v_flex() .flex_1() .items_center() .justify_center() .px(px(24.)) .pb(px(48.)) .child( h_flex() .flex_wrap() .justify_center() .items_center() .text_size(px(34.)) .line_height(px(44.)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .map(|this| { if scratch { this.child("What should we build?") } else { this.child("What should we build in\u{a0}") .child( MetalButton::new("start-project") .ghost() .label(project) .h(px(44.)) .px(px(2.)) .rounded(px(0.)) .text_size(px(34.)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .border_b(px(2.)) .border_dashed() .border_color(p.ink3.opacity(0.6)) .dropdown_menu(project_menu.clone()), ) .child("?") } }), ) .map(|this| { if scratch { this.child( MetalButton::new("pick-project") .ghost() .compact() .label("or pick a project") .caret() .mt(px(6.)) .text_size(px(15.)) .text_color(p.ink3) .dropdown_menu(project_menu), ) } else { this.child( div() .id("start-without-project") .mt(px(6.)) .text_size(px(15.)) .text_color(p.ink3) .cursor_pointer() .hover(|style| style.text_color(p.ink)) .on_click(cx.listener(|_, _, _, cx| cx.emit(ChatEvent::StartWithoutProject))) .child("or start without a project"), ) } }) .child(div().h(px(30.))) .child(self.render_composer(true, window, cx)) } fn render_transcript(&self, cx: &mut Context) -> impl IntoElement { let session = self.session.clone(); let scroller = self.scroller.clone(); let chat = cx.entity().downgrade(); MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| { render_item(&session, &scroller, &chat, ix, window, cx) }) .flex_1() .w_full() .with_row_style(StyleRefinement::default().pb_0()) .with_bottom_fade(metal::palette(cx).s) } /// The line above the composer: what the agent is doing and for how long. fn render_activity(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let working = session.is_working(); let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0); let waiting = session.needs_attention(); h_flex() .w_full() .max_w(px(CONTENT_WIDTH)) .h(px(22.)) .px(px(6.)) .gap(px(8.)) .when(working, |this| { let (led, label) = if waiting { (Led::Waiting, "Waiting for you") } else if session.thinking { (Led::Live, "Thinking") } else { (Led::Live, "Working") }; this.child(metal::led("activity-led", led, true, p)) .child(metal::engraved(label, p)) .child( div() .font_family(metal::PIXEL) .text_size(px(15.)) .text_color(p.ink) .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)), ) .child(div().flex_1()) .child(metal::keycap("Esc", p)) .child(metal::engraved("Interrupt", p)) }) } /// What Claude left running in the background, each with its age and a /// stop button. Claude is woken up when one finishes. fn render_background_tasks(&self, cx: &mut Context) -> Option { let p = metal::palette(cx); let session = self.session.read(cx); if session.background_tasks.is_empty() { return None; } let count = session.background_tasks.len(); let rows = session.background_tasks.iter().enumerate().map(|(row, task)| { let elapsed = task.started.elapsed().as_secs(); let stop_session = self.session.clone(); let task_id = task.task_id.clone(); h_flex() .h(px(28.)) .gap(px(10.)) .px(px(4.)) .child(metal::led(("background-led", row), Led::Live, true, p)) .child( div() .flex_1() .min_w_0() .truncate() .text_size(px(12.5)) .text_color(p.ink2) .child(task.description.clone()), ) .child( div() .font_family(metal::PIXEL) .text_size(px(14.)) .text_color(p.ink2) .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)), ) .child( MetalButton::new(("background-stop", row)) .ghost() .mini() .icon(IconName::Square) .tooltip("Stop this task") .on_click(move |_, _, cx| { stop_session.update(cx, |session, cx| session.stop_task(&task_id, cx)) }), ) }); Some( v_flex() .w_full() .max_w(px(CONTENT_WIDTH)) .px(px(10.)) .py(px(6.)) .rounded(px(14.)) .region(p) .child( h_flex() .h(px(18.)) .px(px(4.)) .gap(px(8.)) .child(metal::engraved( if count == 1 { "1 background task".to_string() } else { format!("{count} background tasks") }, p, )) .child(div().flex_1()) .when(!session.is_working(), |this| { this.child(metal::engraved("Claude reports back when it finishes", p)) }), ) .children(rows) .into_any_element(), ) } fn render_attachments(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); h_flex() .flex_wrap() .gap(px(6.)) .px(px(12.)) .pt(px(12.)) .children(self.attachments.iter().enumerate().map(|(ix, path)| { let name = path .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| path.display().to_string()); let is_image = matches!( path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("png" | "jpg" | "jpeg" | "gif" | "webp") ); h_flex() .h(px(28.)) .pl(px(10.)) .pr(px(3.)) .gap(px(7.)) .rounded(px(10.)) .well(p) .text_size(px(12.5)) .text_color(p.ink2) .child( Icon::new(if is_image { Lucide::Image } else { Lucide::File }) .size(px(13.)) .text_color(p.ink3), ) .child(div().max_w(px(220.)).truncate().child(name)) .child( MetalButton::new(("remove-attachment", ix)) .ghost() .mini() .icon(IconName::Close) .tooltip("Remove") .on_click(cx.listener(move |this, _, _, cx| { if ix < this.attachments.len() { this.attachments.remove(ix); } cx.notify(); })), ) })) } /// A ring showing how full the context window is. fn render_context_ring(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let tokens = session.context_tokens.unwrap_or(0); let window = session.context_window; let percent = window.map_or(0., |window| tokens as f32 * 100. / window.max(1) as f32); let color = if percent >= 90. { p.red } else if percent >= 70. { p.amber_ink } else { p.ink3 }; let tooltip: SharedString = match (session.context_tokens, window) { (Some(tokens), Some(window)) => format!( "Context: {} of {} tokens ({percent:.0}%)", format_tokens(tokens), format_tokens(window) ) .into(), (Some(tokens), None) => format!("Context: {} tokens", format_tokens(tokens)).into(), _ => "Context usage appears after the first reply".into(), }; div() .id("context-ring") .flex() .items_center() .justify_center() .size(px(26.)) .rounded_full() .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) .child( ProgressCircle::new("context-progress") .value(percent.max(if session.context_tokens.is_some() { 1. } else { 0. })) .color(color) .with_size(gpui_kit::component::Size::Size(px(16.))), ) } /// A composer picker: a ghost button that opens `content` above it. fn picker( &self, picker: Picker, trigger: MetalButton, content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, cx: &mut Context, ) -> impl IntoElement { Popover::new(match picker { Picker::Model => "model-popover", Picker::Reasoning => "reasoning-popover", Picker::Access => "access-popover", }) .anchor(Anchor::BottomLeft) .p_0() .bg(gpui_kit::transparent_black()) .shadow_none() .open(self.open_picker == Some(picker)) .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) .trigger(trigger) .content(move |_, window, cx| content(window, cx)) } fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let working = session.is_working(); let provider = session.provider; let mode = session.permission_mode; let reasoning = session.reasoning; let fast_mode = session.fast_mode; let model = Catalog::global(cx).find(provider, session.model.as_deref()).cloned(); let model_label: SharedString = match (&model, &session.model) { (Some(model), _) => model.name.clone().into(), (None, Some(id)) => id.clone().into(), (None, None) => format!("{} default", provider.label()).into(), }; let has_input = !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty(); let focused = self.composer.read(cx).focus_handle(cx).is_focused(window); let ask_panel = session.pending_ask().map(|(ix, ask)| render_ask_panel(&self.session, ix, ask, p)); let send_button = if working && !has_input { MetalButton::new("stop") .danger() .icon(IconName::Square) .tooltip("Stop (Esc)") .on_click(cx.listener(|this, _, _, cx| this.escape(cx))) } else { MetalButton::new("send") .primary() .icon(IconName::ArrowUp) .disabled(!has_input) .tooltip( match (working, crate::settings::get(|settings| settings.send_with_ctrl_enter)) { (true, false) => "Queue message (Enter)", (true, true) => "Queue message (Ctrl+Enter)", (false, false) => "Send (Enter)", (false, true) => "Send (Ctrl+Enter)", }, ) .on_click(cx.listener(|this, _, window, cx| this.submit(window, cx))) }; // MetalUI marks focus with a green-deep ring. let mut card_shadow = p.raise.clone(); if ask_panel.is_some() { card_shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); } else if focused { card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.))); } let model_picker = self.model_picker.clone(); let reasoning_session = self.session.clone(); let access_session = self.session.clone(); let reasoning_model = model.clone(); let access_model = model.clone(); let close = Self::close_picker(cx); let close_access = close.clone(); let model_button = MetalButton::new("model") .ghost() .compact() .icon(pickers::provider_icon(provider, px(14.), p)) .label(model_label) .caret(); let reasoning_button = MetalButton::new("reasoning") .ghost() .compact() .icon(Lucide::Brain) .label(if fast_mode { format!("{} · Fast", reasoning.label()) } else { reasoning.label().to_string() }) .caret(); let access_mode = if provider == Provider::Pi { crate::claude::PermissionMode::BypassPermissions } else { mode }; let access_button = MetalButton::new("access") .ghost() .compact() .icon(pickers::access_icon(access_mode)) .label(access_mode.label()) .caret(); let toolbar = h_flex() .px(px(8.)) .pt(px(4.)) .pb(px(10.)) .gap(px(4.)) .child(self.picker(Picker::Model, model_button, move |_, _| model_picker.clone().into_any_element(), cx)) .child(metal::vrule(p)) .child(self.picker( Picker::Reasoning, reasoning_button, move |_, cx| { let p = metal::palette(cx); pickers::reasoning_menu(&reasoning_session, reasoning_model.as_ref(), close.clone(), p, cx) .into_any_element() }, cx, )) .child(metal::vrule(p)) .child(self.picker( Picker::Access, access_button, move |_, cx| { let p = metal::palette(cx); pickers::access_menu(&access_session, access_model.as_ref(), close_access.clone(), p, cx) .into_any_element() }, cx, )) .child(div().flex_1()) .child( MetalButton::new("attach") .ghost() .compact() .icon(Lucide::Paperclip) .tooltip("Attach files or images") .on_click(cx.listener(|this, _, window, cx| this.pick_attachments(window, cx))), ) .when(!start, |this| this.child(self.render_context_ring(cx))) .child(div().w(px(4.))) .child(send_button); let card = v_flex() .w_full() .rounded(px(24.)) .bg(metal::vertical((p.s_hi, p.s))) .shadow(card_shadow) .children(ask_panel) .when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx))) .child( div() .px(px(12.)) .pt(px(10.)) .text_size(px(14.)) .text_color(p.ink) .child(Textarea::new(&self.composer).appearance(false)), ) .child(toolbar); v_flex() .flex_none() .w_full() .items_center() .px(px(24.)) .pb(px(if start { 0. } else { 20. })) .gap(px(8.)) .when(!start, |this| this.child(self.render_activity(cx))) .when(!start, |this| this.children(self.render_background_tasks(cx))) .child( v_flex() .w_full() .max_w(px(CONTENT_WIDTH)) .items_center() .child(card) .when(start, |this| this.child(self.render_start_footer(cx))), ) } /// Under the start composer: where the thread will run, and on which branch. fn render_start_footer(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let cwd = self.session.read(cx).cwd.clone(); let directory = cx.try_global::(); let hostname = directory.map(|directory| directory.hostname.clone()).unwrap_or_default(); let project = directory.and_then(|directory| directory.projects.iter().find(|project| project.path == cwd)); let branch = project.and_then(|project| project.branch.clone()); let branches = project.map(|project| project.branches.clone()).unwrap_or_default(); let chat = cx.entity().downgrade(); let current = branch.clone(); h_flex() .w(relative(0.94)) .h(px(38.)) .px(px(10.)) .gap(px(6.)) .rounded_b(px(16.)) .region(p) .child( h_flex() .gap(px(6.)) .pl(px(4.)) .text_size(px(12.5)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .child(Icon::new(Lucide::Laptop).size(px(13.))) .child(Icon::new(IconName::Folder).size(px(13.))) .child(hostname) .child(metal::tag("Local", p)), ) .child(div().flex_1()) .when_some(branch, |this, branch| { this.child( MetalButton::new("start-branch") .ghost() .mini() .icon(Lucide::GitBranch) .label(format!("From {branch}")) .caret() .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { menu = menu.min_w(px(220.)).label("Start from branch"); for name in branches.clone() { let chat = chat.clone(); let checked = current.as_deref() == Some(name.as_str()); menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click( move |_, _, cx| { let name = name.clone(); let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchBranch(name))); }, )); } menu }), ) }) } } impl Render for ChatView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.session.read(cx).items.is_empty(); let close_diff = { let chat = cx.entity().downgrade(); move |_: &mut Window, cx: &mut App| { let _ = chat.update(cx, |this, cx| { this.diff = None; cx.notify(); }); } }; v_flex() .key_context("ChatView") .size_full() .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx))) .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx))) .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| this.pick_with_key(event, cx))) .map(|this| match &self.diff { Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))), None if is_empty => this.child(self.render_start(window, cx)), None => this.child(self.render_transcript(cx)), }) .when(!is_empty, |this| this.child(self.render_composer(false, window, cx))) } } /// Shortens a path under the home directory to `~/…`. pub fn display_path(path: &std::path::Path) -> String { let home = std::env::var_os("HOME").map(std::path::PathBuf::from); match home.as_deref().and_then(|home| path.strip_prefix(home).ok()) { Some(rest) if rest.as_os_str().is_empty() => "~".to_string(), Some(rest) => format!("~/{}", rest.display()), None => path.display().to_string(), } } fn format_tokens(tokens: u64) -> String { let (value, unit) = match tokens { 0..1_000 => return tokens.to_string(), 1_000..1_000_000 => (tokens as f64 / 1_000., "k"), _ => (tokens as f64 / 1_000_000., "M"), }; let value = format!("{value:.1}"); format!("{}{unit}", value.trim_end_matches(".0")) } /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from /// the theme, which `metal` keeps in step with the colorway. fn markdown_style(p: &Palette) -> TextViewStyle { let mut code_block = StyleRefinement::default() .rounded(px(14.)) .bg(metal::vertical((p.well_top, p.well_bot))) .shadow(p.well.clone()); code_block.padding.left = Some(px(14.).into()); code_block.padding.right = Some(px(14.).into()); TextViewStyle { is_dark: p.dark, ..TextViewStyle::default() } .code_block(code_block) .table(StyleRefinement::default().rounded(px(12.))) .inline_code(HighlightStyle { background_color: Some(p.well_top), ..Default::default() }) } /// Markdown with a copy button on every code block. fn markdown(state: &Entity, p: &Palette) -> TextView { TextView::new(state) .style(markdown_style(p)) .selectable(true) .code_block_actions(|block, _, _| { let code = block.code(); let id = { use std::hash::{Hash as _, Hasher as _}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); code.hash(&mut hasher); hasher.finish() }; MetalButton::new(ElementId::NamedInteger("copy-code".into(), id)) .ghost() .mini() .icon(IconName::Copy) .tooltip("Copy") .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string()))) }) } /// The row a finished turn folds into: "Worked for 11s ›". fn render_turn_summary( session: &Entity, scroller: &Entity, turn: &FinishedTurn, expanded: bool, p: &Palette, ) -> AnyElement { let end = turn.end; let toggle_session = session.clone(); let scroller = scroller.clone(); v_flex() .w_full() .gap(px(8.)) .child( h_flex() .id(("turn-summary", end)) .gap(px(6.)) .text_size(px(13.5)) .text_color(p.ink3) .child(format!("Worked for {}", format_duration(turn.duration_ms))) .when(turn.has_work, |this| { this.cursor_pointer() .hover(|style| style.text_color(p.ink)) .child( Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(13.)), ) .on_click(move |_, _, cx| { toggle_session.update(cx, |session, cx| session.toggle_turn(end, cx)); // Folding changes the height of every row in the turn. scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); }) }) .child(div().flex_1()) .when_some( turn.cost.filter(|cost| *cost > 0. && crate::settings::get(|settings| settings.show_costs)), |this, cost| this.child(metal::engraved(format!("${cost:.4}"), p)), ), ) .child(metal::rule(p).w_full()) .into_any_element() } /// A question from an agent extension, answered with one click. fn render_question(session: &Entity, ix: usize, question: &Question, p: &Palette) -> AnyElement { v_flex() .w_full() .gap(px(10.)) .px(px(14.)) .py(px(12.)) .rounded(px(14.)) .bg(metal::vertical(p.row_on)) .shadow({ let mut shadow = p.raise_sm.clone(); if question.answer.is_none() { shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); } shadow }) .child( h_flex() .gap(px(8.)) .child(metal::engraved("Question", p).text_color(p.amber_ink)) .child( div() .text_size(px(13.5)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .child(question.title.clone()), ), ) .when_some(question.message.clone(), |this, message| { this.child(div().text_size(px(13.)).text_color(p.ink2).child(message)) }) .map(|this| match &question.answer { Some(answer) => this.child(metal::engraved(format!("Answered: {answer}"), p)), None => this.child(h_flex().flex_wrap().gap(px(6.)).children(question.options.iter().enumerate().map( |(option, label)| { let session = session.clone(); MetalButton::new(ElementId::NamedInteger(format!("question-{ix}").into(), option as u64)) .compact() .when(option == 0 && question.confirm, |this| this.primary()) .label(label.clone()) .on_click(move |_, _, cx| { session.update(cx, |session, cx| session.answer_question(ix, option, cx)) }) }, ))), }) .into_any_element() } /// Claude's AskUserQuestion form, docked on top of the composer: one /// question at a time, its options as numbered rows. The composer below takes /// a custom answer. fn render_ask_panel(session: &Entity, ix: usize, ask: &UserQuestions, p: &Palette) -> AnyElement { let current = ask.current; let question = &ask.questions[current]; let picked = &ask.picked[current]; let answer = ask.answers[current].as_ref(); let count = ask.questions.len(); let tabs = h_flex().gap(px(4.)).flex_wrap().when(count > 1, |this| { this.children(ask.questions.iter().enumerate().map(|(q, question)| { let session = session.clone(); let selected = q == current; let label: SharedString = if question.header.is_empty() { format!("Q{}", q + 1).into() } else { question.header.clone() }; h_flex() .id(ElementId::NamedInteger("ask-tab".into(), q as u64)) .h(px(22.)) .px(px(9.)) .gap(px(5.)) .rounded_full() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .map(|this| { if selected { this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink) } else { this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink)) } }) .when(ask.answers[q].is_some(), |this| { this.child(Icon::new(IconName::Check).size(px(11.)).text_color(p.green_ink)) }) .child(label) .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_show(ix, q, cx))) })) }); let skip_session = session.clone(); let header = h_flex() .gap(px(10.)) .child(metal::led(("ask-led", ix), Led::Waiting, true, p)) .child( metal::engraved( if count > 1 { format!("Question {} of {count}", current + 1) } else { "Question".to_string() }, p, ) .text_color(p.amber_ink), ) .child(tabs) .child(div().flex_1()) .child( MetalButton::new(("ask-skip", ix)) .ghost() .mini() .label("Skip") .tooltip("Dismiss without answering") .on_click(move |_, _, cx| skip_session.update(cx, |session, cx| session.ask_skip(ix, cx))), ); let options = v_flex().gap(px(4.)).children(question.options.iter().enumerate().map(|(option, choice)| { let session = session.clone(); let selected = if question.multi_select { picked.contains(&option) } else { answer.is_some_and(|answer| *answer == choice.label) }; let marker = div() .flex() .flex_none() .items_center() .justify_center() .size(px(16.)) .map(|this| if question.multi_select { this.rounded(px(5.)) } else { this.rounded_full() }) .map(|this| { if selected { this.bg(metal::vertical((p.green_ink, p.green_deep))).shadow(p.pressed_sh.clone()) } else { this.well(p) } }) .when(selected, |this| { this.child(Icon::new(IconName::Check).size(px(11.)).text_color(gpui_kit::white())) }); h_flex() .id(ElementId::NamedInteger(format!("ask-{ix}-option").into(), option as u64)) .items_start() .gap(px(10.)) .px(px(10.)) .py(px(8.)) .rounded(px(12.)) .cursor_pointer() .map(|this| { if selected { this.bg(metal::vertical(p.row_on)).shadow({ let mut shadow = p.raise_sm.clone(); shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.45)).spread_radius(px(1.))); shadow }) } else { this.hover(|style| style.bg(p.row_hover)) } }) .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_pick(ix, option, cx))) .when(option < 9, |this| this.child(metal::keycap((option + 1).to_string(), p))) .child( v_flex() .flex_1() .min_w_0() .gap(px(2.)) .child( div() .text_size(px(13.5)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(choice.label.clone()), ) .when_some(choice.description.clone().filter(|description| *description != choice.label), |this, description| { this.child(div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(description)) }), ) .child(div().pt(px(1.)).child(marker)) })); let hint = match (question.multi_select, answer) { (_, Some(answer)) if !question.options.iter().any(|option| option.label == *answer) && !question.multi_select => { format!("Your answer: {answer}") } (true, _) => "Pick any that apply, or type your own answer below.".to_string(), (false, _) => "Pick one, or type your own answer below.".to_string(), }; let last_open = ask.answers.iter().enumerate().all(|(q, answer)| q == current || answer.is_some()); let confirm_session = session.clone(); let footer = h_flex() .gap(px(8.)) .child(div().flex_1().min_w_0().text_size(px(12.)).text_color(p.ink3).child(hint)) .when(question.multi_select, |this| { this.child( MetalButton::new(("ask-confirm", ix)) .primary() .compact() .label(if last_open { "Submit" } else { "Next" }) .disabled(picked.is_empty()) .on_click(move |_, _, cx| confirm_session.update(cx, |session, cx| session.ask_confirm(ix, cx))), ) }); v_flex() .gap(px(10.)) .px(px(16.)) .pt(px(14.)) .pb(px(12.)) .child(header) .child( div() .text_size(px(15.)) .line_height(px(22.)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .child(question.question.clone()), ) .child(options) .child(footer) .child(metal::rule(p).mt(px(2.))) .into_any_element() } /// What an AskUserQuestion call asked and what the user answered, as it /// stays in the transcript. fn render_ask_record(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement { let state = tool.permission.as_ref().map(|prompt| prompt.state); let pending = state == Some(PermissionState::Pending); let status = match state { Some(PermissionState::Pending) => "Waiting for your answer", Some(PermissionState::Denied) => "Skipped", Some(PermissionState::Cancelled) => "Cancelled", _ => "Answered", }; let mut shadow = p.raise_sm.clone(); if pending { shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); } v_flex() .w_full() .gap(px(10.)) .px(px(14.)) .py(px(12.)) .rounded(px(14.)) .bg(metal::vertical(p.row_on)) .shadow(shadow) .child( h_flex() .gap(px(8.)) .child(Icon::new(Lucide::MessageCircleQuestionMark).size(px(14.)).text_color(if pending { p.amber_ink } else { p.ink3 })) .child(metal::engraved(if ask.questions.len() > 1 { "Questions" } else { "Question" }, p).when( pending, |this| this.text_color(p.amber_ink), )) .child(div().flex_1()) .child(metal::engraved(status, p).when(state == Some(PermissionState::Allowed), |this| { this.text_color(p.green_ink) })), ) .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| { // Headers sit in a fixed column so questions and answers line up. h_flex() .id(ElementId::NamedInteger(format!("ask-record-{ix}").into(), q as u64)) .items_start() .gap(px(8.)) .child(div().w(px(76.)).flex_none().pt(px(2.)).when(!question.header.is_empty(), |this| { this.child(metal::tag(question.header.clone(), p)) })) .child( v_flex() .flex_1() .min_w_0() .gap(px(4.)) .child( div() .text_size(px(13.5)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .child(question.question.clone()), ) .when_some(answer.clone(), |this, answer| { this.child( h_flex() .items_start() .gap(px(6.)) .text_size(px(13.)) .text_color(p.ink2) .child( Icon::new(Lucide::CornerDownRight) .size(px(12.)) .mt(px(3.)) .text_color(p.green_ink), ) .child(div().flex_1().min_w_0().child(answer)), ) }), ) })) .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, } } /// A background task finished. Claude is woken up by it, so it heads the /// turn that follows. fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement { let (icon, color) = match status { TaskStatus::Completed => (IconName::CircleCheck, p.green_ink), TaskStatus::Running => (IconName::Info, p.blue_ink), TaskStatus::Failed => (IconName::CircleX, p.red), TaskStatus::Stopped => (IconName::Square, p.ink3), }; h_flex() .id(("task-event", ix)) .items_start() .gap(px(10.)) .px(px(12.)) .py(px(8.)) .rounded(px(12.)) .region(p) .child(Icon::new(icon).size(px(13.)).mt(px(2.)).text_color(color)) .child(div().flex_1().min_w_0().text_size(px(13.)).text_color(p.ink2).child(summary.clone())) .child(div().pt(px(2.)).child(metal::engraved( match status { TaskStatus::Stopped => "Background task stopped", TaskStatus::Failed => "Background task failed", TaskStatus::Running => "Background task event", TaskStatus::Completed => "Background task", }, p, ))) .into_any_element() } /// Renders one transcript row. Runs inside the virtual list, so it only gets /// `App` and talks to the session entity directly. fn render_item( session: &Entity, 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(); }; // 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 = crate::settings::get(|settings| settings.fold_turns); let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold); let summary = turn .as_ref() .filter(|turn| turn.first == ix) .map(|turn| render_turn_summary(session, scroller, turn, expanded, p)); let visible = match (&turn, item) { (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(), (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true, (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true, (Some(turn), _) => expanded || Some(ix) == turn.answer, (None, _) => true, }; if !visible && summary.is_none() { return div().into_any_element(); } let content: Option = visible.then(|| match item { ChatItem::User { text, attachments } => h_flex() .w_full() .justify_end() .child( v_flex() .max_w(relative(0.8)) .gap(px(8.)) .px(px(16.)) .py(px(10.)) .rounded(px(18.)) .raised(p) .text_size(px(14.)) .line_height(px(21.)) .text_color(p.ink) .when(!text.is_empty(), |this| this.child(text.clone())) .when(!attachments.is_empty(), |this| { this.child(h_flex().flex_wrap().gap(px(6.)).children(attachments.iter().map(|name| { h_flex() .h(px(22.)) .px(px(8.)) .gap(px(6.)) .rounded(px(8.)) .well(p) .text_size(px(12.)) .text_color(p.ink2) .child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3)) .child(name.clone()) }))) }), ) .into_any_element(), ChatItem::Assistant(state) => div() .w_full() .px(px(4.)) .text_size(px(14.)) .line_height(px(22.)) .text_color(p.ink) .child(markdown(state, p)) .into_any_element(), ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx), ChatItem::Question(question) => render_question(session, ix, question, p), ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p), ChatItem::Notice(NoticeKind::Info, text) => h_flex() .items_start() .gap(px(8.)) .px(px(6.)) .text_size(px(12.5)) .text_color(p.ink3) .child(Icon::new(IconName::Info).size(px(13.)).mt(px(2.))) .child(div().flex_1().min_w_0().child(text.clone())) .into_any_element(), ChatItem::Notice(NoticeKind::Error, text) => h_flex() .items_start() .gap(px(10.)) .px(px(14.)) .py(px(11.)) .rounded(px(14.)) .region(p) .bg(p.red.opacity(0.07)) .text_size(px(13.)) .text_color(p.ink) .child(div().pt(px(5.)).child(metal::led(("notice-led", ix), Led::Failed, false, p))) .child(div().flex_1().min_w_0().child(text.clone())) .into_any_element(), ChatItem::TurnEnd { .. } => match state.turn_changes(ix) { Some(changes) => { let chat = chat.clone(); changes::render_changes( session, ix, changes, move |path, window, cx| { let _ = chat.update(cx, |this, cx| this.open_diff(ix, path, window, cx)); }, cx, ) } None => div().into_any_element(), }, }); // Tool cards sit close together; a new user message starts a new turn. let (top, bottom) = match item { _ if !visible => (px(10.), px(6.)), ChatItem::User { .. } if ix > 0 => (px(28.), px(10.)), ChatItem::User { .. } => (px(20.), px(10.)), ChatItem::Tool(_) => (px(4.), px(4.)), ChatItem::TurnEnd { .. } => (px(10.), px(6.)), _ => (px(6.), px(6.)), }; h_flex() .w_full() .justify_center() .px(px(28.)) .pt(top) .pb(bottom) .child( v_flex() .w_full() .max_w(px(CONTENT_WIDTH)) .gap(px(12.)) .map(|this| match item { ChatItem::TaskEvent { .. } => this.children(content).children(summary), _ => this.children(summary).children(content), }), ) .into_any_element() } fn render_tool( session: &Entity, ix: usize, tool: &ToolCall, cwd: &std::path::Path, cx: &App, ) -> AnyElement { let p = metal::palette(cx); if let Some(ask) = &tool.ask { return render_ask_record(ix, tool, ask, p); } let summary = tool_summary(&tool.name, &tool.input, cwd); let pending = tool .permission .as_ref() .is_some_and(|prompt| prompt.state == PermissionState::Pending); let expanded = tool.expanded || pending; let status: AnyElement = match (tool.state, pending) { (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p), // A backgrounded tool returns at once; what counts is its task. _ if tool.background == Some(TaskStatus::Running) => metal::led(("tool-led", ix), Led::Live, true, p), _ if tool.background == Some(TaskStatus::Failed) => { Icon::new(IconName::CircleX).size(px(13.)).text_color(p.red).into_any_element() } _ if tool.background == Some(TaskStatus::Stopped) => { Icon::new(IconName::Square).size(px(11.)).text_color(p.ink3).into_any_element() } (ToolState::Running, _) => metal::led(("tool-led", ix), Led::Live, true, p), (ToolState::Done, _) => Icon::new(IconName::Check) .size(px(13.)) .text_color(p.green_ink) .into_any_element(), (ToolState::Error, _) => Icon::new(IconName::CircleX) .size(px(13.)) .text_color(p.red) .into_any_element(), }; let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state { PermissionState::Allowed => Some(("Approved", p.green_ink)), PermissionState::Denied => Some(("Denied", p.red)), PermissionState::Cancelled => Some(("Cancelled", p.ink3)), PermissionState::Pending => None, }); let toggle_session = session.clone(); let header = h_flex() .id(("tool-header", ix)) .h(px(36.)) .px(px(12.)) .gap(px(10.)) .cursor_pointer() .rounded(px(14.)) .hover(|this| this.bg(p.row_hover)) .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx))) .child(div().w(px(14.)).flex().justify_center().child(status)) .child(Icon::new(tool_icon(&tool.name)).size(px(14.)).text_color(p.ink3)) .child( div() .flex_none() .text_size(px(13.)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink) .child(tool.name.clone()), ) .child( div() .flex_1() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(11.5)) .text_color(p.ink3) .child(summary), ) .when(tool.subagent_tool_count > 0, |this| { this.child(metal::engraved(format!("{} tool uses", tool.subagent_tool_count), p)) }) .when_some(decision, |this, (label, color)| { this.child(metal::engraved(label, p).text_color(color)) }) .children(render_task_controls(session, ix, tool, p)) .child( Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(12.)) .text_color(p.ink3), ); let mut shadow = p.raise_sm.clone(); if pending { shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); } v_flex() .w_full() .rounded(px(14.)) .bg(metal::vertical(p.row_on)) .shadow(shadow) .child(header) .when(expanded, |this| this.child(render_tool_details(ix, tool, cx))) .when(pending, |this| this.child(render_permission_bar(session, ix, tool, cx))) .into_any_element() } fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement { let p = metal::palette(cx); // Claude and Pi name the same arguments differently. let field = |keys: &[&str]| tool_field(&tool.input, keys); let old_new = (field(&["old_string", "oldText"]), field(&["new_string", "newText"])); let mut sections: Vec<(&'static str, String)> = Vec::new(); let mut plan: Option = None; match tool.name.to_ascii_lowercase().as_str() { "bash" => { if let Some(description) = field(&["description"]) { sections.push(("Description", description)); } sections.push(("Command", field(&["command"]).unwrap_or_default())); } "edit" if old_new.0.is_some() || old_new.1.is_some() => { sections.push(("File", field(&["file_path", "path"]).unwrap_or_default())); sections.push(("Replace", old_new.0.unwrap_or_default())); sections.push(("With", old_new.1.unwrap_or_default())); } "write" => { sections.push(("File", field(&["file_path", "path"]).unwrap_or_default())); sections.push(("Content", field(&["content"]).unwrap_or_default())); } "exitplanmode" => plan = field(&["plan"]), _ => { let input = if tool.input.is_null() && !tool.partial_input.is_empty() { tool.partial_input.clone() } else { serde_json::to_string_pretty(&tool.input).unwrap_or_default() }; sections.push(("Input", input)); } } if let Some(result) = &tool.result { sections.push(( if tool.state == ToolState::Error { "Error" } else { "Output" }, truncate(result, MAX_TOOL_OUTPUT), )); } v_flex() .gap(px(10.)) .px(px(12.)) .pb(px(12.)) .pt(px(2.)) .when_some(plan, |this, plan| { this.child( div() .px(px(16.)) .py(px(12.)) .rounded(px(12.)) .well(p) .text_size(px(14.)) .text_color(p.ink) .child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)), ) }) .children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| { v_flex() .gap(px(6.)) .child(div().px(px(2.)).child(metal::engraved(label, p))) .child( div() .id(ElementId::NamedInteger( format!("tool-{ix}-section").into(), section_ix as u64, )) .max_h(px(280.)) .overflow_y_scroll() .px(px(12.)) .py(px(9.)) .rounded(px(12.)) .well(p) .font_family(metal::MONO) .text_size(px(11.5)) .line_height(px(17.)) .text_color(p.ink2) .child(if body.is_empty() { "(empty)".to_string() } else { body }), ) })) } fn render_permission_bar( session: &Entity, ix: usize, tool: &ToolCall, cx: &App, ) -> impl IntoElement { let p = metal::palette(cx); let always_label = tool.permission.as_ref().and_then(|prompt| prompt.always_label()); let respond = |decision: PermissionDecision| { let session = session.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.respond_permission(ix, decision, cx)); } }; let question = match tool.name.as_str() { "ExitPlanMode" => "Claude finished planning. Start implementing?".to_string(), name => format!("Allow Claude to use {name}?"), }; v_flex() .child(metal::rule(p)) .child( h_flex() .gap(px(8.)) .px(px(12.)) .py(px(10.)) .child(metal::engraved("Approval", p).text_color(p.amber_ink)) .child( div() .flex_1() .min_w_0() .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(question), ) .child( MetalButton::new(("deny", ix)) .ghost() .compact() .label("Deny") .on_click(respond(PermissionDecision::Deny)), ) .when_some(always_label, |this, label| { this.child( MetalButton::new(("allow-always", ix)) .compact() .label(label) .on_click(respond(PermissionDecision::AllowAlways)), ) }) .child( MetalButton::new(("allow", ix)) .primary() .compact() .icon(IconName::Check) .label("Allow") .on_click(respond(PermissionDecision::Allow)), ), ) } fn tool_icon(name: &str) -> IconName { match name.to_ascii_lowercase().as_str() { "bash" | "bashoutput" | "killshell" => IconName::SquareTerminal, "read" => IconName::FileText, "write" | "edit" | "multiedit" | "notebookedit" => IconName::File, "glob" | "grep" | "find" | "ls" => IconName::Search, "webfetch" | "websearch" => IconName::Globe, "task" | "agent" => IconName::Bot, "todowrite" => IconName::CircleCheck, "exitplanmode" => IconName::Map, "monitor" => IconName::Eye, _ => IconName::Settings2, } } fn truncate(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } let mut truncated: String = text.chars().take(max_chars).collect(); truncated.push_str("\n… (truncated)"); truncated } fn format_duration(ms: u64) -> String { let secs = ms / 1000; if secs < 1 { format!("{ms}ms") } else if secs < 60 { format!("{:.1}s", ms as f64 / 1000.) } else { format!("{}m {}s", secs / 60, secs % 60) } }