//! 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. What docks on top of the composer lives in `dock`,
//! its footer in `footer`, and the strip hanging below it in `strip`.
mod dock;
mod footer;
mod strip;
use std::{
path::PathBuf,
time::{Duration, SystemTime},
};
use gpui_kit::component::{
Icon, IconName, h_flex,
input::{self, EditorState, InputEvent},
menu::{DropdownMenu as _, PopupMenuItem},
message_scroller::{MessageScroller, MessageScrollerState},
popover::Popover,
text::{TextView, TextViewStyle},
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, SlashCommand};
use crate::session::{
ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, SessionEvent,
SetupCancel, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
};
use crate::worktree::StageStatus;
use crate::settings::{self, FollowUp, WorkspaceMode};
use crate::ui::changes::{self, DiffAction, DiffView};
use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone};
use crate::ui::icons::Lucide;
use crate::ui::markdown_input::{self, MarkdownInput};
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers::{ModelPicker, ModelPickerEvent};
use crate::ui::workspace::ProjectDirectory;
pub use strip::BranchPicker;
const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands";
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,
/// Check out a branch in the thread's folder, creating it first when
/// `create`.
SwitchBranch { branch: String, create: bool },
/// Run this (still empty) thread in the project folder or a new worktree.
SetWorkspace(WorkspaceMode),
/// Start a new thread in this project that implements this plan.
ImplementInNewThread(String),
/// No agent is on: open Settings → Providers.
OpenProviderSettings,
}
/// 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 `/`, `$` and `@` menus list.
const MENU_ROWS: usize = 40;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Picker {
Model,
Traits,
Access,
Overflow,
Environment,
Workspace,
Branch,
}
/// A notice docked above the composer, T3 Code's banner stack.
pub(crate) enum Notice {
/// Commands, monitors or subagents left running in the background.
BackgroundWork { title: SharedString, detail: Option<SharedString>, waiting: bool, task_ids: Vec<String> },
/// A usage limit stopped the thread.
Limit { resets: SystemTime, resume_at: Option<SystemTime> },
}
impl Notice {
fn tone(&self) -> Tone {
match self {
Notice::BackgroundWork { .. } => Tone::Neutral,
Notice::Limit { .. } => Tone::Warning,
}
}
}
/// A prompt put aside with Ctrl+S.
struct Stashed {
text: String,
attachments: Vec<PathBuf>,
at: SystemTime,
}
impl Stashed {
/// The stash menu's line for it: its start, or what it carries.
fn snippet(&self) -> String {
let text = self.text.split_whitespace().collect::<Vec<_>>().join(" ");
if !text.is_empty() {
let mut snippet: String = text.chars().take(90).collect();
if text.chars().count() > 90 {
snippet.push('…');
}
return snippet;
}
match self.attachments.len() {
0 => "(empty)".into(),
1 => "(1 file)".into(),
n => format!("({n} files)"),
}
}
}
/// What the `/`, `$` or `@` menu offers for the word at the cursor.
struct CommandMenu {
/// `/`, `$` or `@`.
trigger: char,
/// Where the word (with its trigger) starts in the composer text.
start: usize,
items: Vec<MenuItem>,
}
#[derive(Clone)]
enum MenuItem {
Command(SlashCommand),
/// A file or folder of the thread's folder, relative to it.
Path { path: String, folder: bool },
}
pub struct ChatView {
session: Entity<Session>,
composer: Entity<EditorState>,
scroller: Entity<MessageScrollerState>,
model_picker: Entity<ModelPicker>,
branch_picker: Entity<BranchPicker>,
open_picker: Option<Picker>,
attachments: Vec<PathBuf>,
diff: Option<DiffView>,
/// The composer's placeholder, as the thread's state picks it.
placeholder: &'static str,
/// The question form's question the composer holds the answer to.
ask_shown: Option<(usize, usize)>,
/// A question folded away, by form and question.
question_collapsed: Option<(usize, usize)>,
/// The option just picked on a single-select question, shown checked
/// until the form moves on.
optimistic_pick: Option<(usize, usize, usize)>,
ask_timer: Task<()>,
/// The highlighted row of the `/`, `$` or `@` menu.
menu_selected: usize,
/// The composer text the menu was dismissed at, with Esc.
menu_dismissed: Option<String>,
/// The thread folder's files, for the `@` menu, once read.
files: Option<(PathBuf, Vec<String>)>,
files_task: Task<()>,
/// Prompts put aside with Ctrl+S, oldest first.
stash: Vec<Stashed>,
stash_open: bool,
stash_selected: usize,
/// When a prompt was last stashed, for the tab's brief pulse.
stash_pulse: Option<std::time::Instant>,
queue_expanded: bool,
/// The queued message being edited in the composer.
editing_queued: Option<usize>,
notices_expanded: bool,
tasks_expanded: bool,
/// The composer card's width, measured as it's laid out, so the footer
/// knows how much fits.
composer_width: Pixels,
/// Whether the worktree setup card shows its details.
setup_details: bool,
/// Whether the traits and access controls folded into "…" last frame.
folded: std::cell::Cell<(bool, bool)>,
/// 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| {
markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer))
.submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter))
.placeholder(DEFAULT_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 branch_picker = cx.new(|cx| BranchPicker::new(window, cx));
let subscriptions = vec![
cx.observe_in(&session, window, |this, session, window, cx| {
this.sync_with_session(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| 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.menu_selected = 0;
this.stash_open = false;
this.sync_custom_answer(cx);
this.expand(cx);
this.load_files_if_mentioning(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();
}),
cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)),
];
// 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;
}
}
});
let mut this = Self {
session,
composer,
scroller,
model_picker,
branch_picker,
open_picker: None,
attachments: Vec::new(),
diff: None,
placeholder: DEFAULT_PLACEHOLDER,
ask_shown: None,
question_collapsed: None,
optimistic_pick: None,
ask_timer: Task::ready(()),
menu_selected: 0,
menu_dismissed: None,
files: None,
files_task: Task::ready(()),
stash: Vec::new(),
stash_open: false,
stash_selected: 0,
stash_pulse: None,
queue_expanded: true,
editing_queued: None,
notices_expanded: false,
tasks_expanded: false,
composer_width: px(0.),
setup_details: false,
folded: Default::default(),
collapsed: false,
_subscriptions: subscriptions,
_ticker: ticker,
};
this.sync_with_session(window, cx);
this
}
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, window: &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));
markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx);
self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
self.sync_with_session(window, 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));
}
/// Keeps the composer in step with what the thread waits on: its
/// placeholder, whether it takes input, and (while a question is open)
/// the answer typed for the question on screen.
fn sync_with_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let session = self.session.read(cx);
let approval = dock::pending_approval(session).is_some() && dock::pending_plan(session).is_none();
let ask = session.pending_ask().map(|(ix, ask)| (ix, ask.current, ask.custom[ask.current].clone()));
let plan = dock::pending_plan(session).is_some();
let choice_only = dock::pending_extension_question(session).is_some() && ask.is_none();
let no_provider = Catalog::global(cx).enabled().next().is_none();
let placeholder = if approval {
"Resolve this approval request to continue"
} else if choice_only {
"Choose an option above"
} else if ask.is_some() {
"Type your own answer, or leave this blank to use the selected option"
} else if plan {
"Add feedback to refine the plan, or leave this blank to implement it"
} else if no_provider {
"Enable a provider in Settings to send a message"
} else {
DEFAULT_PLACEHOLDER
};
if placeholder != self.placeholder {
self.placeholder = placeholder;
self.composer.update(cx, |composer, cx| {
composer.set_placeholder(placeholder, window, cx);
composer.set_disabled(approval || choice_only, cx);
});
}
// A question shown anew brings its own typed answer into the composer.
let shown = ask.as_ref().map(|(ix, current, _)| (*ix, *current));
if shown != self.ask_shown {
let had_question = self.ask_shown.is_some();
self.ask_shown = shown;
match ask {
Some((_, _, custom)) => self.composer.update(cx, |composer, cx| composer.set_value(custom, window, cx)),
// The form is answered: the answer typed for it goes away.
None if had_question => self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)),
None => {}
}
}
}
/// Hands what the composer holds to the open question as the user's
/// own answer.
fn sync_custom_answer(&mut self, cx: &mut Context<Self>) {
let Some((ix, _)) = self.session.read(cx).pending_ask() else { return };
if self.ask_shown.is_none() {
return;
}
let text = self.composer.read(cx).value().to_string();
self.session.update(cx, |session, cx| session.ask_set_custom(ix, text, 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>) {
if self.command_menu(cx).is_some() {
return;
}
let text = self.composer.read(cx).value().trim().to_string();
let session = self.session.read(cx);
// An open question takes the text as the user's own answer and moves on.
if let Some((ix, _)) = session.pending_ask() {
self.sync_custom_answer(cx);
self.session.update(cx, |session, cx| session.ask_advance(ix, cx));
return;
}
if dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode") {
return;
}
if let Some((ix, _)) = dock::pending_plan(session) {
if text.is_empty() {
self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Allow, cx));
} else {
self.refine_plan(ix, window, 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));
if let Some(ix) = self.editing_queued.take() {
self.session.update(cx, |session, cx| session.update_queued(ix, text, attachments, cx));
return;
}
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.menu_dismissed = None;
self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
}
/// Declines Claude's plan with the composer's text as feedback, so it
/// plans again.
fn refine_plan(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let text = self.composer.read(cx).value().trim().to_string();
if text.is_empty() {
return;
}
self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
self.session.update(cx, |session, cx| {
session.respond_permission(ix, PermissionDecision::Deny, cx);
session.send_message(text, Vec::new(), cx);
});
}
/// Hands Claude's plan to a new thread to implement, and declines it here.
fn implement_in_new_thread(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
let plan = match self.session.read(cx).items.get(ix) {
Some(ChatItem::Tool(tool)) => tool_field(&tool.input, &["plan"]).unwrap_or_default(),
_ => return,
};
self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Deny, cx));
cx.emit(ChatEvent::ImplementInNewThread(plan));
}
/// Loads a queued message into the composer to edit it.
fn edit_queued(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(message) = self.session.read(cx).queue.get(ix) else { return };
let (text, attachments) = (message.text.clone(), message.attachments.clone());
self.editing_queued = Some(ix);
self.attachments = attachments;
self.composer.update(cx, |composer, cx| {
composer.set_value(text, window, cx);
composer.focus(window, cx);
});
cx.notify();
}
fn cancel_queued_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.editing_queued.take().is_some() {
self.attachments.clear();
self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
cx.notify();
}
}
/// T3 Code's composer shortcuts: Ctrl+Shift+M (model), E (effort),
/// A (access), G (branch), X (workspace), L (previous worktree) and
/// H (machine).
fn composer_shortcut(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
let keystroke = &event.keystroke;
let modifiers = &keystroke.modifiers;
if !(modifiers.control && modifiers.shift) || modifiers.alt || event.is_held {
return false;
}
let (traits_folded, access_folded) = self.folded.get();
let strip = self.strip_visible(cx);
let draft = self.session.read(cx).items.is_empty();
let picker = match keystroke.key.as_str() {
"m" => Picker::Model,
"e" if traits_folded => Picker::Overflow,
"e" => Picker::Traits,
"a" if access_folded => Picker::Overflow,
"a" => Picker::Access,
"g" if strip => Picker::Branch,
"x" if strip && draft => Picker::Workspace,
"h" if strip && draft => Picker::Environment,
"l" if draft => {
self.use_previous_worktree(window, cx);
return true;
}
_ => return false,
};
self.expand(cx);
let open = self.open_picker != Some(picker);
self.set_picker(picker, open, window, cx);
if !open {
self.focus_composer(window, cx);
}
true
}
/// Points a new thread at the project's previous worktree (Ctrl+Shift+L).
fn use_previous_worktree(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let session = self.session.read(cx);
let env = session.remote.as_ref().map(|remote| remote.env.clone());
let previous = cx
.try_global::<ProjectDirectory>()
.and_then(|directory| directory.find(&session.project, env.as_deref()))
.and_then(|project| project.previous_worktree.clone());
if let Some(worktree) = previous {
cx.emit(ChatEvent::SetWorkspace(WorkspaceMode::Local));
self.session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx));
self.focus_composer(window, cx);
}
}
/// Puts the composer's prompt aside (Ctrl+S), or with nothing to stash,
/// opens the stash.
fn stash_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let text = self.composer.read(cx).value().to_string();
if self.session.read(cx).pending_ask().is_some() || text.trim().is_empty() && self.attachments.is_empty() {
self.toggle_stash(cx);
return;
}
self.stash.push(Stashed { text, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
self.stash_pulse = Some(std::time::Instant::now());
self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
cx.notify();
}
fn toggle_stash(&mut self, cx: &mut Context<Self>) {
self.stash_open = !self.stash_open;
self.stash_selected = self.stash.len().saturating_sub(1);
cx.notify();
}
/// Puts a stashed prompt back in the composer, stashing what's there.
fn restore_stash(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
if ix >= self.stash.len() {
return;
}
let stashed = self.stash.remove(ix);
let current = self.composer.read(cx).value().to_string();
if !current.trim().is_empty() || !self.attachments.is_empty() {
self.stash.push(Stashed { text: current, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
}
self.attachments = stashed.attachments;
self.composer.update(cx, |composer, cx| {
composer.set_value(stashed.text, window, cx);
composer.focus(window, cx);
});
self.stash_open = false;
cx.notify();
}
fn delete_stash(&mut self, ix: usize, cx: &mut Context<Self>) {
if ix < self.stash.len() {
self.stash.remove(ix);
self.stash_selected = self.stash_selected.min(self.stash.len().saturating_sub(1));
cx.notify();
}
}
/// Keys the open stash takes: arrows, Enter to restore, Ctrl+Backspace
/// to delete, Esc to close.
fn stash_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
if !self.stash_open {
return false;
}
let count = self.stash.len();
let modifiers = &event.keystroke.modifiers;
match event.keystroke.key.as_str() {
// Newest is listed first, so up moves to newer ones.
"up" if count > 0 => self.stash_selected = (self.stash_selected + 1) % count,
"down" if count > 0 => self.stash_selected = (self.stash_selected + count - 1) % count,
"enter" if count > 0 => {
let ix = self.stash_selected.min(count - 1);
self.restore_stash(ix, window, cx);
}
"backspace" if modifiers.control && count > 0 => {
let ix = self.stash_selected.min(count - 1);
self.delete_stash(ix, cx);
}
"escape" => self.stash_open = false,
_ => return false,
}
cx.notify();
true
}
/// 1–9 pick an option of the open question while the composer is empty.
fn pick_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.composer_shortcut(event, window, cx) {
cx.stop_propagation();
return;
}
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::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else {
return;
};
if let Some((ix, question)) = dock::pending_extension_question(self.session.read(cx))
&& self.session.read(cx).pending_ask().is_none()
{
if digit <= question.options.len() {
self.session.update(cx, |session, cx| session.answer_question(ix, digit - 1, cx));
cx.stop_propagation();
}
return;
}
let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return };
if self.question_collapsed == Some((ix, ask.current)) || digit > ask.questions[ask.current].options.len() {
return;
}
self.pick_option(ix, digit - 1, cx);
cx.stop_propagation();
}
fn expand(&mut self, cx: &mut Context<Self>) {
if self.collapsed {
self.collapsed = false;
cx.notify();
}
}
fn interrupt(&mut self, cx: &mut Context<Self>) {
self.session.update(cx, |session, cx| session.interrupt(cx));
}
/// The notices docked above the composer, front first: work running in
/// the background, then a usage limit.
fn notices(&self, cx: &App) -> Vec<Notice> {
let session = self.session.read(cx);
let mut notices = Vec::new();
if !session.background_tasks.is_empty() {
// What each task is, from the tool call that started it.
let kind = |task_id: &str| {
let tool = session.items.iter().find_map(|item| match item {
ChatItem::Tool(tool) if tool.task_id.as_deref() == Some(task_id) => Some(tool.name.as_str()),
_ => None,
});
match tool {
Some("Task" | "Agent") => 0,
Some("Bash") => 1,
Some("Monitor") => 2,
_ => 3,
}
};
const NOUNS: [(&str, &str); 4] =
[("subagent", "subagents"), ("command", "commands"), ("monitor", "monitors"), ("background task", "background tasks")];
let mut tasks: Vec<(usize, &crate::session::BackgroundTask)> =
session.background_tasks.iter().map(|task| (kind(&task.task_id), task)).collect();
tasks.sort_by_key(|(kind, _)| *kind);
// Subagents and monitors wake the agent; a dev server doesn't.
let waiting = tasks.iter().any(|(kind, _)| matches!(kind, 0 | 2));
let title = if let [(kind, task)] = tasks.as_slice() {
let noun = NOUNS[*kind].0;
let named = !task.description.is_empty();
match (waiting, named) {
(true, true) => format!("Waiting on {noun} {}", task.description),
(true, false) => format!("Waiting on a {noun}"),
(false, true) => format!("Running: {}", task.description),
(false, false) => format!("Running a {noun}"),
}
} else {
let mut groups: Vec<String> = Vec::new();
for kind in 0..4 {
let count = tasks.iter().filter(|(task_kind, _)| *task_kind == kind).count();
if count > 0 {
let (one, many) = NOUNS[kind];
groups.push(format!("{count} {}", if count == 1 { one } else { many }));
}
}
let joined = match groups.len() {
0 | 1 => groups.join(""),
n => format!("{} and {}", groups[..n - 1].join(", "), groups[n - 1]),
};
format!("{} {joined}", if waiting { "Waiting on" } else { "Running" })
};
let detail = (tasks.len() > 1).then(|| {
tasks.iter().map(|(_, task)| task.description.to_string()).collect::<Vec<_>>().join(", ").into()
});
notices.push(Notice::BackgroundWork {
title: title.into(),
detail,
waiting,
task_ids: tasks.iter().map(|(_, task)| task.task_id.clone()).collect(),
});
}
if let Some(resets) = session.limited_until() {
notices.push(Notice::Limit { resets, resume_at: session.resume_at });
}
notices
}
// ---- the `/`, `$` and `@` menus ---------------------------------------
/// The menu for the word at the cursor: `/` at the start of a line lists
/// commands (and skills, when set to), `$` lists skills, `@` lists the
/// thread folder's files and folders.
fn command_menu(&self, cx: &App) -> Option<CommandMenu> {
let composer = self.composer.read(cx);
let text = composer.value();
if self.menu_dismissed.as_deref() == Some(text.as_ref()) {
return None;
}
let cursor = composer.cursor().min(text.len());
let before = &text[..cursor];
let start = before.rfind(char::is_whitespace).map_or(0, |at| at + before[at..].chars().next().map_or(1, char::len_utf8));
let word = &before[start..];
let trigger = word.chars().next().filter(|c| matches!(c, '/' | '$' | '@'))?;
// `/` only opens a command at the start of a line.
if trigger == '/' && start > 0 && !before[..start].ends_with('\n') {
return None;
}
let query = word[1..].to_lowercase();
let items: Vec<MenuItem> = match trigger {
'@' => {
let files = self.files.as_ref().map(|(_, files)| files.as_slice()).unwrap_or_default();
let mut matches: Vec<MenuItem> = files
.iter()
.filter(|path| path.to_lowercase().contains(&query))
.take(MENU_ROWS)
.map(|path| MenuItem::Path { folder: path.ends_with('/'), path: path.clone() })
.collect();
matches.sort_by_key(|item| match item {
MenuItem::Path { path, .. } => {
let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path).to_lowercase();
(!name.starts_with(&query), path.len())
}
MenuItem::Command(_) => (true, 0),
});
matches
}
_ => {
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(MENU_ROWS);
prefix.into_iter().map(MenuItem::Command).collect()
}
};
Some(CommandMenu { trigger, start, items })
}
/// Reads the thread folder's files in the background once `@` is typed.
fn load_files_if_mentioning(&mut self, cx: &mut Context<Self>) {
let cwd = self.session.read(cx).cwd.clone();
let text = self.composer.read(cx).value();
if !text.contains('@') || self.files.as_ref().is_some_and(|(dir, _)| *dir == cwd) || self.session.read(cx).is_remote() {
return;
}
self.files = Some((cwd.clone(), Vec::new()));
let task = cx.background_executor().spawn({
let cwd = cwd.clone();
async move { list_files(&cwd) }
});
self.files_task = cx.spawn(async move |this, cx| {
let files = task.await;
let _ = this.update(cx, |this, cx| {
this.files = Some((cwd, files));
cx.notify();
});
});
}
/// Puts the picked item in the composer in place of the word typed.
fn accept_menu_item(&mut self, menu: &CommandMenu, item: &MenuItem, window: &mut Window, cx: &mut Context<Self>) {
let replacement = match item {
MenuItem::Command(command) => format!("{}{} ", if menu.trigger == '$' { '$' } else { '/' }, command.name),
MenuItem::Path { path, .. } => format!("@{path} "),
};
let start = menu.start;
self.composer.update(cx, |composer, cx| {
let cursor = composer.cursor();
composer.set_selected_range(start..cursor, cx);
composer.replace(replacement, window, cx);
composer.focus(window, cx);
});
self.menu_selected = 0;
cx.notify();
}
/// Keys the menus take before the composer sees them, Ctrl+Shift+Enter
/// (the send key's opposite, or steering the first queued message), and
/// Alt+↑ to edit the last queued message.
fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let keystroke = &event.keystroke;
let modifiers = &keystroke.modifiers;
if self.stash_key(event, window, cx) {
cx.stop_propagation();
return;
}
if keystroke.key == "s" && modifiers.control && !modifiers.shift && !modifiers.alt {
cx.stop_propagation();
self.stash_prompt(window, cx);
return;
}
if keystroke.key == "enter" && modifiers.control && modifiers.shift {
cx.stop_propagation();
let queued = !self.session.read(cx).queue.is_empty();
if queued && self.composer.read(cx).value().trim().is_empty() {
self.session.update(cx, |session, cx| session.steer_queued(0, cx));
} else {
self.submit(true, window, cx);
}
return;
}
if keystroke.key == "up" && modifiers.alt && !modifiers.control {
let count = self.session.read(cx).queue.len();
if count > 0 {
cx.stop_propagation();
self.edit_queued(count - 1, window, cx);
}
return;
}
if keystroke.key == "tab" && modifiers.shift && settings::get(|settings| settings.plan_mode) {
cx.stop_propagation();
self.session.update(cx, |session, cx| session.toggle_plan(cx));
return;
}
let Some(menu) = self.command_menu(cx) else { return };
if modifiers.control || modifiers.alt || modifiers.shift {
return;
}
let count = menu.items.len();
match keystroke.key.as_str() {
"up" if count > 0 => self.menu_selected = (self.menu_selected + count - 1) % count,
"down" if count > 0 => self.menu_selected = (self.menu_selected + 1) % count,
"enter" | "tab" if count > 0 => {
let item = menu.items[self.menu_selected.min(count - 1)].clone();
cx.stop_propagation();
self.accept_menu_item(&menu, &item, window, cx);
return;
}
"escape" => self.menu_dismissed = Some(self.composer.read(cx).value().to_string()),
_ => return,
}
cx.stop_propagation();
cx.notify();
}
/// The menu, docked on top of the composer like T3 Code's.
fn render_command_menu(&self, menu: CommandMenu, cx: &mut Context<Self>) -> AnyElement {
let p = metal::palette(cx);
let selected = self.menu_selected.min(menu.items.len().saturating_sub(1));
let loading = menu.trigger == '@' && self.files.as_ref().is_some_and(|(_, files)| files.is_empty());
let empty = match menu.trigger {
'$' => "No skills found. Try / to browse provider commands.",
'@' if loading => "Searching workspace files...",
'@' => "No matching files or folders.",
_ => "No matching command.",
};
let trigger = menu.trigger;
let rows: Vec<AnyElement> = menu
.items
.iter()
.enumerate()
.map(|(ix, item)| {
let pick = item.clone();
let start = menu.start;
let row = h_flex()
.id(("command-menu-item", ix))
.min_h(px(28.))
.gap(px(8.))
.px(px(8.))
.py(px(6.))
.rounded(px(6.))
.cursor_pointer()
.text_size(px(13.5))
.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| {
let menu = CommandMenu { trigger, start, items: Vec::new() };
this.accept_menu_item(&menu, &pick, window, cx)
}));
match item {
MenuItem::Command(command) => row
.child(
div()
.flex_none()
.max_w(relative(0.45))
.truncate()
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.when(command.skill && trigger == '/', |this| {
this.child(div().text_color(p.ink3).child("/skill:")).child(command.name.clone())
})
.when(!(command.skill && trigger == '/'), |this| {
this.child(format!("{}{}", if trigger == '$' { '$' } else { '/' }, command.name))
}),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.text_color(p.ink3)
.child(if command.description.is_empty() {
command.argument_hint.clone()
} else {
command.description.clone()
}),
)
.when(command.skill, |this| {
this.child(metal::tag(if trigger == '$' { "Personal Skill" } else { "Personal" }, p))
})
.into_any_element(),
MenuItem::Path { path, folder } => {
let trimmed = path.trim_end_matches('/');
let (dir, name) = match trimmed.rsplit_once('/') {
Some((dir, name)) => (dir.to_string(), name.to_string()),
None => (String::new(), trimmed.to_string()),
};
row.child(
Icon::new(if *folder { IconName::Folder } else { IconName::File }).size(px(16.)).text_color(p.ink3),
)
.child(
div()
.flex_none()
.max_w(relative(0.45))
.truncate()
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(name),
)
.child(div().flex_1().min_w_0().truncate().text_size(px(12.)).text_color(p.ink3).child(dir))
.into_any_element()
}
}
})
.collect();
let empty_rows = rows.is_empty();
composer::banner(Tone::Neutral, Density::Default, false, p)
.child(
v_flex()
.id("command-menu")
.max_h(px(288.))
.overflow_y_scroll()
.p(px(4.))
.children(rows)
.when(empty_rows, |this| {
this.child(div().px(px(14.)).pt(px(20.)).pb(px(12.)).text_size(px(12.)).text_color(p.ink3).child(empty))
}),
)
.into_any_element()
}
fn escape(&mut self, cx: &mut Context<Self>) {
if self.stash_open {
self.stash_open = false;
cx.notify();
return;
}
if self.command_menu(cx).is_some() {
self.menu_dismissed = Some(self.composer.read(cx).value().to_string());
cx.notify();
return;
}
if self.open_picker.take().is_some() {
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);
match picker {
Picker::Model => self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)),
Picker::Branch => self.open_branch_picker(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();
// T3 Code's project menu: No project, the projects, Add project.
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.)).max_h(px(320.));
let no_project = chat.clone();
menu = menu.item(
PopupMenuItem::new("No project")
.icon(Icon::new(Lucide::MessageSquareDashed))
.checked(scratch)
.on_click(move |_, _, cx| {
let _ = no_project.update(cx, |_, cx| cx.emit(ChatEvent::StartWithoutProject));
}),
);
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").icon(Icon::new(Lucide::FolderPlus)).on_click(
move |_, _, cx| {
let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::AddProject));
},
))
};
let picker = |id: &'static str, label: SharedString| {
MetalButton::new(id)
.ghost()
.label(label)
.px(px(2.))
.rounded(px(0.))
.border_b(px(1.))
.border_dashed()
.border_color(p.ink3.opacity(0.6))
};
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(30.))
.line_height(px(36.))
.text_color(p.ink)
.map(|this| {
if scratch {
this.child("What should we work on?")
} else {
this.child("What should we build in\u{a0}")
.child(
picker("start-project", project)
.h(px(36.))
.max_w(px(256.))
.text_size(px(30.))
.font_weight(FontWeight::NORMAL)
.text_color(p.ink)
.dropdown_menu(project_menu.clone()),
)
.child("?")
}
}),
)
.child(div().mt(px(8.)).h(px(24.)).flex().items_center().text_size(px(14.)).map(|this| {
if scratch {
this.child(
picker("pick-project", "No project".into())
.h(px(24.))
.text_size(px(14.))
.text_color(p.ink3)
.dropdown_menu(project_menu),
)
} else {
this.child(
div()
.id("start-without-project")
.text_color(p.ink3)
.cursor_pointer()
.hover(|style| style.text_color(p.ink).underline())
.tooltip(|window, cx| {
gpui_kit::component::tooltip::Tooltip::new("Ctrl+Alt+N").build(window, cx)
})
.on_click(cx.listener(|_, _, _, cx| cx.emit(ChatEvent::StartWithoutProject)))
.child("or start without a project"),
)
}
}))
.child(div().h(px(24.)))
.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),
)
}
/// The line closing the conversation while the agent works, as T3 Code
/// ends its timeline: "Working for 12s", or "Setting up worktree…"; and
/// once it's done, whether the thread is snoozed or settled.
fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let p = metal::palette(cx);
let session = self.session.read(cx);
if session.worktree_setup.is_some() {
return Some(self.render_worktree_setup(cx));
}
if session.is_working() {
if session.needs_attention() {
return None;
}
let label = if session.preparing {
"Setting up worktree…".to_string()
} else {
let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0);
format!("Working for {}", format_elapsed(elapsed))
};
return Some(
h_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.px(px(16.))
.child(
h_flex()
.w_full()
.h(px(24.))
.px(px(4.))
.gap(px(8.))
.mb(px(8.))
.text_size(px(14.))
.text_color(p.ink3)
.child(metal::led("working-led", Led::Live, true, p))
.child(label),
)
.into_any_element(),
);
}
let snoozed = session.snoozed_until.filter(|_| session.is_snoozed());
let settled = session.settled;
if snoozed.is_none() && !settled {
return None;
}
let action_session = self.session.clone();
let (icon, label, action): (Icon, String, &'static str) = match snoozed {
Some(until) => (Icon::new(Lucide::AlarmClock), format!("Snoozed, {}", clock::when(until)), "Wake now"),
None => (Icon::new(IconName::CircleCheck), "Settled".to_string(), "Un-settle"),
};
Some(
h_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.px(px(16.))
.pb(px(8.))
.gap(px(6.))
.text_size(px(12.))
.text_color(p.ink3)
.child(icon.size(px(14.)))
.child(div().truncate().child(label))
.child("·")
.child(
div()
.id("status-line-action")
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.hover(|style| style.text_color(p.ink).underline())
.on_click(move |_, _, cx| {
action_session.update(cx, |session, cx| {
if snoozed.is_some() {
session.snooze(None, cx)
} else {
session.unsettle(cx)
}
})
})
.child(action),
)
.into_any_element(),
)
}
/// T3 Code's worktree setup card: the stages of making the thread's
/// worktree as they run, with Details, Work locally and Cancel; or what
/// went wrong once it failed or was cancelled.
fn render_worktree_setup(&self, cx: &mut Context<Self>) -> AnyElement {
let p = metal::palette(cx);
let session = self.session.read(cx);
let Some(setup) = session.worktree_setup.as_ref() else { return div().into_any_element() };
let running = session.preparing;
let header = if running {
"Setting up worktree…"
} else if setup.cancelled || setup.cancel.is_some() {
"Worktree setup cancelled"
} else if setup.error.is_some() {
"Worktree setup failed"
} else {
"Worktree ready"
};
let header_color = if setup.error.is_some() && !running { p.red } else { p.ink3 };
let stage_rows = setup.stages.iter().map(|(stage, status, started, ended)| {
let (icon, color): (AnyElement, Hsla) = match status {
StageStatus::Done => (Icon::new(IconName::Check).size(px(14.)).into_any_element(), p.ink3),
StageStatus::Running => (metal::led(("setup-led", *stage as usize), Led::Live, true, p), p.ink2),
StageStatus::Failed => (Icon::new(IconName::Close).size(px(14.)).into_any_element(), p.red),
StageStatus::Skipped => (Icon::new(IconName::Minus).size(px(14.)).into_any_element(), p.ink3),
StageStatus::Pending => (Icon::new(Lucide::Circle).size(px(14.)).into_any_element(), p.ink3.opacity(0.4)),
};
let elapsed = started.map(|started| ended.unwrap_or_else(std::time::Instant::now).duration_since(started).as_secs());
let trailing = match status {
StageStatus::Skipped => Some("skipped".to_string()),
StageStatus::Pending => None,
_ => elapsed.map(format_elapsed),
};
h_flex()
.h(px(24.))
.gap(px(10.))
.text_size(px(13.))
.text_color(color)
.child(div().w(px(16.)).flex().justify_center().child(icon))
.child(div().flex_1().min_w_0().truncate().child(stage.label()))
.when_some(trailing, |this, trailing| this.child(div().text_size(px(12.)).text_color(p.ink3).child(trailing)))
});
let details = self.setup_details.then(|| {
let row = |label: &'static str, value: String| {
h_flex()
.gap(px(12.))
.child(div().w(px(48.)).text_color(p.ink2).child(label))
.child(div().flex_1().min_w_0().truncate().font_family(metal::MONO).child(value))
};
v_flex()
.ml(px(26.))
.mt(px(4.))
.gap(px(2.))
.text_size(px(12.))
.text_color(p.ink3)
.when_some(setup.branch.clone(), |this, branch| this.child(row("Branch", branch)))
.child(row("Base", setup.base.clone()))
.when_some(setup.path.clone(), |this, path| this.child(row("Path", display_path(&path))))
});
let (cancel, local) = (self.session.clone(), self.session.clone());
v_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.px(px(16.))
.pb(px(8.))
.child(
h_flex()
.h(px(24.))
.px(px(4.))
.gap(px(8.))
.mb(px(4.))
.text_size(px(14.))
.text_color(header_color)
.when(running, |this| this.child(metal::led("setup-header-led", Led::Live, true, p)))
.child(header)
.child(div().flex_1())
.child(div().text_size(px(12.)).text_color(p.ink3).child(format_elapsed(setup.started.elapsed().as_secs()))),
)
.child(v_flex().px(px(4.)).children(stage_rows))
.when_some(setup.error.clone().filter(|_| !running), |this, error| {
this.child(div().ml(px(30.)).mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(error))
})
.children(details)
.child(
h_flex()
.ml(px(26.))
.mt(px(6.))
.gap(px(4.))
.child(
MetalButton::new("setup-details")
.ghost()
.mini()
.label("Details")
.on_click(cx.listener(|this, _, _, cx| {
this.setup_details = !this.setup_details;
cx.notify();
})),
)
.when(running, |this| {
this.child(
MetalButton::new("setup-local")
.ghost()
.mini()
.icon(Lucide::Laptop)
.label("Work locally")
.on_click(move |_, _, cx| {
local.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Local, cx))
}),
)
.child(
MetalButton::new("setup-cancel")
.ghost()
.mini()
.label("Cancel")
.on_click(move |_, _, cx| {
cancel.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Cancel, cx))
}),
)
}),
)
.into_any_element()
}
/// A composer picker: a trigger 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(SharedString::from(format!("{picker:?}-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))
}
/// A picker whose content reads the conversation as it opens.
fn picker_anchored(
&self,
picker: Picker,
trigger: MetalButton,
anchor: Anchor,
cx: &mut Context<Self>,
content: impl Fn(&mut Self, &mut Context<Self>) -> AnyElement + 'static,
) -> impl IntoElement {
let chat = cx.entity().downgrade();
let content = std::rc::Rc::new(content);
Popover::new(SharedString::from(format!("{picker:?}-popover")))
.anchor(anchor)
.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 |_, _, cx| {
let content = content.clone();
chat.update(cx, |this, cx| content(this, cx)).unwrap_or_else(|_| div().into_any_element())
})
}
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 approval = dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode");
let asking = session.pending_ask().is_some();
let focused = self.composer.read(cx).focus_handle(cx).is_focused(window);
let dock = self.render_dock(cx);
let collapsed = self.collapsed && !start && dock.is_empty();
let strip = self.strip_visible(cx).then(|| self.render_strip(cx));
// MetalUI marks focus with a green-deep ring.
let mut card_shadow = p.raise.clone();
if focused && !approval {
card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.)));
}
let measure = {
let chat = cx.entity().downgrade();
let known = self.composer_width;
canvas(
move |bounds, _, cx| {
if (bounds.size.width - known).abs() > px(0.5) {
let _ = chat.update(cx, |this, cx| {
this.composer_width = bounds.size.width;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.size_full()
};
let textarea = div()
.text_size(px(14.))
.text_color(p.ink)
.child(MarkdownInput::new(&self.composer).rows(if approval { 2 } else { 3 }, 9));
let card = if collapsed {
// Resting: one line, a click (or typing) away from the full composer.
h_flex()
.id("composer-collapsed")
.relative()
.w_full()
.h(px(48.))
.pl(px(16.))
.pr(px(8.))
.gap(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(measure)
.child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden())
.child(self.render_actions(start, cx))
.into_any_element()
} else {
let right_width = self.actions_width(cx);
v_flex()
.relative()
.w_full()
.p(px(1.))
.rounded(px(24.))
.bg(metal::vertical((p.s_hi, p.s)))
.shadow(card_shadow)
.when(approval, |this| this.opacity(0.92))
.child(measure)
.child(
v_flex()
.px(px(16.))
.pt(px(16.))
.pb(px(if approval { 16. } else { 8. }))
.when(!self.attachments.is_empty() && !asking, |this| this.child(self.render_attachments(cx)))
.child(textarea),
)
.when(!approval, |this| {
this.child(
h_flex()
.w_full()
.justify_between()
.px(px(16.))
.pb(px(16.))
.when(asking, |this| this.pt(px(8.)))
.child(self.render_controls(right_width, cx))
.child(self.render_actions(start, cx)),
)
})
.into_any_element()
};
v_flex()
.flex_none()
.w_full()
.items_center()
.px(px(24.))
.pb(px(if start { 0. } else { 20. }))
.when(!start, |this| this.children(self.render_status_line(cx)))
.child(
v_flex()
.w_full()
.max_w(px(CONTENT_WIDTH))
.capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
this.composer_key(event, window, cx)
}))
.children(dock)
// The strip is laid out below the card but painted first,
// so the card covers its top edge.
.child(v_flex().w_full().flex_col_reverse().children(strip).child(card)),
)
}
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();
})),
)
}))
}
}
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)))
.capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.pick_with_key(event, window, 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 theme.
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(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))
})
.child(match &question.answer {
Some(answer) => metal::engraved(format!("Answered: {answer}"), p),
// Answered in the drawer above the composer.
None => metal::engraved("Waiting for your answer", p).text_color(p.amber_ink),
})
.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<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> {
let session = session.clone();
match (tool.background, &tool.task_id) {
(Some(TaskStatus::Running), Some(task_id)) => {
let task_id = task_id.clone();
Some(
h_flex()
.gap(px(6.))
.child(metal::tag("Background", p))
.child(
MetalButton::new(("tool-stop-task", ix))
.ghost()
.mini()
.icon(IconName::Square)
.tooltip("Stop this task")
.on_click(move |_, _, cx| {
cx.stop_propagation();
session.update(cx, |session, cx| session.stop_task(&task_id, cx));
}),
)
.into_any_element(),
)
}
(Some(status), _) => Some(
metal::engraved(
match status {
TaskStatus::Running | TaskStatus::Completed => "Ran in background",
TaskStatus::Failed => "Failed in background",
TaskStatus::Stopped => "Stopped",
},
p,
)
.into_any_element(),
),
// A long foreground command can be sent to the background.
(None, Some(_)) if tool.state == ToolState::Running => Some(
MetalButton::new(("tool-background", ix))
.ghost()
.mini()
.label("Background")
.tooltip("Keep it running in the background; Claude carries on and hears when it finishes")
.on_click(move |_, _, cx| {
cx.stop_propagation();
session.update(cx, |session, cx| session.background_tool(ix, cx));
})
.into_any_element(),
),
_ => None,
}
}
/// 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<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,
(_, 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<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(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<Session>,
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;
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 => Some(("Awaiting approval", p.amber_ink)),
});
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)))
.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 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)
}
}
/// The files and folders of `cwd` for the `@` menu: what git tracks (or
/// would, untracked files included), else a shallow walk. Folders end in `/`.
fn list_files(cwd: &std::path::Path) -> Vec<String> {
const MAX_FILES: usize = 20_000;
let mut files: Vec<String> = match git::git(cwd, &["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) {
Ok(out) => out.split('\0').filter(|file| !file.is_empty()).take(MAX_FILES).map(str::to_string).collect(),
Err(_) => {
let mut found = Vec::new();
let mut stack = vec![(cwd.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || found.len() >= MAX_FILES {
continue;
}
if path.is_dir() {
if depth < 4 && name != "node_modules" && name != "target" {
stack.push((path, depth + 1));
}
} else if let Ok(relative) = path.strip_prefix(cwd) {
found.push(relative.to_string_lossy().to_string());
}
}
}
found
}
};
let mut folders: Vec<String> = files
.iter()
.flat_map(|file| {
let parts: Vec<&str> = file.split('/').collect();
(1..parts.len()).map(move |n| format!("{}/", parts[..n].join("/")))
})
.collect();
folders.sort();
folders.dedup();
files.sort();
folders.extend(files);
folders
}
/// `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads.
fn format_elapsed(seconds: u64) -> String {
match seconds {
0..60 => format!("{seconds}s"),
60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
_ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
}
}