//! The subagents panel: every subagent the open thread's agent started, the //! working ones first with what each is doing right now (its latest tool //! call, its progress summary, how long it's run, its tool uses and //! tokens), then the finished ones. A click shows the subagent's call in //! its conversation. //! //! Claude's Task and Agent calls are one subagent each. Pi's `subagent` //! extension and jcode's swarm spawns can start several, each its own row. //! A task delegated to the app runs in a thread of its own, which a click //! opens. use std::time::Duration; use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::{PANEL_WIDTH, Workspace}; use crate::session::{ChatItem, Session, SubagentStop, TaskStatus}; use crate::ui::chat::{describe_call, format_duration}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; /// A subagent as the panel lists it. struct Subagent { session: Entity, /// Its tool call's place in the thread's transcript. ix: usize, /// Tells apart the rows, several of which can share a call. id: usize, description: SharedString, /// The kind of agent, e.g. `Explore`. kind: Option, status: TaskStatus, background: bool, tool_uses: usize, tokens: Option, elapsed: Option, /// What it's doing: its latest tool call while it works. activity: Option<(Icon, String)>, summary: Option, /// The thread a task delegated to the app runs in, by id. thread: Option, /// How its Stop button stops it, while it runs. stop: Option, } /// A thread's subagents, in the order they started. fn subagents_of(session: &Entity, cx: &App) -> Vec { let state = session.read(cx); let thread = session.entity_id().as_u64() as usize; let mut subagents = Vec::new(); for (ix, item) in state.items.iter().enumerate() { let ChatItem::Tool(tool) = item else { continue }; for (member, entry) in tool.subagents().into_iter().enumerate() { subagents.push(Subagent { session: session.clone(), ix, id: thread << 32 | ix << 8 | member, description: entry.label.into(), // The default kind goes without saying. kind: entry.kind.filter(|kind| !kind.is_empty() && kind != "general-purpose").map(Into::into), status: entry.status, background: entry.background, tool_uses: entry.tool_uses, tokens: entry.tokens, elapsed: entry.elapsed, activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)), summary: entry.summary.map(Into::into), thread: entry.thread, stop: entry.stop, }); } } subagents } /// `812 tokens`, `23.4k tokens`, `1.2M tokens`. fn format_tokens(tokens: u64) -> String { match tokens { 0..1_000 => format!("{tokens} tokens"), 1_000..1_000_000 => format!("{:.1}k tokens", tokens as f64 / 1_000.).replace(".0k", "k"), _ => format!("{:.1}M tokens", tokens as f64 / 1_000_000.).replace(".0M", "M"), } } impl Workspace { /// How many subagents the open thread has working, for the header's light. pub(super) fn working_subagents(&self, cx: &App) -> usize { self.active.as_ref().map_or(0, |session| { subagents_of(session, cx).iter().filter(|subagent| subagent.status == TaskStatus::Running).count() }) } /// Opens a subagent's thread, if it isn't open, and shows its call; or /// for a task delegated to the app, the thread it runs in. fn reveal_subagent( &mut self, session: Entity, ix: usize, thread: Option, window: &mut Window, cx: &mut Context, ) { if let Some((project, child)) = thread.and_then(|thread| self.local_thread(&thread, cx)) { self.select(project, child, window, cx); return; } if self.active.as_ref() != Some(&session) { let Some(project) = self.project_of(&session) else { return }; self.select(project, session.clone(), window, cx); } if let Some(chat) = self.chats.get(&session.entity_id()).cloned() { chat.update(cx, |chat, cx| chat.reveal_tool(ix, cx)); } } fn stop_subagent(&mut self, session: &Entity, stop: &SubagentStop, cx: &mut Context) { match stop { SubagentStop::Thread(task) => self.stop_delegated(task, cx), _ => session.update(cx, |session, cx| session.stop_subagent(stop, cx)), } cx.notify(); } pub(super) fn render_subagents_panel(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default(); let (working, mut finished): (Vec, Vec) = mine.into_iter().partition(|subagent| subagent.status == TaskStatus::Running); finished.reverse(); let empty = working.is_empty() && finished.is_empty(); let header = h_flex() .h(px(36.)) .pl(px(10.)) .gap(px(10.)) .child(Icon::new(Lucide::Bot).size(px(16.)).text_color(p.icon)) .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Subagents")) .when(!working.is_empty(), |this| this.child(metal::tag(format!("{} working", working.len()), p))) .child( MetalButton::new("close-subagents") .ghost() .mini() .icon(IconName::Close) .tooltip("Close subagents") .on_click(cx.listener(|this, _, _, cx| { this.right_panel = None; cx.notify(); })), ); let section = |title: String, rows: Vec| { v_flex() .gap(px(2.)) .child(div().px(px(10.)).pt(px(8.)).pb(px(4.)).child(metal::engraved(title, p))) .children(rows) }; let rows = |subagents: Vec, cx: &mut Context| -> Vec { subagents.into_iter().map(|subagent| self.render_subagent(subagent, p, cx)).collect() }; let working_count = working.len(); let finished_count = finished.len(); let working_rows = rows(working, cx); let finished_rows = rows(finished, cx); v_flex() .w(px(PANEL_WIDTH)) .flex_none() .h_full() .p(px(12.)) .pl(px(0.)) .child( v_flex() .max_h_full() .p(px(6.)) .rounded(px(18.)) .raised(p) .child(header) .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))) .child( v_flex() .id("subagents-list") .flex_shrink(1.) .min_h_0() .overflow_y_scroll() .when(empty, |this| { this.child( v_flex() .px(px(10.)) .py(px(10.)) .gap(px(4.)) .child(div().text_size(px(13.)).text_color(p.ink2).child("No subagents in this thread")) .child(div().text_size(px(12.)).text_color(p.ink3).child( "When the agent hands work to a subagent, it shows up here with what it's doing.", )), ) }) .when(working_count > 0, |this| this.child(section("Working".into(), working_rows))) .when(finished_count > 0, |this| this.child(section(format!("Finished · {finished_count}"), finished_rows))), ), ) } /// One subagent: its light, what it was asked to do and for how long, /// its kind and totals, and while it works, what it's doing now. fn render_subagent(&self, subagent: Subagent, p: &Palette, cx: &mut Context) -> AnyElement { let running = subagent.status == TaskStatus::Running; let (led, status) = match subagent.status { TaskStatus::Running => (Led::Live, None), TaskStatus::Completed => (Led::Off, None), TaskStatus::Failed => (Led::Failed, Some("Failed")), TaskStatus::Stopped => (Led::Off, Some("Stopped")), }; let id = subagent.id; // A delegated task's agent can stop to ask the user something. let waiting = running && subagent .thread .as_ref() .and_then(|thread| self.local_thread(thread, cx)) .is_some_and(|(_, child)| child.read(cx).needs_attention()); let status = if waiting { Some("Waiting for you") } else { status }; let meta: Vec = [ subagent.kind.as_ref().map(ToString::to_string), (subagent.tool_uses > 0) .then(|| format!("{} {}", subagent.tool_uses, if subagent.tool_uses == 1 { "tool" } else { "tools" })), subagent.tokens.filter(|tokens| *tokens > 0).map(format_tokens), subagent.background.then(|| "background".to_string()), ] .into_iter() .flatten() .collect(); let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone()); let (stop, stop_session) = (subagent.stop.clone(), subagent.session.clone()); h_flex() .id(("subagent", id)) .items_start() .gap(px(10.)) .px(px(10.)) .py(px(7.)) .rounded(px(12.)) .cursor_pointer() .hover(|style| style.bg(p.row_hover)) .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, child.clone(), window, cx))) .child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p))) .child( v_flex() .flex_1() .min_w_0() .gap(px(2.)) .child( h_flex() .gap(px(8.)) .child( div() .flex_1() .min_w_0() .truncate() .text_size(px(13.)) .text_color(if running { p.ink } else { p.ink2 }) .child(subagent.description.clone()), ) .when_some(status, |this, status| { this.child( div() .flex_none() .text_size(px(11.5)) .text_color(if subagent.status == TaskStatus::Failed { p.red } else { p.ink3 }) .child(status), ) }) .when_some(subagent.elapsed, |this, elapsed| { this.child( div() .flex_none() .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink3) .child(format_duration(elapsed.as_millis() as u64)), ) }) .when_some(stop, |this, stop| { let tip = if stop == SubagentStop::Turn { "Stop (stops the whole turn: Pi runs these inside one tool call)" } else { "Stop this subagent" }; let session = stop_session.clone(); this.child( MetalButton::new(("stop-subagent", id)) .ghost() .mini() .icon(Lucide::Square) .tooltip(tip) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.stop_subagent(&session, &stop, cx); })), ) }), ) .when_some(subagent.activity, |this, (icon, label)| { this.child( h_flex() .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink2) .child(icon.size(px(13.)).text_color(p.ink3)) .child(div().flex_1().min_w_0().truncate().child(label)), ) }) .when_some(subagent.summary.filter(|_| running), |this, summary| { this.child(div().text_size(px(12.)).text_color(p.ink3).line_clamp(2).child(summary)) }) .when(!meta.is_empty(), |this| { this.child( div() .font_family(metal::MONO) .text_size(px(10.5)) .text_color(p.ink3) .child(meta.join(" · ")), ) }), ) .into_any_element() } } #[cfg(test)] mod tests { use super::format_tokens; #[::core::prelude::v1::test] fn token_counts() { assert_eq!(format_tokens(812), "812 tokens"); assert_eq!(format_tokens(23_412), "23.4k tokens"); assert_eq!(format_tokens(40_000), "40k tokens"); assert_eq!(format_tokens(1_340_000), "1.3M tokens"); } }