//! 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, 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<TaskStep> {
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()))
}
/// 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<Self>) -> Vec<AnyElement> {
// 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 steps = task_steps(session);
let blocking = approval.is_some() || ask.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<AnyElement> = 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)),
_ => {}
}
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<Self>) -> 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<Self>) -> 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<AnyElement> = 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<Self>) -> 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<AnyElement> = 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::<QueuedDrag>(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<Notice>, joined: bool, cx: &mut Context<Self>) -> 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<Notice> = 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<Self>) -> 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::<Div>, 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<Self>) {
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<TaskStep>, joined: bool, cx: &mut Context<Self>) -> 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<Session>, id: impl Into<ElementId>, notice: &Notice, p: &Palette) -> Stateful<Div> {
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<Session>, 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()
}
/// 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::<Div>, 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<Self>) -> 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
}