//! 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, SystemTime},
};
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::clock;
use crate::git;
use crate::models::{Catalog, ModelEntry, Provider, SlashCommand};
use crate::session::{
ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session,
SessionEvent, ToolCall, ToolState, tool_field, tool_summary,
};
use crate::settings::{self, FollowUp, WorkspaceMode};
use crate::ui::changes::{self, DiffAction, 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 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: its folder, and
/// the remote connection it's on (`None` here).
SwitchProject(PathBuf, Option<String>),
AddProject,
StartWithoutProject,
SwitchBranch(String),
/// Run this (still empty) thread in the project folder or a new worktree.
SetWorkspace(WorkspaceMode),
}
/// Proactive panels open the diff after a turn that changed at least this
/// many files or lines.
const PROACTIVE_FILES: usize = 3;
const PROACTIVE_LINES: u64 = 50;
/// The most rows the `/` menu lists.
const SLASH_ROWS: usize = 40;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Picker {
Model,
Reasoning,
Access,
}
pub struct ChatView {
session: Entity<Session>,
composer: Entity<TextareaState>,
scroller: Entity<MessageScrollerState>,
model_picker: Entity<ModelPicker>,
open_picker: Option<Picker>,
attachments: Vec<PathBuf>,
diff: Option<DiffView>,
/// The highlighted row of the `/` menu.
slash_selected: usize,
/// The composer text the `/` menu was dismissed at, with Esc.
slash_dismissed: Option<String>,
/// The composer rests on one line while the conversation scrolls.
collapsed: bool,
_subscriptions: Vec<Subscription>,
_ticker: Task<()>,
}
impl EventEmitter<ChatEvent> for ChatView {}
impl ChatView {
pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let composer = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(2, 12)
.submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter))
.placeholder("Ask for changes, send follow-ups, or attach images")
});
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(&session, |this, session, 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| match event {
InputEvent::PressEnter { shift: false, secondary } => {
let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
if *secondary || !ctrl_enter {
// With Enter sending, Ctrl+Enter does the opposite
// of the follow-up setting.
this.submit(*secondary && !ctrl_enter, window, cx);
}
}
InputEvent::Change => {
this.slash_selected = 0;
this.expand(cx);
cx.notify();
}
InputEvent::Focus => this.expand(cx),
_ => {}
}),
cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| {
let SessionEvent::TurnChanges(end) = event;
let big = session.read(cx).turn_changes(*end).is_some_and(|changes| {
changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES
});
if big && this.diff.is_none() && settings::get(|settings| settings.proactive_panels) {
this.open_diff(*end, None, 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| {
if this.session.read(cx).is_working() {
cx.notify();
}
});
if alive.is_err() {
break;
}
}
});
Self {
session,
composer,
scroller,
model_picker,
open_picker: None,
attachments: Vec::new(),
diff: None,
slash_selected: 0,
slash_dismissed: None,
collapsed: false,
_subscriptions: subscriptions,
_ticker: ticker,
}
}
pub fn session(&self) -> &Entity<Session> {
&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<Self>) {
let ctrl_enter = 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<ChatView>, window: &mut Window, cx: &mut Context<Self>) {
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));
}
/// Sends what's in the composer. While the agent works, the follow-up
/// setting decides between steering it now and queueing for after the
/// turn; `opposite` flips that for this one message.
fn submit(&mut self, opposite: bool, window: &mut Window, cx: &mut Context<Self>) {
let text = self.composer.read(cx).value().trim().to_string();
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));
let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
self.session.update(cx, |session, cx| {
if (session.is_working() || session.preparing) && queue_mode != opposite {
session.queue_message(text, attachments, cx);
} else {
session.send_message(text, attachments, cx);
}
});
self.slash_dismissed = None;
self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
}
fn expand(&mut self, cx: &mut Context<Self>) {
if self.collapsed {
self.collapsed = false;
cx.notify();
}
}
// ---- the `/` menu ------------------------------------------------------
/// The commands the `/` menu offers for what's typed: `/` lists commands
/// (and skills, when set to), `$` lists skills. `None` when the composer
/// doesn't hold a lone `/word` or `$word`.
fn slash_matches(&self, cx: &App) -> Option<Vec<SlashCommand>> {
let text = self.composer.read(cx).value();
if self.slash_dismissed.as_deref() == Some(text.as_ref()) {
return None;
}
let trigger = text.chars().next().filter(|c| matches!(c, '/' | '$'))?;
let query = &text[1..];
if query.contains(char::is_whitespace) {
return None;
}
let query = query.to_lowercase();
let skills_too = settings::get(|settings| settings.slash_skills);
let instance = self.session.read(cx).instance.clone();
let commands = Catalog::global(cx).commands(&instance);
let wanted = |command: &&SlashCommand| match trigger {
'$' => command.skill,
_ => !command.skill || skills_too,
};
let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands
.iter()
.filter(wanted)
.filter(|command| command.name.to_lowercase().contains(&query))
.cloned()
.partition(|command| command.name.to_lowercase().starts_with(&query));
prefix.append(&mut inside);
prefix.truncate(SLASH_ROWS);
Some(prefix).filter(|matches| !matches.is_empty())
}
/// Puts the picked command in the composer, ready for its arguments.
fn accept_slash(&mut self, command: &SlashCommand, window: &mut Window, cx: &mut Context<Self>) {
let text = format!("/{} ", command.name);
self.composer.update(cx, |composer, cx| {
composer.set_value(text, window, cx);
composer.focus(window, cx);
});
self.slash_selected = 0;
cx.notify();
}
/// Keys the `/` menu takes before the composer sees them, and
/// Ctrl+Shift+Enter, which sends against the follow-up setting when
/// Ctrl+Enter is the send key.
fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let keystroke = &event.keystroke;
let modifiers = &keystroke.modifiers;
if keystroke.key == "enter" && modifiers.control && modifiers.shift {
cx.stop_propagation();
self.submit(true, window, cx);
return;
}
let Some(matches) = self.slash_matches(cx) else { return };
if modifiers.control || modifiers.alt || modifiers.shift {
return;
}
match keystroke.key.as_str() {
"up" => self.slash_selected = (self.slash_selected + matches.len() - 1) % matches.len(),
"down" => self.slash_selected = (self.slash_selected + 1) % matches.len(),
"enter" | "tab" => {
let command = matches[self.slash_selected.min(matches.len() - 1)].clone();
cx.stop_propagation();
self.accept_slash(&command, window, cx);
return;
}
"escape" => self.slash_dismissed = Some(self.composer.read(cx).value().to_string()),
_ => return,
}
cx.stop_propagation();
cx.notify();
}
fn render_slash_menu(&self, matches: Vec<SlashCommand>, cx: &mut Context<Self>) -> Div {
let p = metal::palette(cx);
let selected = self.slash_selected.min(matches.len().saturating_sub(1));
pickers::panel(p)
.w_full()
.mb(px(8.))
.child(
v_flex()
.id("slash-menu")
.max_h(px(280.))
.overflow_y_scroll()
.gap(px(1.))
.children(matches.into_iter().enumerate().map(|(ix, command)| {
let pick = command.clone();
h_flex()
.id(("slash-command", ix))
.gap(px(10.))
.px(px(10.))
.py(px(6.))
.rounded(px(10.))
.cursor_pointer()
.when(ix == selected, |this| this.bg(p.menu_row_hover))
.hover(|style| style.bg(p.menu_row_hover))
.on_click(cx.listener(move |this, _, window, cx| this.accept_slash(&pick, window, cx)))
.child(
div()
.flex_none()
.font_family(metal::MONO)
.text_size(px(12.5))
.text_color(p.ink)
.child(format!("/{}", command.name)),
)
.when(!command.argument_hint.is_empty(), |this| {
this.child(
div()
.flex_shrink()
.max_w(px(200.))
.truncate()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.ink3)
.child(command.argument_hint.clone()),
)
})
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.5))
.text_color(p.ink3)
.child(command.description.clone()),
)
.when(command.skill, |this| this.child(metal::tag("Skill", p)))
})),
)
}
fn escape(&mut self, cx: &mut Context<Self>) {
if self.slash_matches(cx).is_some() {
self.slash_dismissed = Some(self.composer.read(cx).value().to_string());
cx.notify();
return;
}
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<Self>) {
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<String>, window: &mut Window, cx: &mut Context<Self>) {
let Some(changes) = self.session.read(cx).turn_changes(end) else { return };
let title = match &path {
Some(path) => path.clone(),
None => format!("{} changed files", changes.files.len()),
};
let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| {
(settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed)
});
self.diff = Some(DiffView {
title: title.into(),
end,
path,
ignore_whitespace,
side_by_side,
start_collapsed: collapsed,
..Default::default()
});
self.load_diff(window, cx);
cx.notify();
}
/// Reads the open diff in the background, as its whitespace toggle asks.
fn load_diff(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(diff) = &self.diff else { return };
let session = self.session.read(cx);
let Some(changes) = session.turn_changes(diff.end) else { return };
if session.is_remote() {
// The snapshots live in the host's repository.
let note = Err("This thread runs on another device; its diffs open there.".to_string());
if let Some(diff) = self.diff.as_mut() {
diff.set_files(note);
}
return;
}
let (base, head) = (changes.base.clone(), changes.head.clone());
let cwd = session.cwd.clone();
let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace);
let task = cx.background_executor().spawn({
let path = path.clone();
async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) }
});
cx.spawn_in(window, async move |this, cx| {
let files = task.await;
let _ = this.update(cx, |this, cx| {
// Unless the diff was closed, replaced or toggled meanwhile.
if let Some(diff) = this.diff.as_mut()
&& (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace)
{
diff.set_files(files);
cx.notify();
}
});
})
.detach();
}
fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context<Self>) {
let Some(diff) = self.diff.as_mut() else { return };
match action {
DiffAction::Close => self.diff = None,
DiffAction::ToggleWhitespace => {
diff.ignore_whitespace = !diff.ignore_whitespace;
self.load_diff(window, cx);
}
DiffAction::SetSideBySide(side_by_side) => {
diff.side_by_side = side_by_side;
settings::update(|settings| settings.diff_side_by_side = side_by_side);
}
DiffAction::ToggleFile(path) => diff.toggle_file(path),
DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed),
}
cx.notify();
}
/// A callback that closes whichever picker is open.
fn close_picker(cx: &Context<Self>) -> 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<Self>) {
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<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let session = self.session.read(cx);
let cwd = session.project.clone();
let env = session.remote.as_ref().map(|remote| remote.env.clone());
let directory = cx.try_global::<ProjectDirectory>();
let current = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
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, Option<String>)> = directory
.map(|directory| {
directory
.projects
.iter()
.filter(|project| !project.scratch)
.map(|project| {
let name = match &project.host {
Some(host) => format!("{} · {host}", project.name).into(),
None => project.name.clone(),
};
(name, project.path.clone(), project.environment.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<gpui_kit::component::menu::PopupMenu>| {
menu = menu.min_w(px(240.)).label("Project");
for (name, path, project_env) in projects.clone() {
let chat = chat.clone();
let checked = path == cwd && project_env == env;
menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| {
let (path, project_env) = (path.clone(), project_env.clone());
let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path, project_env)));
}));
}
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<Self>) -> impl IntoElement {
let session = self.session.clone();
let scroller = self.scroller.clone();
let chat = cx.entity().downgrade();
div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
// Scrolling rests the composer, when set to.
.on_scroll_wheel(cx.listener(|this, _, _, cx| {
let empty = this.composer.read(cx).value().is_empty() && this.attachments.is_empty();
if !this.collapsed && empty && settings::get(|settings| settings.collapse_composer) {
this.collapsed = true;
cx.notify();
}
}))
.child(
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),
)
}
/// Above the composer: the usage limit that stopped the thread, and when
/// it continues.
fn render_limit(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let p = metal::palette(cx);
let session = self.session.read(cx);
let resets = session.limited_until()?;
let resume_at = session.resume_at;
let session = self.session.clone();
Some(
h_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.px(px(14.))
.py(px(9.))
.gap(px(10.))
.rounded(px(14.))
.region(p)
.child(div().pt(px(1.)).child(metal::led("limit-led", Led::Waiting, false, p)))
.child(
div()
.flex_1()
.min_w_0()
.text_size(px(13.))
.text_color(p.ink2)
.child(match resume_at {
Some(at) => format!("Usage limit reached. The thread continues at {}.", clock::when(at)),
None => format!("Usage limit reached. It resets at {}.", clock::when(resets)),
}),
)
.child(match resume_at {
Some(_) => MetalButton::new("cancel-resume")
.ghost()
.compact()
.label("Cancel")
.tooltip("Don't continue on its own")
.on_click(move |_, _, cx| session.update(cx, |session, cx| session.cancel_resume(cx))),
None => MetalButton::new("schedule-resume")
.compact()
.icon(Lucide::AlarmClock)
.label("Continue then")
.on_click(move |_, _, cx| {
session.update(cx, |session, cx| {
session.schedule_resume(resets + Duration::from_secs(60), cx)
})
}),
})
.into_any_element(),
)
}
/// Messages waiting for the turn to end, each with a way to send it now.
fn render_queue(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let p = metal::palette(cx);
let queue = self.session.read(cx).queue.clone();
if queue.is_empty() {
return None;
}
Some(
v_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.gap(px(4.))
.children(queue.into_iter().enumerate().map(|(ix, message)| {
let steer = self.session.clone();
let remove = self.session.clone();
let text = message.text.lines().next().unwrap_or_default().to_string();
h_flex()
.h(px(32.))
.pl(px(12.))
.pr(px(4.))
.gap(px(8.))
.rounded(px(12.))
.well(p)
.child(Icon::new(Lucide::ListOrdered).size(px(13.)).text_color(p.ink3))
.child(metal::engraved("Queued", p))
.child(div().flex_1().min_w_0().truncate().text_size(px(13.)).text_color(p.ink2).child(text))
.when(!message.attachments.is_empty(), |this| {
this.child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3))
})
.child(
MetalButton::new(("steer-queued", ix))
.ghost()
.mini()
.label("Steer")
.tooltip("Send it now, mid-turn")
.on_click(move |_, _, cx| steer.update(cx, |session, cx| session.steer_queued(ix, cx))),
)
.child(
MetalButton::new(("remove-queued", ix))
.ghost()
.mini()
.icon(IconName::Close)
.tooltip("Remove")
.on_click(move |_, _, cx| remove.update(cx, |session, cx| session.remove_queued(ix, cx))),
)
}))
.into_any_element(),
)
}
/// The line above the composer: what the agent is doing and for how long.
fn render_activity(&self, cx: &mut Context<Self>) -> 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();
let preparing = session.preparing;
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 preparing {
(Led::Live, "Creating worktree")
} 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))
})
}
fn render_attachments(&self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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 catalog = Catalog::global(cx);
let model = catalog.find(&session.instance, 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", catalog.label(&session.instance)).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 collapsed = self.collapsed && !start;
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,
settings::get(|settings| settings.follow_up),
settings::get(|settings| settings.send_with_ctrl_enter),
) {
(true, FollowUp::Queue, false) => "Queue for after this turn (Enter)",
(true, FollowUp::Queue, true) => "Queue for after this turn (Ctrl+Enter)",
(true, FollowUp::Steer, false) => "Steer the running turn (Enter)",
(true, FollowUp::Steer, true) => "Steer the running turn (Ctrl+Enter)",
(false, _, false) => "Send (Enter)",
(false, _, true) => "Send (Ctrl+Enter)",
},
)
.on_click(cx.listener(|this, _, window, cx| this.submit(false, window, cx)))
};
// MetalUI marks focus with a green-deep ring.
let mut card_shadow = p.raise.clone();
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.)));
let textarea = div()
.px(px(12.))
.text_size(px(14.))
.text_color(p.ink)
.child(Textarea::new(&self.composer).appearance(false));
let card = if collapsed {
// Resting: one line, a click (or typing) away from the full composer.
h_flex()
.id("composer-collapsed")
.w_full()
.h(px(48.))
.pr(px(8.))
.rounded(px(24.))
.bg(metal::vertical((p.s_hi, p.s)))
.shadow(card_shadow)
.cursor_text()
.on_click(cx.listener(|this, _, window, cx| {
this.expand(cx);
this.focus_composer(window, cx);
}))
.child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden())
.child(send_button)
.into_any_element()
} else {
v_flex()
.w_full()
.rounded(px(24.))
.bg(metal::vertical((p.s_hi, p.s)))
.shadow(card_shadow)
.when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx)))
.child(textarea.pt(px(10.)))
.child(toolbar.child(send_button))
.into_any_element()
};
let slash_menu = match self.slash_matches(cx) {
Some(matches) => Some(self.render_slash_menu(matches, cx).into_any_element()),
None => None,
};
let above: Vec<AnyElement> = if start {
Vec::new()
} else {
let mut above: Vec<AnyElement> = self.render_limit(cx).into_iter().chain(self.render_queue(cx)).collect();
above.push(self.render_activity(cx).into_any_element());
above
};
v_flex()
.flex_none()
.w_full()
.items_center()
.px(px(24.))
.pb(px(if start { 0. } else { 20. }))
.gap(px(8.))
.children(above)
.child(
v_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.items_center()
.capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
this.composer_key(event, window, cx)
}))
.children(slash_menu)
.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<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let session = self.session.read(cx);
let cwd = session.project.clone();
let env = session.remote.as_ref().map(|remote| remote.env.clone());
let directory = cx.try_global::<ProjectDirectory>();
let project = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
let remote_host = project.and_then(|project| project.host.clone());
let is_remote = remote_host.is_some();
let hostname = remote_host.unwrap_or_else(|| directory.map(|directory| directory.hostname.clone()).unwrap_or_default());
let plan = self.session.read(cx).pending_worktree.clone();
let branch = match &plan {
Some(plan) => plan.base.clone().or_else(|| project.and_then(|project| project.branch.clone())),
None => project.and_then(|project| project.branch.clone()),
};
let branches = project.map(|project| project.branches.clone()).unwrap_or_default();
let can_worktree = project.is_some_and(|project| project.git && !project.scratch);
let mode = if plan.is_some() { WorkspaceMode::Worktree } else { WorkspaceMode::Local };
let chat = cx.entity().downgrade();
let workspace_chat = chat.clone();
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(if is_remote { Lucide::Server } else { Lucide::Laptop }).size(px(13.)))
.child(Icon::new(IconName::Folder).size(px(13.)))
.child(hostname)
.child(metal::tag(if is_remote { "Remote" } else { "Local" }, p)),
)
.child(div().flex_1())
.when(can_worktree, |this| {
this.child(metal::segmented(
"start-workspace",
&[(WorkspaceMode::Local, "Local"), (WorkspaceMode::Worktree, "New worktree")],
mode,
p,
move |mode, _, cx| {
let _ = workspace_chat.update(cx, |_, cx| cx.emit(ChatEvent::SetWorkspace(mode)));
},
))
})
.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<Self>) -> impl IntoElement {
let is_empty = self.session.read(cx).items.is_empty();
let on_diff = {
let chat = cx.entity().downgrade();
move |action: DiffAction, window: &mut Window, cx: &mut App| {
let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx));
}
};
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)))
.map(|this| match &self.diff {
Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_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(),
}
}
/// A turn's end time: the clock today, and the day before that.
fn clock_short(time: SystemTime) -> String {
let age = time.elapsed().unwrap_or_default();
if age < Duration::from_secs(12 * 3_600) { clock::clock(time) } else { clock::when(time) }
}
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<gpui_kit::component::text::TextViewState>, 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<Session>,
scroller: &Entity<MessageScrollerState>,
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)))
.child(div().text_size(px(12.)).child(format!("· {}", clock_short(turn.at))))
.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. && 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<Session>, 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()
}
/// 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<Session>,
scroller: &Entity<MessageScrollerState>,
chat: &WeakEntity<ChatView>,
ix: usize,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
let p = metal::palette(cx);
let state = session.read(cx);
let Some(item) = state.items.get(ix) else {
return div().into_any_element();
};
// A finished turn shows only its answer (and any errors) under one
// "Worked for" row, unless the user unfolds it. Its end shows what it
// changed on disk.
let turn = state.finished_turn(ix);
// With folding off, turns start unfolded and a click folds them.
let fold = settings::get(|settings| settings.fold_turns);
let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold);
let summary = turn
.as_ref()
.filter(|turn| turn.first == ix)
.map(|turn| render_turn_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,
(Some(turn), _) => expanded || Some(ix) == turn.answer,
(None, _) => true,
};
if !visible && summary.is_none() {
return div().into_any_element();
}
let content: Option<AnyElement> = 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(answer) => div()
.w_full()
.px(px(4.))
.text_size(px(14.))
.line_height(px(22.))
.text_color(p.ink)
.child(markdown(&answer.view, 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::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.))
.children(summary)
.children(content),
)
.into_any_element()
}
fn render_tool(
session: &Entity<Session>,
ix: usize,
tool: &ToolCall,
cwd: &std::path::Path,
cx: &App,
) -> AnyElement {
let p = metal::palette(cx);
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),
(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))
})
.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<String> = None;
match tool.name.to_ascii_lowercase().as_str() {
"bash" => {
if let Some(description) = field(&["description"]) {
sections.push(("Description", description));
}
sections.push(("Command", field(&["command"]).unwrap_or_default()));
}
"edit" if old_new.0.is_some() || old_new.1.is_some() => {
sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
sections.push(("Replace", old_new.0.unwrap_or_default()));
sections.push(("With", old_new.1.unwrap_or_default()));
}
"write" => {
sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
sections.push(("Content", field(&["content"]).unwrap_or_default()));
}
"exitplanmode" => plan = field(&["plan"]),
_ => {
let input = if tool.input.is_null() && !tool.partial_input.is_empty() {
tool.partial_input.clone()
} else {
serde_json::to_string_pretty(&tool.input).unwrap_or_default()
};
sections.push(("Input", input));
}
}
if let Some(result) = &tool.result {
sections.push((
if tool.state == ToolState::Error { "Error" } else { "Output" },
truncate(result, MAX_TOOL_OUTPUT),
));
}
v_flex()
.gap(px(10.))
.px(px(12.))
.pb(px(12.))
.pt(px(2.))
.when_some(plan, |this, plan| {
this.child(
div()
.px(px(16.))
.py(px(12.))
.rounded(px(12.))
.well(p)
.text_size(px(14.))
.text_color(p.ink)
.child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)),
)
})
.children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| {
v_flex()
.gap(px(6.))
.child(div().px(px(2.)).child(metal::engraved(label, p)))
.child(
div()
.id(ElementId::NamedInteger(
format!("tool-{ix}-section").into(),
section_ix as u64,
))
.max_h(px(280.))
.overflow_y_scroll()
.px(px(12.))
.py(px(9.))
.rounded(px(12.))
.well(p)
.font_family(metal::MONO)
.text_size(px(11.5))
.line_height(px(17.))
.text_color(p.ink2)
.child(if body.is_empty() { "(empty)".to_string() } else { body }),
)
}))
}
fn render_permission_bar(
session: &Entity<Session>,
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,
_ => 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)
}
}