//! What docks on top of the composer, like T3 Code's banner stack: queued //! messages, notices (background work, a usage limit), and the drawer that //! takes over while the agent waits on you (an approval, a question, a plan). use std::time::Duration; use gpui_kit::component::{Icon, IconName, h_flex, menu::DropdownMenu as _, tooltip::Tooltip, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use serde_json::Value; use super::{ChatView, Notice}; use crate::clock; use crate::session::{ChatItem, PermissionDecision, PermissionState, Question, Session, ToolCall, UserQuestions, tool_field}; use crate::ui::composer::{self, Density, Tone}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, Palette}; /// A step of the agent's task list (its latest TodoWrite). pub struct TaskStep { pub text: SharedString, pub status: StepStatus, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum StepStatus { Pending, InProgress, Completed, } /// The agent's task list, from the thread's latest TodoWrite call. pub fn task_steps(session: &Session) -> Vec { let todos = session.items.iter().rev().find_map(|item| match item { ChatItem::Tool(tool) if tool.name == "TodoWrite" => tool.input.get("todos").and_then(Value::as_array).cloned(), _ => None, }); todos .unwrap_or_default() .iter() .filter_map(|todo| { let status = match todo.get("status").and_then(Value::as_str).unwrap_or_default() { "completed" => StepStatus::Completed, "in_progress" => StepStatus::InProgress, _ => StepStatus::Pending, }; // The form the agent shows while it works on a step reads better. let text = match status { StepStatus::InProgress => todo.get("activeForm").or_else(|| todo.get("content")), _ => todo.get("content"), }; Some(TaskStep { text: text?.as_str()?.to_string().into(), status }) }) .collect() } /// The first tool call waiting on an approval (not a question form), with /// how many wait in all. pub fn pending_approval(session: &Session) -> Option<(usize, &ToolCall, usize)> { let mut pending = session.items.iter().enumerate().filter_map(|(ix, item)| match item { ChatItem::Tool(tool) if tool.ask.is_none() && tool.permission.as_ref().is_some_and(|prompt| prompt.state == PermissionState::Pending) => { Some((ix, tool)) } _ => None, }); let (ix, tool) = pending.next()?; Some((ix, tool, 1 + pending.count())) } /// A question from a Pi extension waiting for an answer. pub fn pending_extension_question(session: &Session) -> Option<(usize, &Question)> { session.items.iter().enumerate().find_map(|(ix, item)| match item { ChatItem::Question(question) if question.answer.is_none() => Some((ix, question)), _ => None, }) } /// The plan Claude asks to start on, if ExitPlanMode waits for an answer. pub fn pending_plan(session: &Session) -> Option<(usize, &ToolCall)> { pending_approval(session).filter(|(_, tool, _)| tool.name == "ExitPlanMode").map(|(ix, tool, _)| (ix, tool)) } /// A plan's title: its first heading, or its first line. pub fn plan_title(tool: &ToolCall) -> String { let plan = tool.input.get("plan").and_then(Value::as_str).unwrap_or_default(); let line = plan .lines() .map(str::trim) .find(|line| line.starts_with('#')) .or_else(|| plan.lines().map(str::trim).find(|line| !line.is_empty())) .unwrap_or("Proposed plan"); line.trim_start_matches('#').trim().to_string() } /// What kind of approval a tool call asks for, as T3 Code labels it. fn approval_label(tool: &ToolCall) -> &'static str { match tool.name.as_str() { "Bash" | "BashOutput" | "KillShell" => "Command approval", "Read" | "Glob" | "Grep" | "LS" | "NotebookRead" => "File read approval", "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => "File change approval", name if name.starts_with("mcp__") => "App permission approval", _ => "App access approval", } } /// The detail an approval shows: the command, the path, or the input. fn approval_detail(tool: &ToolCall) -> String { if let Some(command) = tool_field(&tool.input, &["command"]) { return command; } if let Some(path) = tool_field(&tool.input, &["file_path", "notebook_path", "path", "pattern", "url"]) { return path; } match &tool.input { Value::Object(map) if map.is_empty() => tool.name.clone(), input => serde_json::to_string_pretty(input).unwrap_or_else(|_| tool.name.clone()), } } impl ChatView { /// The docked surfaces, top to bottom. Each tucks under the next, the /// last under the composer. pub(super) fn render_dock(&self, cx: &mut Context) -> Vec { // The `/`, `$` and `@` menus, and the stash, cover everything else // while open. if let Some(menu) = self.command_menu(cx) { return vec![div().mx(px(composer::INSET)).child(self.render_command_menu(menu, cx)).into_any_element()]; } if self.stash_open { return vec![div().mx(px(composer::INSET)).child(self.render_stash_menu(cx)).into_any_element()]; } let session = self.session.read(cx); let approval = pending_approval(session).map(|(ix, _, count)| (ix, count)); let plan = pending_plan(session).map(|(ix, tool)| (ix, plan_title(tool))); let ask = session.pending_ask().map(|(ix, _)| ix); let extension = pending_extension_question(session).map(|(ix, _)| ix); let steps = task_steps(session); let blocking = approval.is_some() || ask.is_some() || extension.is_some(); let show_tasks = !blocking && !steps.is_empty() && (session.is_working() || steps.iter().any(|step| step.status != StepStatus::Completed)); let queued = !session.queue.is_empty(); let notices = self.notices(cx); let mut items: Vec = Vec::new(); if queued { items.push(self.render_queue(!items.is_empty(), cx)); } if !notices.is_empty() { items.push(self.render_notices(notices, !items.is_empty(), cx)); } let p = metal::palette(cx); match (plan, approval, ask) { (Some((ix, title)), _, _) => items.push(render_plan_ready(ix, title, !items.is_empty(), p)), (None, Some((ix, count)), _) => items.push(render_approval(&self.session, ix, count, !items.is_empty(), cx)), (None, None, Some(ix)) => items.push(self.render_question(ix, !items.is_empty(), cx)), (None, None, None) => { if let Some(ix) = extension { items.push(render_extension_question(&self.session, ix, !items.is_empty(), cx)); } } } if show_tasks { items.push(self.render_tasks(steps, !items.is_empty(), cx)); } // The stash sits beside the others as a tab of its own. let stash = (!self.stash.is_empty() && approval.is_none()).then(|| self.render_stash_badge(cx)); if items.is_empty() && stash.is_none() { return Vec::new(); } vec![ h_flex() .mx(px(composer::INSET)) .items_end() .gap(px(4.)) .child(v_flex().flex_1().min_w_0().children(items)) .children(stash) .into_any_element(), ] } /// The tab that opens the stash: "Stash" and how many prompts it holds. fn render_stash_badge(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let pulsing = self.stash_pulse.is_some_and(|at| at.elapsed() < Duration::from_millis(1200)); composer::banner(Tone::Neutral, Density::Comfortable, false, p) .flex_none() .child( composer::row() .id("stash-badge") .pr(px(4.)) .cursor_pointer() .rounded(px(6.)) .text_color(if pulsing { p.ink } else { p.ink3 }) .hover(|style| style.text_color(p.ink)) .tooltip({ let count = self.stash.len(); move |window, cx| Tooltip::new(format!("Stashed prompts: {count}. Open stash.")).build(window, cx) }) .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx))) .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p)) .child("Stash") .child(composer::count(self.stash.len().to_string(), p).when(pulsing, |this| this.text_color(p.green_ink))), ) .into_any_element() } /// The stashed prompts: each restores into the composer, or goes away. fn render_stash_menu(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let selected = self.stash_selected.min(self.stash.len().saturating_sub(1)); let header = composer::row() .id("stash-header") .cursor_pointer() .rounded(px(6.)) .tooltip(|window, cx| Tooltip::new("Close stash").build(window, cx)) .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx))) .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p)) .child(composer::content().child(div().text_color(p.ink3).child("Stash"))) .child(composer::count(self.stash.len().to_string(), p)) .child(composer::toggle_icon(true, p)); let rows: Vec = self .stash .iter() .enumerate() .rev() .map(|(ix, stashed)| { composer::row() .id(("stashed", ix)) .rounded(px(4.)) .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.restore_stash(ix, window, cx))) .child(composer::icon_cell(Some(Icon::new(IconName::FileText).size(px(12.))), p)) .child(composer::content().text_color(p.ink2).child(div().flex_1().min_w_0().truncate().child(stashed.snippet()))) .when(!stashed.attachments.is_empty(), |this| { this.child( h_flex() .gap(px(2.)) .text_color(p.ink3) .child(Icon::new(IconName::File).size(px(12.))) .child(stashed.attachments.len().to_string()), ) }) .child(div().text_color(p.ink3).child(clock::when(stashed.at))) .child( composer::icon_button(("stashed-delete", ix), IconName::Close, "Delete stashed prompt") .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.delete_stash(ix, cx); })), ) .into_any_element() }) .collect(); let empty = rows.is_empty(); composer::banner(Tone::Neutral, Density::Default, false, p) .child(header) .child(v_flex().id("stash-list").max_h(px(384.)).overflow_y_scroll().children(rows)) .when(empty, |this| { this.child( div() .px(px(28.)) .py(px(8.)) .text_color(p.ink3) .child("Nothing stashed yet. Press Ctrl+S with a prompt in the composer to stash it."), ) }) .into_any_element() } /// Messages waiting for the turn to end: each can be dragged into /// another place, edited in the composer, sent now as a steer, or /// removed. fn render_queue(&self, joined: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let session = self.session.clone(); let state = session.read(cx); let queue_len = state.queue.len(); let expanded = self.queue_expanded; let editing = self.editing_queued; let remote = state.is_remote(); let working = state.is_working(); let header = composer::row() .id("queue-header") .cursor_pointer() .rounded(px(6.)) .on_click(cx.listener(|this, _, _, cx| { this.queue_expanded = !this.queue_expanded; cx.notify(); })) .child(composer::icon_cell(Some(Icon::new(Lucide::ListOrdered).size(px(12.))), p)) .child(composer::content().child(div().text_color(p.ink3).child("Queued"))) .child(composer::count(queue_len.to_string(), p)) .child(composer::toggle_icon(expanded, p)); let rows: Vec = state .queue .iter() .enumerate() .map(|(ix, message)| { let text = message.text.lines().next().unwrap_or_default().to_string(); let full: SharedString = message.text.clone().into(); let is_editing = editing == Some(ix); let last = ix + 1 == queue_len; let (steer, remove, drop) = (session.clone(), session.clone(), session.clone()); composer::row() .id(("queued", ix)) .rounded(px(4.)) .when(is_editing, |this| this.bg(p.menu_row_hover)) .drag_over::(move |style, _, _, _| style.border_t_2().border_color(p.green_deep.opacity(0.7))) .on_drop(move |drag: &QueuedDrag, _, cx| drop.update(cx, |session, cx| session.move_queued(drag.0, ix, cx))) .child(composer::icon_cell( (!remote).then(|| { div() .id(("queued-grip", ix)) .cursor_grab() .tooltip(|window, cx| Tooltip::new("Reorder queued message (drag)").build(window, cx)) .on_drag(QueuedDrag(ix), move |_, _, _, cx| { let text: SharedString = text_preview(&full).into(); cx.new(|_| QueuedGhost(text)) }) .child(Icon::new(Lucide::GripVertical).size(px(12.))) }), p, )) .child( composer::content() .id(("queued-text", ix)) .text_color(p.ink2) .when(!message.attachments.is_empty(), |this| { this.child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3)) }) .child(div().flex_1().min_w_0().truncate().child(text)) .tooltip({ let full: SharedString = message.text.clone().into(); move |window, cx| Tooltip::new(full.clone()).build(window, cx) }), ) .map(|row| { if is_editing { row.child( MetalButton::new(("queued-cancel", ix)) .ghost() .mini() .h(px(24.)) .label("Cancel") .on_click(cx.listener(|this, _, window, cx| this.cancel_queued_edit(window, cx))), ) } else { row.when(!remote, |row| { row.child( composer::icon_button( ("queued-edit", ix), Lucide::Pencil, if last { "Edit in the composer (Alt+↑)" } else { "Edit in the composer" }, ) .on_click(cx.listener(move |this, _, window, cx| this.edit_queued(ix, window, cx))), ) }) .child( MetalButton::new(("queued-steer", ix)) .ghost() .mini() .h(px(24.)) .icon(Lucide::CornerUpRight) .label("Steer") .disabled(!working) .tooltip(if ix == 0 { "Send as a steer instead (Ctrl+Shift+Enter)" } else { "Send as a steer instead" }) .on_click(move |_, _, cx| steer.update(cx, |session, cx| session.steer_queued(ix, cx))), ) .child( composer::icon_button(("queued-remove", ix), IconName::Close, "Remove from queue") .on_click(move |_, _, cx| remove.update(cx, |session, cx| session.remove_queued(ix, cx))), ) } }) .into_any_element() }) .collect(); composer::banner(Tone::Neutral, Density::Default, joined, p) .child(header) .when(expanded, |this| { this.child(v_flex().id("queue-list").max_h(px(128.)).overflow_y_scroll().gap(px(1.)).children(rows)) }) .into_any_element() } /// The notices above the composer: the front one docked, the others /// behind a peek tab that unfolds them. fn render_notices(&self, notices: Vec, joined: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let mut notices = notices.into_iter(); let Some(front) = notices.next() else { return div().into_any_element() }; let rest: Vec = notices.collect(); let expanded = self.notices_expanded && !rest.is_empty(); let chat = cx.entity().downgrade(); let peek = (!rest.is_empty()).then(|| { let chat = chat.clone(); div() .id("notice-peek") .mx(px(8.)) .h(px(12.)) .rounded_t(px(16.)) .bg(metal::vertical((p.s_hi, p.s))) .shadow(p.raise_lite.clone()) .cursor_pointer() .mb(px(-4.)) .tooltip(|window, cx| Tooltip::new("Show other notices").build(window, cx)) .on_click(move |_, _, cx| { let _ = chat.update(cx, |this, cx| { this.notices_expanded = !this.notices_expanded; cx.notify(); }); }) }); let stacked = expanded.then(|| { v_flex().gap(px(8.)).pb(px(8.)).children(rest.iter().enumerate().map(|(ix, notice)| { render_notice(&self.session, ("notice-stacked", ix), notice, p) .rounded(px(16.)) .px(px(4.)) .py(px(5.)) .bg(metal::vertical((p.s_hi, p.s))) .shadow(p.raise_lite.clone()) .into_any_element() })) }); v_flex() .children(stacked) .children(peek.filter(|_| !expanded)) .child( composer::banner(front.tone(), Density::Comfortable, joined, p) .child(render_notice(&self.session, ("notice-front", 0usize), &front, p)), ) .into_any_element() } /// Claude's AskUserQuestion form, one question at a time. The header /// folds it away; options pick with a click or 1–9; the composer below /// takes an answer in the user's own words. fn render_question(&self, ix: usize, joined: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let session = self.session.read(cx); let Some((_, ask)) = session.pending_ask().filter(|(found, _)| *found == ix) else { return div().into_any_element(); }; let current = ask.current; let question = &ask.questions[current]; let collapsed = self.question_collapsed == Some((ix, current)); let count = ask.questions.len(); let custom_active = !ask.custom[current].trim().is_empty(); let chat = cx.entity().downgrade(); let header = { let chat = chat.clone(); let dismiss = self.session.clone(); composer::row() .id("question-header") .cursor_pointer() .rounded(px(6.)) .tooltip(move |window, cx| { Tooltip::new(if collapsed { "Show the question and its options" } else { "Hide the question and its options" }) .build(window, cx) }) .on_click(move |_, _, cx| { let _ = chat.update(cx, |this, cx| { this.question_collapsed = if collapsed { None } else { Some((ix, current)) }; cx.notify(); }); }) .child(composer::icon_cell(None::
, p)) .child( composer::content() .child(div().flex_none().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child(question.header.clone())) .when(collapsed, |this| { this.child(div().flex_1().min_w_0().truncate().text_color(p.ink2).child(question.question.clone())) }), ) .when(count > 1, |this| { this.child( div() .text_size(px(10.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .child(format!("{}/{count}", current + 1)), ) }) .child(composer::toggle_icon(!collapsed, p)) .child( composer::icon_button("question-dismiss", IconName::Close, "Dismiss question without answering") .on_click(move |_, _, cx| { cx.stop_propagation(); dismiss.update(cx, |session, cx| session.ask_skip(ix, cx)); }), ) }; let options = question.options.iter().enumerate().map(|(option, choice)| { let selected = !custom_active && ask.picked[current].contains(&option) || self.optimistic_pick == Some((ix, current, option)); let chat = chat.clone(); h_flex() .id(("question-option", option)) .w_full() .gap(px(8.)) .px(px(10.)) .py(px(8.)) .rounded(px(8.)) .cursor_pointer() .map(|this| { if selected { this.bg(p.ink.opacity(0.07)).text_color(p.ink) } else { this.text_color(p.ink.opacity(0.85)).hover(|style| style.bg(p.row_hover)) } }) .on_click(move |_, _, cx| { let _ = chat.update(cx, |this, cx| this.pick_option(ix, option, cx)); }) .child( v_flex() .flex_1() .min_w_0() .gap(px(2.)) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(choice.label.clone())) .when_some( choice.description.clone().filter(|description| *description != choice.label), |this, description| this.child(div().text_size(px(11.)).text_color(p.ink3).child(description)), ), ) .map(|this| { if selected { this.child(Icon::new(IconName::Check).size(px(14.)).text_color(p.green_ink)) } else if option < 9 { this.child( div() .flex() .flex_none() .items_center() .justify_center() .size(px(20.)) .text_size(px(10.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .child((option + 1).to_string()), ) } else { this } }) }); composer::banner(Tone::Neutral, Density::Default, joined, p) .child(header) .when(!collapsed, |this| { this.child( v_flex() .id("question-body") .max_h(px(384.)) .overflow_y_scroll() .pl(px(28.)) .pr(px(4.)) .pb(px(4.)) .child(div().text_size(px(14.)).text_color(p.ink.opacity(0.85)).child(question.question.clone())) .when(question.multi_select, |this| { this.child(div().mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child("Select one or more options.")) }) .child(v_flex().mt(px(8.)).gap(px(2.)).children(options)), ) }) .into_any_element() } /// Selects an option of the open question; a single-select question /// moves on by itself a moment later, as in T3 Code. pub(super) fn pick_option(&mut self, ix: usize, option: usize, cx: &mut Context) { let current = self.session.read(cx).pending_ask().map(|(_, ask)| ask.current).unwrap_or(0); let single = self.session.update(cx, |session, cx| session.ask_toggle(ix, option, cx)); if !single { return; } self.optimistic_pick = Some((ix, current, option)); self.ask_timer = cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_millis(200)).await; let _ = this.update(cx, |this, cx| { this.optimistic_pick = None; this.session.update(cx, |session, cx| session.ask_advance(ix, cx)); }); }); } /// The agent's task list: one row with the current step and progress, /// unfolding into every step. fn render_tasks(&self, steps: Vec, joined: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let expanded = self.tasks_expanded; let total = steps.len(); let done = steps.iter().filter(|step| step.status == StepStatus::Completed).count(); let current = steps .iter() .find(|step| step.status == StepStatus::InProgress) .or_else(|| steps.iter().find(|step| step.status == StepStatus::Pending)) .or(steps.last()) .map(|step| step.text.clone()) .unwrap_or_default(); let chat = cx.entity().downgrade(); let segments = (2..=10).contains(&total).then(|| { h_flex().w(px(80.)).flex_none().gap(px(2.)).children(steps.iter().map(|step| { div().flex_1().h(px(3.)).rounded_full().bg(match step.status { StepStatus::Completed => p.green_ink, StepStatus::InProgress => p.blue_ink, StepStatus::Pending => p.ink3.opacity(0.25), }) })) }); let summary = composer::row() .id("tasks-summary") .cursor_pointer() .rounded(px(6.)) .on_click(move |_, _, cx| { let _ = chat.update(cx, |this, cx| { this.tasks_expanded = !this.tasks_expanded; cx.notify(); }); }) .child(composer::icon_cell(Some(Icon::new(Lucide::ListTodo).size(px(12.))), p)) .child( composer::content() .child(div().flex_none().text_color(p.ink3).child("Tasks")) .child(div().flex_1().min_w_0().truncate().font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(current)), ) .child(composer::count(format!("{done}/{total}"), p).when(done >= total, |this| this.text_color(p.green_ink))) .children(segments) .child(composer::toggle_icon(expanded, p)); let list = expanded.then(|| { v_flex().id("tasks-list").max_h(px(384.)).overflow_y_scroll().children(steps.iter().enumerate().map(|(ix, step)| { let (icon, color, text) = match step.status { StepStatus::Completed => (Icon::new(IconName::Check), p.green_ink, p.ink3.opacity(0.7)), StepStatus::InProgress => (Icon::new(Lucide::CircleDot), p.blue_ink, p.ink), StepStatus::Pending => (Icon::new(Lucide::Circle), p.ink3.opacity(0.4), p.ink3), }; composer::row() .id(("task-step", ix)) .items_start() .py(px(4.)) .pr(px(8.)) .child(composer::icon_cell(Some(icon.size(px(12.)).text_color(color)), p).h(px(16.))) .child(div().flex_1().min_w_0().text_color(text).child(step.text.clone())) .when(step.status == StepStatus::InProgress, |this| { this.child(div().w(px(48.)).text_right().text_size(px(10.)).text_color(p.ink3.opacity(0.6)).child("now")) }) })) }); composer::banner(Tone::Neutral, Density::Comfortable, joined, p) .child(summary) .children(list) .into_any_element() } } /// One notice's row. fn render_notice(session: &Entity, id: impl Into, notice: &Notice, p: &Palette) -> Stateful
{ let session = session.clone(); match notice { Notice::BackgroundWork { title, detail, waiting, task_ids } => { let task_ids = task_ids.clone(); composer::row() .id(id) .child(composer::icon_cell( Some(div().size(px(6.)).rounded_full().bg(if *waiting { p.green_ink } else { p.ink })), p, )) .child( composer::content() .child(div().flex_none().font_weight(FontWeight::MEDIUM).truncate().child(title.clone())) .when_some(detail.clone(), |this, detail| { this.child(div().flex_1().min_w_0().truncate().text_color(p.ink3).child(detail)) }), ) .child(MetalButton::new("background-stop").ghost().mini().h(px(24.)).label("Stop").on_click(move |_, _, cx| { session.update(cx, |session, cx| { for task_id in &task_ids { session.stop_task(task_id, cx); } }) })) } Notice::Limit { resets, resume_at } => { let resets = *resets; composer::row() .id(id) .child(composer::icon_cell(Some(Icon::new(Lucide::Gauge).size(px(12.))), p)) .child( composer::content() .child(div().flex_none().font_weight(FontWeight::MEDIUM).child("Usage limit reached")) .child(div().flex_1().min_w_0().truncate().text_color(p.ink3).child(match resume_at { Some(at) => format!("Continues at {}", clock::when(*at)), None => format!("Resets at {}", clock::when(resets)), })), ) .child(match resume_at { Some(_) => MetalButton::new("cancel-resume") .ghost() .mini() .h(px(24.)) .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") .ghost() .mini() .h(px(24.)) .label("Continue then") .on_click(move |_, _, cx| { session.update(cx, |session, cx| session.schedule_resume(resets + Duration::from_secs(60), cx)) }), }) } } } /// An approval the agent waits on: what it wants to run, Decline and /// Approve, and the rarer answers behind "…". fn render_approval(session: &Entity, ix: usize, count: usize, joined: bool, cx: &App) -> AnyElement { let p = metal::palette(cx); let state = session.read(cx); let Some(ChatItem::Tool(tool)) = state.items.get(ix) else { return div().into_any_element() }; let label = approval_label(tool); let detail = approval_detail(tool); let mono = !tool.name.starts_with("mcp__"); let always = tool.permission.as_ref().and_then(|prompt| prompt.always_label()).is_some(); 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 menu_session = session.clone(); composer::banner(Tone::Warning, Density::Spacious, joined, p) .child( h_flex() .items_start() .gap(px(8.)) .child(div().pt(px(2.)).child(Icon::new(Lucide::Shield).size(px(16.)).text_color(p.amber_ink))) .child( v_flex() .flex_1() .min_w_0() .gap(px(4.)) .child( h_flex() .w_full() .gap(px(8.)) .text_size(px(11.)) .text_color(p.ink3) .child(div().font_weight(FontWeight::MEDIUM).text_color(p.amber_ink).child(label)) .when(tool.name.starts_with("mcp__"), |this| { this.child(div().truncate().child(tool.name.trim_start_matches("mcp__").replace("__", " · "))) }) .when(count > 1, |this| this.child(div().ml_auto().child(format!("1/{count}")))), ) .child( div() .id("approval-detail") .max_h(px(80.)) .overflow_y_scroll() .text_size(px(12.)) .text_color(p.ink) .when(mono, |this| this.font_family(metal::MONO)) .child(detail), ), ) .child( h_flex() .flex_none() .gap(px(6.)) .child(MetalButton::new(("approval-decline", ix)).compact().label("Decline").on_click(respond(PermissionDecision::Deny))) .child( MetalButton::new(("approval-approve", ix)) .primary() .compact() .label("Approve") .on_click(respond(PermissionDecision::Allow)), ) .child( MetalButton::new(("approval-more", ix)) .compact() .icon(IconName::Ellipsis) .tooltip("More approval options") .dropdown_menu_with_anchor(Anchor::BottomRight, move |menu, _, _| { let cancel = menu_session.clone(); let allow = menu_session.clone(); let menu = menu.item(gpui_kit::component::menu::PopupMenuItem::new("Cancel").on_click( move |_, _, cx| { cancel.update(cx, |session, cx| { session.respond_permission(ix, PermissionDecision::Deny, cx); session.interrupt(cx); }) }, )); if always { menu.item(gpui_kit::component::menu::PopupMenuItem::new("Always allow this session").on_click( move |_, _, cx| { allow.update(cx, |session, cx| { session.respond_permission(ix, PermissionDecision::AllowAlways, cx) }) }, )) } else { menu } }), ), ), ) .into_any_element() } /// A Pi extension's question: its title and message, and its options to /// pick with a click or 1–9. fn render_extension_question(session: &Entity, ix: usize, joined: bool, cx: &App) -> AnyElement { let p = metal::palette(cx); let Some(ChatItem::Question(question)) = session.read(cx).items.get(ix) else { return div().into_any_element() }; let options = question.options.iter().enumerate().map(|(option, label)| { let session = session.clone(); h_flex() .id(("extension-option", option)) .w_full() .gap(px(8.)) .px(px(10.)) .py(px(8.)) .rounded(px(8.)) .cursor_pointer() .text_color(p.ink.opacity(0.85)) .hover(|style| style.bg(p.row_hover)) .on_click(move |_, _, cx| session.update(cx, |session, cx| session.answer_question(ix, option, cx))) .child(div().flex_1().min_w_0().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(label.clone())) .when(option < 9, |this| { this.child( div() .flex() .flex_none() .items_center() .justify_center() .size(px(20.)) .text_size(px(10.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .child((option + 1).to_string()), ) }) }); composer::banner(Tone::Neutral, Density::Default, joined, p) .child( composer::row() .child(composer::icon_cell(None::
, p)) .child(composer::content().child(div().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Question"))), ) .child( v_flex() .pl(px(28.)) .pr(px(4.)) .pb(px(4.)) .child(div().text_size(px(14.)).text_color(p.ink.opacity(0.85)).child(question.title.clone())) .when_some(question.message.clone(), |this, message| { this.child(div().mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(message)) }) .child(v_flex().mt(px(8.)).gap(px(2.)).children(options)), ) .into_any_element() } /// Claude's plan, ready to implement: the footer below offers Implement or /// Refine. fn render_plan_ready(ix: usize, title: String, joined: bool, p: &Palette) -> AnyElement { composer::banner(Tone::Neutral, Density::Default, joined, p) .child( composer::row() .id(("plan-ready", ix)) .child(composer::icon_cell(None::
, p)) .child( composer::content() .child(div().flex_none().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Plan ready")) .child(div().flex_1().min_w_0().truncate().text_color(p.ink.opacity(0.85)).child(title)), ), ) .into_any_element() } /// The answers so far of an open form, for the primary actions. pub fn ask_progress(ask: &UserQuestions) -> (bool, bool) { let can_advance = ask.resolved(ask.current).is_some(); (can_advance, ask.is_complete()) } /// A queued message being dragged into another place. #[derive(Clone)] struct QueuedDrag(usize); /// What follows the pointer while a queued message is dragged. struct QueuedGhost(SharedString); impl Render for QueuedGhost { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); div() .max_w(px(320.)) .px(px(10.)) .py(px(4.)) .rounded(px(8.)) .bg(p.menu_bg) .shadow(p.raise_sm.clone()) .text_size(px(12.)) .text_color(p.ink2) .truncate() .child(self.0.clone()) } } fn text_preview(text: &str) -> String { let line = text.lines().next().unwrap_or_default(); let mut preview: String = line.chars().take(80).collect(); if line.chars().count() > 80 { preview.push('…'); } preview }