diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index f15ed6bee0c..47bbd2afef6 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -37,6 +37,10 @@ use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size}; use crate::ai::blocklist::inline_action::requested_action::{ render_requested_action_row, render_requested_action_row_for_text, }; +use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, +}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::appearance::Appearance; use crate::terminal::view::TerminalAction; @@ -63,14 +67,6 @@ impl OrchestrationParticipant { } } - fn unknown_child() -> Self { - Self { - display_name: "Unknown agent".to_string(), - avatar: OrchestrationAvatar::agent("Unknown agent".to_string()), - conversation_id: None, - } - } - fn is_orchestrator(&self) -> bool { matches!(&self.avatar, OrchestrationAvatar::Orchestrator) } @@ -90,21 +86,28 @@ fn participant_for_agent_id( orchestrator_agent_id: Option<&str>, app: &AppContext, ) -> OrchestrationParticipant { - if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) { - if let Some(conversation) = - BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) - { - return participant_for_conversation( - conversation, - orchestrator_agent_id, - Some(agent_id), - ); + let participant = resolve_orchestration_participant( + BlocklistAIHistoryModel::as_ref(app), + agent_id, + orchestrator_agent_id, + ); + let display_name = participant.kind.display_name().to_string(); + let avatar = match &participant.kind { + OrchestrationParticipantKind::Orchestrator => OrchestrationAvatar::Orchestrator, + OrchestrationParticipantKind::Agent { .. } | OrchestrationParticipantKind::Unknown => { + OrchestrationAvatar::agent(display_name.clone()) } + }; + OrchestrationParticipant { + display_name, + avatar, + conversation_id: match &participant.kind { + OrchestrationParticipantKind::Orchestrator | OrchestrationParticipantKind::Unknown => { + None + } + OrchestrationParticipantKind::Agent { .. } => participant.conversation_id, + }, } - if orchestrator_agent_id.is_some_and(|id| id == agent_id) { - return OrchestrationParticipant::orchestrator(); - } - OrchestrationParticipant::unknown_child() } fn participant_for_conversation( @@ -131,18 +134,6 @@ fn participant_for_conversation( } } -fn orchestrator_agent_id_for_conversation( - conversation: &AIConversation, - app: &AppContext, -) -> Option { - match conversation.parent_conversation_id() { - Some(parent_id) => BlocklistAIHistoryModel::as_ref(app) - .conversation(&parent_id) - .and_then(|parent| parent.orchestration_agent_id()), - None => conversation.orchestration_agent_id(), - } -} - fn participant_for_current_conversation( props: Props, orchestrator_agent_id: Option<&str>, @@ -343,10 +334,9 @@ pub(super) fn render_messages_received_from_agents( if messages.is_empty() { return Empty::new().finish(); } - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); for (index, msg) in messages.iter().enumerate() { let sender = @@ -422,10 +412,9 @@ pub(super) fn render_send_message( let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let status = props.action_model.as_ref(app).get_action_status(action_id); - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let recipient_participants = participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app); let recipients = participant_display_names(&recipient_participants); diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index ff556cc32d3..e1d95f34f01 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -15,6 +15,91 @@ pub enum OrchestrationNavigationDirection { Next, } +/// Semantic role of a participant in an orchestration transcript. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OrchestrationParticipantKind { + Orchestrator, + Agent { name: String }, + Unknown, +} + +impl OrchestrationParticipantKind { + pub(super) fn display_name(&self) -> &str { + match self { + Self::Orchestrator => "Orchestrator", + Self::Agent { name } => name, + Self::Unknown => "Unknown agent", + } + } +} + +/// Frontend-independent identity for an orchestration participant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedOrchestrationParticipant { + pub kind: OrchestrationParticipantKind, + pub conversation_id: Option, +} + +impl ResolvedOrchestrationParticipant { + fn orchestrator(conversation_id: Option) -> Self { + Self { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id, + } + } + + fn unknown() -> Self { + Self { + kind: OrchestrationParticipantKind::Unknown, + conversation_id: None, + } + } +} + +/// Returns the agent ID of the conversation that orchestrates `conversation`. +pub fn orchestrator_agent_id_for_conversation( + history: &BlocklistAIHistoryModel, + conversation: &AIConversation, +) -> Option { + match history.resolved_parent_conversation_id_for_conversation(conversation) { + Some(parent_id) => history + .conversation(&parent_id) + .and_then(AIConversation::orchestration_agent_id) + .or_else(|| conversation.parent_agent_id().map(str::to_owned)), + None => conversation + .parent_agent_id() + .map(str::to_owned) + .or_else(|| conversation.orchestration_agent_id()), + } +} + +/// Resolves a server-side agent ID to frontend-independent participant data. +pub fn resolve_orchestration_participant( + history: &BlocklistAIHistoryModel, + agent_id: &str, + orchestrator_agent_id: Option<&str>, +) -> ResolvedOrchestrationParticipant { + let conversation_id = history.conversation_id_for_agent_id(agent_id); + if orchestrator_agent_id == Some(agent_id) { + return ResolvedOrchestrationParticipant::orchestrator(conversation_id); + } + let Some(conversation_id) = conversation_id else { + return ResolvedOrchestrationParticipant::unknown(); + }; + let Some(conversation) = history.conversation(&conversation_id) else { + return ResolvedOrchestrationParticipant::unknown(); + }; + let name = conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(); + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent { name }, + conversation_id: Some(conversation_id), + } +} + const DONE_STATUS_KEY: u8 = 3; fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 { diff --git a/app/src/ai/blocklist/orchestration_topology_tests.rs b/app/src/ai/blocklist/orchestration_topology_tests.rs index 92d742fdeeb..6c61fc46e49 100644 --- a/app/src/ai/blocklist/orchestration_topology_tests.rs +++ b/app/src/ai/blocklist/orchestration_topology_tests.rs @@ -1,9 +1,89 @@ +use uuid::Uuid; use warpui::{App, EntityId, ModelHandle}; use super::*; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::test_util::settings::initialize_history_persistence_for_tests; + +#[test] +fn participant_resolution_uses_the_direct_parent_as_orchestrator() { + App::test((), |mut app| async move { + initialize_history_persistence_for_tests(&mut app); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let surface_id = EntityId::new(); + let root_run_id = Uuid::new_v4().to_string(); + let child_run_id = Uuid::new_v4().to_string(); + let grandchild_run_id = Uuid::new_v4().to_string(); + + let (root_id, child_id, grandchild_id) = history_model.update(&mut app, |history, ctx| { + let root_id = history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + root_id, + root_run_id.clone(), + None, + surface_id, + ctx, + ); + let child_id = history.start_new_child_conversation( + surface_id, + "child".to_string(), + root_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + child_id, + child_run_id.clone(), + None, + surface_id, + ctx, + ); + let grandchild_id = history.start_new_child_conversation( + surface_id, + "grandchild".to_string(), + child_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + grandchild_id, + grandchild_run_id, + None, + surface_id, + ctx, + ); + (root_id, child_id, grandchild_id) + }); + + history_model.read(&app, |history, _| { + let grandchild = history + .conversation(&grandchild_id) + .expect("grandchild conversation exists"); + assert_eq!( + orchestrator_agent_id_for_conversation(history, grandchild), + Some(child_run_id.clone()) + ); + assert_eq!( + resolve_orchestration_participant(history, &child_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id: Some(child_id), + } + ); + assert_eq!( + resolve_orchestration_participant(history, &root_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent { + name: "Agent".to_string(), + }, + conversation_id: Some(root_id), + } + ); + }); + }); +} + #[test] fn pill_order_keys_prioritize_attention_then_in_progress_then_done() { let blocked = ConversationStatus::Blocked { diff --git a/app/src/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 41100f3f1a5..c2207053970 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -128,7 +128,7 @@ pub struct AIExecutionProfilesModel { impl AIExecutionProfilesModel { #[allow(unused_variables)] - pub fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { + pub(crate) fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "agent_mode_evals")] { let default_profile_state = DefaultProfileState::Unsynced { diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 1941e4a26e7..51d6dcfaa6e 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -67,6 +67,10 @@ pub use crate::ai::blocklist::history_model::{ pub use crate::ai::blocklist::orchestration_event_streamer::{ register_agent_event_consumer, unregister_agent_event_consumer, }; +pub use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, ResolvedOrchestrationParticipant, +}; pub use crate::ai::blocklist::view_util::format_credits; #[cfg(not(target_family = "wasm"))] pub use crate::ai::blocklist::{ diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index c64569e3f56..07431fac0aa 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -19,7 +19,7 @@ use warp::tui_export::{ AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, AIConversationId, BlockId, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel, CancellationReason, MessageId, ModelEvent, ModelEventDispatcher, - SummarizationType, TerminalModel, TodoOperation, TodoStatus, + ReceivedMessageDisplay, SummarizationType, TerminalModel, TodoOperation, TodoStatus, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -40,6 +40,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; +use crate::agent_message::render_agent_message; use crate::orchestration_block::{TuiOrchestrationBlock, TuiOrchestrationBlockEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; @@ -99,6 +100,8 @@ enum TuiAIBlockSection { CompletedTodos { completed: Vec, }, + /// A message delivered by another agent in the orchestration. + AgentMessage(ReceivedMessageDisplay), } /// Per-message UI state for collapsible sections (thinking blocks, @@ -970,6 +973,7 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(_) => return None, }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -1082,26 +1086,14 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, - - // TODO: add full status rendering for sub-agents. AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received in messages { - sections.push(TuiAIBlockSection::RichText( - TuiRichTextSection::PlainText(format!( - "Received message from agent {}: {}", - received.sender_agent_id, received.subject - )), - )); + sections.push(TuiAIBlockSection::AgentMessage(received.clone())); } } - AIAgentOutputMessageType::EventsFromAgents { event_ids } => { - let count = event_ids.len(); - let plural = if count == 1 { "" } else { "s" }; - sections.push(TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( - format!("Received {count} agent lifecycle event{plural}"), - ))); - } - + // Event IDs contain no display detail. The sender's live + // conversation status is shown on rich message rows. + AIAgentOutputMessageType::EventsFromAgents { .. } => {} // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) @@ -1201,8 +1193,16 @@ impl TuiAIBlock { /// Builds this block's generic TUI element tree. fn render_element(&self, app: &AppContext) -> Box { let output_streaming = self.block_model.status(app).is_streaming(); - let mut column = TuiFlex::column(); + + // Keep the view registered so a streaming exchange can gain visible + // sections later, but do not reserve inter-block padding while every + // message in this exchange is intentionally hidden. let sections = self.sections(app); + if sections.is_empty() { + return TuiFlex::column().finish(); + } + + let mut column = TuiFlex::column(); let last_index = sections.len().saturating_sub(1); for (index, section) in sections.iter().enumerate() { let element = match section { @@ -1285,6 +1285,12 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => render_agent_message( + &self.collapsible_states, + message, + self.conversation_id, + app, + ), }; // One row of bottom padding separates sections; the last section @@ -1330,8 +1336,8 @@ fn last_row_content_width(element: &mut Box, width: u16, height: } /// The copy-able logical text for a section, or `None` for section kinds with no -/// clean logical form (tool calls, reasoning, summaries, todo lists), which fall -/// back to per-row grid text. +/// clean logical form (tool calls, reasoning, summaries, todo lists, or agent +/// messages), which fall back to per-row grid text. fn section_logical_text(section: &TuiAIBlockSection) -> Option { match section { TuiAIBlockSection::Input(text) => Some(text.clone()), @@ -1348,7 +1354,8 @@ fn section_logical_text(section: &TuiAIBlockSection) -> Option { | TuiAIBlockSection::Thinking { .. } | TuiAIBlockSection::Summarization { .. } | TuiAIBlockSection::TodoList { .. } - | TuiAIBlockSection::CompletedTodos { .. } => None, + | TuiAIBlockSection::CompletedTodos { .. } + | TuiAIBlockSection::AgentMessage(_) => None, } } diff --git a/crates/warp_tui/src/agent_block_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index 708668e603d..f09051fe3d3 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -18,10 +18,7 @@ use warpui_core::elements::CrossAxisAlignment; use warpui_core::AppContext; use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; -use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, ResolvedCommandBlock, - ToolCallDisplayState, -}; +use crate::tool_call_labels::{tool_call_display_state, tool_call_label, ResolvedCommandBlock}; use crate::tui_builder::TuiUiBuilder; const INPUT_PREFIX: &str = "> "; @@ -73,41 +70,6 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval | ToolCallDisplayState::Running => { - builder.attention_glyph_style() - } - ToolCallDisplayState::Succeeded => builder.success_glyph_style(), - ToolCallDisplayState::Failed => builder.error_text_style(), - ToolCallDisplayState::Cancelled => builder.muted_text_style(), - } -} - -/// Shared label style for all rich and fallback TUI tool-call rows. -pub(crate) fn tool_call_label_style( - state: ToolCallDisplayState, - builder: &TuiUiBuilder, -) -> TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval - | ToolCallDisplayState::Running - | ToolCallDisplayState::Succeeded - | ToolCallDisplayState::Failed - | ToolCallDisplayState::Cancelled => builder.primary_text_style(), - } -} - /// Renders the fallback plain-text status row for an agent tool call, used /// for every tool call without a richer registered child view (the GUI's /// view-based action rendering has no TUI equivalent for these yet): a @@ -116,9 +78,10 @@ pub(crate) fn tool_call_label_style( /// hanging indent under itself. State lives in the glyph, so labels keep the /// normal foreground except in-flight rows, which stay dim until execution /// starts. `output_streaming` marks tool calls whose arguments are still -/// streaming in (see `ToolCallDisplayState::Constructing`); `block` carries -/// the terminal block's ground truth for shell-command tool calls (see -/// `ResolvedCommandBlock`). +/// streaming in (see +/// [`crate::tool_call_labels::ToolCallDisplayState::Constructing`]); `block` +/// carries the terminal block's ground truth for shell-command tool calls +/// (see `ResolvedCommandBlock`). pub(crate) fn render_fallback_tool_call_section( action: &AIAgentAction, status: Option<&AIActionStatus>, @@ -128,12 +91,12 @@ pub(crate) fn render_fallback_tool_call_section( ) -> Box { let builder = TuiUiBuilder::from_app(app); let state = tool_call_display_state(status, output_streaming, block.map(|block| block.state)); - let glyph_style = tool_call_glyph_style(state, &builder); - let label_style = tool_call_label_style(state, &builder); + let glyph_style = state.glyph_style(&builder); + let label_style = state.label_style(&builder); let label = tool_call_label(action, status, output_streaming, block); TuiFlex::row() .child( - TuiText::new(format!("{} ", tool_call_glyph(state))) + TuiText::new(format!("{} ", state.glyph())) .with_style(glyph_style) .finish(), ) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 7d67021d2f0..cf6e6418df0 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -40,6 +40,7 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; +use crate::agent_message::agent_message_section_id; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_plan_view::TuiPlanViewAction; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -328,6 +329,13 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { task_id: TaskId::new("wait-task".to_string()), requires_result: false, }; + let received = ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }; let block = test_agent_block( &mut app, FakeAgentBlockModel { @@ -337,13 +345,7 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { AIAgentOutputMessage { id: MessageId::new("m2".to_string()), message: AIAgentOutputMessageType::MessagesReceivedFromAgents { - messages: vec![ReceivedMessageDisplay { - message_id: "message-1".to_string(), - sender_agent_id: "researcher".to_string(), - addresses: vec!["lead".to_string()], - subject: "Investigation complete".to_string(), - message_body: "Found the issue".to_string(), - }], + messages: vec![received.clone()], }, citations: Vec::new(), }, @@ -362,27 +364,49 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { let block = block.as_ref(app_ctx); assert_eq!( block.sections(app_ctx), - vec![ - TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( - "Received message from agent researcher: Investigation complete" - .to_string(), - )), - TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( - "Received 2 agent lifecycle events".to_string(), - )), - ], - ); - assert_eq!( - render_block_lines(block, 80, app_ctx), - vec![ - "Received message from agent researcher: Investigation complete", - "Received 2 agent lifecycle events", - ], + vec![TuiAIBlockSection::AgentMessage(received)], ); + let lines = render_block_lines(block, 80, app_ctx); + assert_eq!(lines.len(), 1); + assert!(lines[0].ends_with(" ▸")); + assert!(!lines[0].contains("lifecycle event")); }); }); } +#[test] +fn hidden_only_orchestration_exchange_has_zero_height() { + App::test((), |mut app| async move { + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage::events_from_agents( + MessageId::new("m2".to_owned()), + vec!["event-1".to_owned()], + ), + ]), + }, + ); + + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(block.sections(ctx).is_empty()); + assert_eq!(desired_height(block, 80, ctx), 0); + }); + }); +} #[test] fn tool_call_row_glyph_and_colors_reflect_state() { App::test((), |app| async move { @@ -781,6 +805,81 @@ fn agent_block_ignores_unsupported_message_variants() { }); } +#[test] +fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { + App::test((), |mut app| async move { + let first = received_message("run-1", "first", "Starting work"); + let second = received_message("run-2", "second", "Reviewing changes"); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![first.clone(), second.clone()], + ), + AIAgentOutputMessage::events_from_agents( + MessageId::new("events-1".to_owned()), + vec!["event-1".to_owned(), "event-2".to_owned()], + ), + ]), + }, + ); + app.read(|app_ctx| { + assert_eq!( + block.as_ref(app_ctx).sections(app_ctx), + vec![ + TuiAIBlockSection::AgentMessage(first), + TuiAIBlockSection::AgentMessage(second), + ] + ); + }); + }); +} + +#[test] +fn agent_message_defaults_collapsed_and_expands_through_block_state() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let received = received_message("run-1", "progress", "Starting work"); + let message_id = agent_message_section_id(&received); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![received], + ), + ]), + }, + ); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting work"))); + }); + + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + block.window_id(ctx), + block.id(), + &TuiAIBlockAction::SetSectionCollapsed { + message_id, + collapsed: false, + }, + ); + }); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▾")); + assert_eq!(lines[1], " Starting work"); + }); + }); +} + #[test] fn agent_block_preserves_and_renders_code_sections_in_order() { App::test((), |mut app| async move { @@ -1751,6 +1850,17 @@ fn debug_output_message(id: &str, text: &str) -> AIAgentOutputMessage { } } +/// Builds one incoming orchestration message for extraction tests. +fn received_message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + /// Builds a todo item for task-list tests. fn todo(id: &str, title: &str) -> AIAgentTodo { AIAgentTodo::new(id.to_owned().into(), title.to_owned(), String::new()) diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs new file mode 100644 index 00000000000..3297a8a0a5b --- /dev/null +++ b/crates/warp_tui/src/agent_message.rs @@ -0,0 +1,204 @@ +//! Rich TUI rendering for messages received from orchestration participants. + +use warp::tui_export::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, AIConversationId, + BlocklistAIHistoryModel, ConversationStatus, MessageId, OrchestrationParticipantKind, + ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + tui_collapsible, Modifier, TuiContainer, TuiElement, TuiStyle, TuiText, +}; +use warpui_core::AppContext; + +use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; +use crate::orchestrated_agent_identity_styling::{ + assign_agent_identity_indices, stable_hash, AgentIdentity, +}; +use crate::tui_builder::TuiUiBuilder; + +/// Render-ready identity and lifecycle presentation for a message sender. +struct AgentMessagePresentation { + name: String, + status: ConversationStatus, + identity: AgentIdentity, +} + +/// Compact glyph for a conversation's lifecycle status. +fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents => "●", + ConversationStatus::Success => "✓", + ConversationStatus::Error => "×", + ConversationStatus::Cancelled | ConversationStatus::Blocked { .. } => "■", + } +} + +/// Semantic theme style for a conversation's lifecycle glyph. +fn conversation_status_glyph_style( + status: &ConversationStatus, + builder: &TuiUiBuilder, +) -> TuiStyle { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents + | ConversationStatus::Blocked { .. } => builder.attention_glyph_style(), + ConversationStatus::Success => builder.success_glyph_style(), + ConversationStatus::Error => builder.error_text_style(), + ConversationStatus::Cancelled => builder.muted_text_style(), + } +} + +/// Returns a child's stable identity index among its siblings. +fn child_identity_index( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, + palette_len: usize, +) -> Option { + let conversation = history.conversation(&conversation_id)?; + let parent_id = history.resolved_parent_conversation_id_for_conversation(conversation)?; + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == conversation_id)?; + assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + palette_len, + ) + .get(sender_index) + .copied() +} + +/// Resolves a sender's name and sibling-stable identity. +fn message_presentation( + sender_agent_id: &str, + current_conversation_id: AIConversationId, + builder: &TuiUiBuilder, + app: &AppContext, +) -> AgentMessagePresentation { + let history = BlocklistAIHistoryModel::as_ref(app); + let palette = builder.agent_identity_palette(); + let orchestrator_agent_id = history + .conversation(¤t_conversation_id) + .and_then(|conversation| orchestrator_agent_id_for_conversation(history, conversation)); + let participant = resolve_orchestration_participant( + history, + sender_agent_id, + orchestrator_agent_id.as_deref(), + ); + let sender = participant + .conversation_id + .and_then(|conversation_id| history.conversation(&conversation_id)); + let status = sender + .map(|conversation| conversation.status().clone()) + .unwrap_or(ConversationStatus::InProgress); + let fallback_identity_index = (!palette.is_empty()).then(|| { + usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() + }); + let (name, identity) = match participant.kind { + OrchestrationParticipantKind::Orchestrator => { + ("Orchestrator".to_owned(), AgentIdentity::default()) + } + OrchestrationParticipantKind::Agent { name } => ( + name, + participant + .conversation_id + .and_then(|conversation_id| { + child_identity_index(history, conversation_id, palette.len()) + }) + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + ), + OrchestrationParticipantKind::Unknown => ( + "Unknown agent".to_owned(), + fallback_identity_index + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + ), + }; + AgentMessagePresentation { + name, + status, + identity, + } +} + +/// The persistent collapse-state key for one received message. +pub(crate) fn agent_message_section_id(message: &ReceivedMessageDisplay) -> MessageId { + MessageId::new(format!("received-agent-message:{}", message.message_id)) +} + +/// Renders a received child message as a collapsed-by-default disclosure. +pub(crate) fn render_agent_message( + states: &CollapsibleSectionStates, + message: &ReceivedMessageDisplay, + current_conversation_id: AIConversationId, + app: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(app); + let presentation = message_presentation( + &message.sender_agent_id, + current_conversation_id, + &builder, + app, + ); + let header_spans = [ + ( + format!("{} ", conversation_status_glyph(&presentation.status)), + conversation_status_glyph_style(&presentation.status, &builder), + ), + ( + format!("{} ", presentation.identity.glyph), + presentation.identity.style, + ), + ( + presentation.name, + presentation.identity.style.add_modifier(Modifier::BOLD), + ), + // The helper adds another separating space with its chevron. + (" ".to_owned(), builder.primary_text_style()), + ]; + let preview = if message.message_body.trim().is_empty() { + message.subject.as_str() + } else { + message.message_body.as_str() + } + .to_owned(); + let preview_style = builder.muted_text_style(); + let message_id = agent_message_section_id(message); + let collapsed = states.is_collapsed(&message_id, true); + let toggle_message_id = message_id.clone(); + tui_collapsible( + collapsed, + header_spans, + builder.primary_text_style(), + states.hover_state(&message_id), + move || { + TuiContainer::new( + TuiText::new(preview.clone()) + .with_style(preview_style) + .finish(), + ) + .with_padding_left(4) + .finish() + }, + move |event_ctx, _app| { + event_ctx.dispatch_typed_action(TuiAIBlockAction::SetSectionCollapsed { + message_id: toggle_message_id.clone(), + collapsed: !collapsed, + }); + }, + ) +} + +#[cfg(test)] +#[path = "agent_message_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs new file mode 100644 index 00000000000..f2158bd59df --- /dev/null +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -0,0 +1,261 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + ConversationStatus, ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, EntityId}; + +use super::{agent_message_section_id, conversation_status_glyph, render_agent_message}; +use crate::agent_block::CollapsibleSectionStates; +use crate::tui_builder::TuiUiBuilder; + +const INFRA_RUN_ID: &str = "00000000-0000-0000-0000-000000000001"; +const UI_RUN_ID: &str = "00000000-0000-0000-0000-000000000002"; +const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000003"; + +/// Registers the appearance and history models needed by the renderer. +fn register_models(app: &mut App) { + register_tui_session_view_test_singletons(app); +} + +/// Creates one parent conversation for child-message tests. +fn add_parent(app: &mut App) -> AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); + let conversation_id = + history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + conversation_id, + PARENT_RUN_ID.to_string(), + None, + surface_id, + ctx, + ); + conversation_id + }) + }) +} + +/// Creates a named child with a sender run ID and lifecycle status. +fn add_child( + app: &mut App, + parent_id: AIConversationId, + name: &str, + run_id: &str, + status: ConversationStatus, +) -> AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); + let conversation_id = + history.start_new_conversation(surface_id, false, false, false, ctx); + let conversation = history + .conversation_mut(&conversation_id) + .expect("child conversation was just created"); + conversation.set_agent_name(name.to_owned()); + history.set_parent_for_conversation(conversation_id, parent_id); + history.assign_run_id_for_conversation( + conversation_id, + run_id.to_owned(), + None, + surface_id, + ctx, + ); + history.update_conversation_status(surface_id, conversation_id, status, ctx); + conversation_id + }) + }) +} + +#[test] +fn parent_sender_renders_as_orchestrator_in_child_transcript() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + let child_id = add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message(PARENT_RUN_ID, "instruction", "Hi from the orchestrator"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &received, child_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let header = frame.buffer.to_lines()[0].clone(); + assert!(header.contains("Orchestrator"), "{header}"); + assert!(!header.contains("Unknown agent"), "{header}"); + }); + }); +} + +/// Builds one received message payload. +fn message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + +#[test] +fn conversation_statuses_render_expected_glyphs() { + assert_eq!( + conversation_status_glyph(&ConversationStatus::InProgress), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::TransientError), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::WaitingForEvents), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::Blocked { + blocked_action: "approval".to_owned(), + }), + "■" + ); + assert_eq!(conversation_status_glyph(&ConversationStatus::Success), "✓"); + assert_eq!(conversation_status_glyph(&ConversationStatus::Error), "×"); + assert_eq!( + conversation_status_glyph(&ConversationStatus::Cancelled), + "■" + ); +} + +#[test] +fn running_child_message_matches_the_design_layout_and_styles() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "infrastructure-bot", + INFRA_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let message = message(INFRA_RUN_ID, "progress", "Starting to build infrastructure"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &message, parent_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let lines = frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect::>(); + assert!(lines[0].starts_with("● ")); + assert!(lines[0].contains(" infrastructure-bot ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting to build"))); + + let builder = TuiUiBuilder::from_app(ctx); + assert_eq!( + frame.buffer[(0, 0)].fg, + builder + .attention_glyph_style() + .fg + .expect("running status has a foreground") + ); + assert_eq!(frame.buffer[(2, 0)].fg, frame.buffer[(4, 0)].fg); + assert!(frame.buffer[(4, 0)].modifier.contains(Modifier::BOLD)); + + states.set_collapsed(agent_message_section_id(&message), false); + let expanded = presenter.present_element( + render_agent_message(&states, &message, parent_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + assert!(expanded.buffer.to_lines()[0].contains(" ▾")); + assert_eq!( + expanded.buffer.to_lines()[1].trim_end(), + " Starting to build infrastructure" + ); + assert_eq!( + expanded.buffer[(4, 1)].fg, + builder + .muted_text_style() + .fg + .expect("muted text has a foreground") + ); + }); + }); +} + +#[test] +fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::Success, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message( + UI_RUN_ID, + "progress", + "Starting to implement a responsive interface", + ); + states.set_collapsed(agent_message_section_id(&received), false); + let mut presenter = TuiPresenter::new(); + let wrapped = presenter.present_element( + render_agent_message(&states, &received, parent_id, ctx), + TuiRect::new(0, 0, 24, 4), + ctx, + ); + let wrapped_lines = wrapped + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect::>(); + assert!(wrapped_lines[0].starts_with("✓ ")); + assert!(wrapped_lines[1..] + .iter() + .all(|line| line.starts_with(" "))); + + let fallback = presenter.present_element( + render_agent_message( + &states, + &message(UI_RUN_ID, "Finished verification", " "), + parent_id, + ctx, + ), + TuiRect::new(0, 0, 40, 2), + ctx, + ); + assert_eq!( + fallback.buffer.to_lines()[1].trim_end(), + " Finished verification" + ); + }); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 2215ea16aa7..45eae6f7cfa 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod agent_message; mod alt_screen_view; mod autoupdate; mod clipboard; diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs index 736d848bdc7..07a09655a1d 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -18,6 +18,15 @@ pub(crate) struct AgentIdentity { pub(crate) style: TuiStyle, } +impl Default for AgentIdentity { + fn default() -> Self { + Self { + glyph: "⟡", + style: TuiStyle::default(), + } + } +} + /// Builds the identity palette from the seven color roles in the design: /// themed cyan, blue, magenta, lilac, pink, green, and yellow. Lilac uses /// bright magenta while the remaining roles use their normal ANSI slots. diff --git a/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index a7656c322ff..1b4c81fba30 100644 --- a/crates/warp_tui/src/tool_call_labels.rs +++ b/crates/warp_tui/src/tool_call_labels.rs @@ -3,7 +3,6 @@ use std::path::Path; -pub(crate) use ai::agent::document_action_presentation::ToolCallDisplayState; use warp::tui_export::{ AIActionStatus, AIAgentAction, AIAgentActionResultType, AIAgentActionType, AskUserQuestionResult, FileGlobV2Result, GrepResult, RequestCommandOutputResult, @@ -11,8 +10,10 @@ use warp::tui_export::{ StartAgentExecutionMode, SuggestNewConversationResult, }; use warp_core::command::ExitCode; +use warpui_core::elements::tui::TuiStyle; use self::ToolCallDisplayState as State; +use crate::tui_builder::TuiUiBuilder; /// Ground-truth state of the terminal block backing a shell-command tool /// call, resolved by the caller. When a block exists, its state supersedes @@ -43,6 +44,56 @@ pub(crate) struct ResolvedCommandBlock { /// so tool-call rows stay scannable one-liners. const MAX_INLINE_LEN: usize = 80; +/// Coarse presentation state for a tool call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolCallDisplayState { + /// The tool call's arguments are still streaming and may be incomplete. + Constructing, + /// The tool call is waiting to begin execution. + Pending, + /// The tool call is blocked on user confirmation. + Blocked, + /// The tool call is executing asynchronously. + Running, + Succeeded, + Failed, + Cancelled, +} + +impl ToolCallDisplayState { + /// The compact leading glyph for this state. + pub(crate) fn glyph(self) -> &'static str { + match self { + Self::Constructing | Self::Pending => "○", + Self::Blocked | Self::Cancelled => "■", + Self::Running => "●", + Self::Succeeded => "✓", + Self::Failed => "×", + } + } + + /// The semantic theme style for this state's glyph. + pub(crate) fn glyph_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running => builder.attention_glyph_style(), + Self::Succeeded => builder.success_glyph_style(), + Self::Failed => builder.error_text_style(), + Self::Cancelled => builder.muted_text_style(), + } + } + + /// The semantic text style paired with this state. + pub(crate) fn label_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running | Self::Succeeded | Self::Failed | Self::Cancelled => { + builder.primary_text_style() + } + } + } +} + /// Collapses an optional action status into the coarse display state. /// `output_streaming` is whether the exchange output is still streaming; /// a status-less action in a streaming output is still being constructed @@ -72,7 +123,7 @@ pub(crate) fn tool_call_display_state( match status { None if output_streaming => State::Constructing, None | Some(AIActionStatus::Preprocessing | AIActionStatus::Queued) => State::Pending, - Some(AIActionStatus::Blocked) => State::AwaitingApproval, + Some(AIActionStatus::Blocked) => State::Blocked, Some(AIActionStatus::RunningAsync) => State::Running, Some(finished @ AIActionStatus::Finished(_)) => { if finished.is_cancelled() { @@ -86,21 +137,6 @@ pub(crate) fn tool_call_display_state( } } -/// The leading status glyph for a tool-call row; the caller colors it to -/// mirror the GUI's inline action icons (`action_icon` in the GUI's -/// `output.rs`): grey circle while pending, yellow block awaiting approval, -/// yellow dot running, green check on success, red x on failure, grey block -/// on cancellation. -pub(crate) fn tool_call_glyph(state: ToolCallDisplayState) -> &'static str { - match state { - State::Constructing | State::Pending => "○", - State::AwaitingApproval | State::Cancelled => "■", - State::Running => "●", - State::Succeeded => "✓", - State::Failed => "×", - } -} - /// Returns the one-line transcript label for a tool call in its current state. pub(crate) fn tool_call_label( action: &AIAgentAction, @@ -114,7 +150,7 @@ pub(crate) fn tool_call_label( .map(|result| &result.result); let label = label_for_action(&action.action, state, result, block); match state { - State::AwaitingApproval => format!("{label} (awaiting approval)"), + State::Blocked => format!("{label} (awaiting approval)"), State::Constructing | State::Pending | State::Running @@ -133,7 +169,7 @@ pub(crate) fn tool_call_label( /// view's "Generating command..."). fn label_for_action( action: &AIAgentActionType, - state: ToolCallDisplayState, + state: State, result: Option<&AIAgentActionResultType>, block: Option<&ResolvedCommandBlock>, ) -> String { @@ -149,7 +185,7 @@ fn label_for_action( let cmd = single_line(executed.unwrap_or(command)); match state { State::Constructing => "Generating command…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Run `{cmd}`"), + State::Pending | State::Blocked => format!("Run `{cmd}`"), State::Running => format!("Running `{cmd}`"), State::Succeeded => match block_state { Some(CommandBlockState::Finished { .. }) => format!("Ran `{cmd}`"), @@ -182,7 +218,7 @@ fn label_for_action( } AIAgentActionType::WriteToLongRunningShellCommand { .. } => match state { State::Constructing => "Writing command input…".to_owned(), - State::Pending | State::AwaitingApproval => "Write input to running command".to_owned(), + State::Pending | State::Blocked => "Write input to running command".to_owned(), State::Running => "Writing input to running command…".to_owned(), State::Succeeded => "Wrote input to running command".to_owned(), State::Failed => "Failed to write to running command".to_owned(), @@ -192,7 +228,7 @@ fn label_for_action( let files = files_summary(request.locations.iter().map(|location| &location.name)); match state { State::Constructing => "Reading files…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {files}") } State::Running => format!("Reading {files}"), @@ -204,7 +240,7 @@ fn label_for_action( let file = single_line(&request.file_path); match state { State::Constructing => "Preparing upload…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Upload {file}"), + State::Pending | State::Blocked => format!("Upload {file}"), State::Running => format!("Uploading {file}"), State::Succeeded => format!("Uploaded {file}"), State::Failed => format!("Upload of {file} failed"), @@ -220,7 +256,7 @@ fn label_for_action( .unwrap_or_default(); match state { State::Constructing => "Searching codebase…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Search for \"{query}\"{scope}") } State::Running => format!("Searching for \"{query}\"{scope}"), @@ -263,7 +299,7 @@ fn label_for_action( let path = display_path(path); match state { State::Constructing => "Grepping…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Grep for {queries} in {path}") } State::Running => format!("Grepping for {queries} in {path}"), @@ -304,7 +340,7 @@ fn label_for_action( // GUI's "Reading \"{name}\" MCP resource..." loading text. State::Constructing if name.is_empty() => "Reading MCP resource…".to_owned(), State::Constructing => format!("Reading \"{name}\" MCP resource…"), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read MCP resource {resource}") } State::Running => format!("Reading MCP resource {resource}"), @@ -319,7 +355,7 @@ fn label_for_action( // text; the tool name is available before its args finish. State::Constructing if name.is_empty() => "Calling MCP tool…".to_owned(), State::Constructing => format!("Calling \"{name}\" MCP tool…"), - State::Pending | State::AwaitingApproval => format!("Call MCP tool {name}"), + State::Pending | State::Blocked => format!("Call MCP tool {name}"), State::Running => format!("Calling MCP tool {name}"), State::Succeeded => format!("Called MCP tool {name}"), State::Failed => format!("MCP tool {name} failed"), @@ -328,7 +364,7 @@ fn label_for_action( } AIAgentActionType::SuggestNewConversation { .. } => match state { State::Constructing => "Suggesting a new conversation…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Failed => { + State::Pending | State::Blocked | State::Running | State::Failed => { "Suggested starting a new conversation".to_owned() } State::Succeeded => match result { @@ -346,7 +382,7 @@ fn label_for_action( let documents = count_label(request.document_ids.len(), "document", "documents"); match state { State::Constructing => "Reading documents…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {documents}") } State::Running => format!("Reading {documents}"), @@ -355,7 +391,7 @@ fn label_for_action( } } AIAgentActionType::EditDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Update plan".to_owned(), + State::Pending | State::Blocked => "Update plan".to_owned(), State::Constructing | State::Running => "Updating plan…".to_owned(), State::Succeeded => format!( "Updated plan ({})", @@ -365,7 +401,7 @@ fn label_for_action( State::Cancelled => "Update plan cancelled".to_owned(), }, AIAgentActionType::CreateDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Create plan".to_owned(), + State::Pending | State::Blocked => "Create plan".to_owned(), State::Constructing | State::Running => "Generating plan…".to_owned(), State::Succeeded => { let count = request.documents.len(); @@ -379,9 +415,7 @@ fn label_for_action( State::Cancelled => "Create plan cancelled".to_owned(), }, AIAgentActionType::ReadShellCommandOutput { .. } => match state { - State::Pending | State::AwaitingApproval | State::Succeeded => { - "Read command output".to_owned() - } + State::Pending | State::Blocked | State::Succeeded => "Read command output".to_owned(), State::Constructing | State::Running => "Reading command output…".to_owned(), State::Failed => "Failed to read command output".to_owned(), State::Cancelled => "Read command output cancelled".to_owned(), @@ -391,7 +425,7 @@ fn label_for_action( let comments = count_label(comments.len(), "review comment", "review comments"); match state { State::Constructing => "Preparing review comments…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Insert {comments}"), + State::Pending | State::Blocked => format!("Insert {comments}"), State::Running => format!("Inserting {comments}…"), State::Succeeded => format!("Inserted {comments}"), State::Failed => "Failed to insert review comments".to_owned(), @@ -402,14 +436,14 @@ fn label_for_action( summary_label(&request.task_summary, state) } AIAgentActionType::StartRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Start recording".to_owned(), + State::Pending | State::Blocked => "Start recording".to_owned(), State::Constructing | State::Running => "Starting recording…".to_owned(), State::Succeeded => "Started screen recording".to_owned(), State::Failed => "Recording failed to start".to_owned(), State::Cancelled => "Start recording cancelled".to_owned(), }, AIAgentActionType::StopRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Stop recording".to_owned(), + State::Pending | State::Blocked => "Stop recording".to_owned(), State::Constructing | State::Running => "Stopping recording…".to_owned(), State::Succeeded => "Saved screen recording".to_owned(), State::Failed => "Failed to save recording".to_owned(), @@ -419,7 +453,7 @@ fn label_for_action( let skill = single_line(&request.skill.display_label()); match state { State::Constructing => "Reading skill…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read skill {skill}") } State::Running => format!("Reading skill {skill}"), @@ -428,7 +462,7 @@ fn label_for_action( } } AIAgentActionType::FetchConversation { .. } => match state { - State::Pending | State::AwaitingApproval => "Fetch conversation".to_owned(), + State::Pending | State::Blocked => "Fetch conversation".to_owned(), State::Constructing | State::Running => "Fetching conversation…".to_owned(), State::Succeeded => "Fetched conversation".to_owned(), State::Failed => "Fetch conversation failed".to_owned(), @@ -446,7 +480,7 @@ fn label_for_action( }; match state { State::Constructing => "Configuring agent…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Start {agent}"), + State::Pending | State::Blocked => format!("Start {agent}"), State::Running => format!("Starting {agent}…"), State::Succeeded => format!("Started agent {name}"), State::Failed => format!("Failed to start agent {name}"), @@ -459,7 +493,7 @@ fn label_for_action( let subject = single_line(subject); match state { State::Constructing => "Composing message…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Send message: {subject}"), + State::Pending | State::Blocked => format!("Send message: {subject}"), State::Running => format!( "Sending message to {}: {subject}", count_label(addresses.len(), "agent", "agents") @@ -471,7 +505,7 @@ fn label_for_action( } AIAgentActionType::TransferShellCommandControlToUser { reason } => match state { State::Constructing => "Handing control to you…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => { + State::Pending | State::Blocked | State::Running => { format!("Handing control to you: {}", single_line(reason)) } State::Succeeded => "You are in control".to_owned(), @@ -480,7 +514,7 @@ fn label_for_action( }, AIAgentActionType::AskUserQuestion { questions } => match state { State::Constructing => "Preparing question…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => format!( + State::Pending | State::Blocked | State::Running => format!( "Asking {}", count_label(questions.len(), "question", "questions") ), @@ -511,7 +545,7 @@ fn label_for_action( AIAgentActionType::RunAgents(request) => { let total = request.agent_run_configs.len(); match state { - State::Constructing | State::Pending | State::AwaitingApproval => { + State::Constructing | State::Pending | State::Blocked => { "Configuring agents…".to_owned() } State::Running => { @@ -554,7 +588,7 @@ fn label_for_action( } } AIAgentActionType::WaitForEvents { .. } => match state { - State::Constructing | State::Pending | State::AwaitingApproval | State::Running => { + State::Constructing | State::Pending | State::Blocked | State::Running => { "Waiting for agent events…".to_owned() } State::Succeeded => "Done waiting for agent events".to_owned(), @@ -569,14 +603,14 @@ fn label_for_action( fn file_glob_label( patterns: &[String], path: Option<&str>, - state: ToolCallDisplayState, + state: State, matched_count: Option, ) -> String { let patterns = single_line(&patterns.join(", ")); let path = display_path(path.unwrap_or(".")); match state { State::Constructing => "Finding files…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Find files matching {patterns} in {path}") } State::Running => format!("Finding files matching {patterns} in {path}"), @@ -595,11 +629,11 @@ fn file_glob_label( /// Labels computer-use calls with their agent-supplied summary, marking only /// terminal non-success states (matching the GUI, which shows the summary /// verbatim). -fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { +fn summary_label(summary: &str, state: State) -> String { let summary = single_line(summary); match state { State::Constructing => "Preparing computer use…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Succeeded => summary, + State::Pending | State::Blocked | State::Running | State::Succeeded => summary, State::Failed => format!("{summary} — failed"), State::Cancelled => format!("{summary} — cancelled"), } @@ -607,10 +641,10 @@ fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { /// Generic label for action types without bespoke text, derived from the /// action's user-friendly name. -fn fallback_label(action: &AIAgentActionType, state: ToolCallDisplayState) -> String { +fn fallback_label(action: &AIAgentActionType, state: State) -> String { let name = action.user_friendly_name(); match state { - State::Pending | State::AwaitingApproval => name, + State::Pending | State::Blocked => name, State::Constructing | State::Running => format!("{name}…"), State::Succeeded => format!("{name} — done"), State::Failed => format!("{name} — failed"), diff --git a/crates/warp_tui/src/tool_call_labels_tests.rs b/crates/warp_tui/src/tool_call_labels_tests.rs index 7db35df1ec6..a2ab0ad6d06 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,7 +6,10 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use super::{tool_call_label, CommandBlockState, ResolvedCommandBlock}; +use super::{ + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, + ToolCallDisplayState, +}; /// Builds a `Finished` status wrapping the given result. fn finished(result: AIAgentActionResultType) -> AIActionStatus { @@ -43,6 +46,26 @@ fn command_action(command: &str) -> AIAgentAction { } } +#[test] +fn tool_call_statuses_map_to_tool_call_display_states() { + assert_eq!( + tool_call_display_state(None, true, None), + ToolCallDisplayState::Constructing + ); + assert_eq!( + tool_call_display_state(None, false, None), + ToolCallDisplayState::Pending + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), + ToolCallDisplayState::Blocked + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), + ToolCallDisplayState::Running + ); +} + /// One end-to-end pass over a tool call's lifecycle: the label text must /// change as the action moves through constructing (args still streaming), /// pending, awaiting approval, running, and terminal states. diff --git a/crates/warp_tui/src/tui_file_edits_view.rs b/crates/warp_tui/src/tui_file_edits_view.rs index 102ef174b42..bdbca9930f5 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -36,9 +36,8 @@ use warpui_core::elements::tui::{ use warpui_core::elements::MouseStateHandle; use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; -use crate::agent_block_sections::{tool_call_glyph_style, tool_call_label_style}; use crate::editor_element::{TuiEditorElement, TuiEditorStyles}; -use crate::tool_call_labels::{tool_call_display_state, tool_call_glyph, ToolCallDisplayState}; +use crate::tool_call_labels::{tool_call_display_state, ToolCallDisplayState}; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -364,13 +363,13 @@ impl TuiFileEditsView { let state = self.display_state(app); // State lives in the glyph, mirroring `render_tool_call_section`. - let glyph_style = tool_call_glyph_style(state, builder); - let name_style = tool_call_label_style(state, builder); + let glyph_style = state.glyph_style(builder); + let name_style = state.label_style(builder); let bold = |style: TuiStyle| style.add_modifier(Modifier::BOLD); let embolden = |style: TuiStyle| if hovered { bold(style) } else { style }; let mut spans = vec![ - (format!("{} ", tool_call_glyph(state)), glyph_style), + (format!("{} ", state.glyph()), glyph_style), (label.to_owned(), embolden(bold(name_style))), ]; if let Some((added, removed)) = line_stats { diff --git a/crates/warp_tui/src/tui_plan_view.rs b/crates/warp_tui/src/tui_plan_view.rs index ce3533fd2f1..e1449542282 100644 --- a/crates/warp_tui/src/tui_plan_view.rs +++ b/crates/warp_tui/src/tui_plan_view.rs @@ -17,9 +17,8 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block_sections::tool_call_glyph_style; use crate::keybindings::plan_toggle_hint; -use crate::tool_call_labels::{tool_call_display_state, tool_call_glyph, ToolCallDisplayState}; +use crate::tool_call_labels::{tool_call_display_state, ToolCallDisplayState}; use crate::tui_builder::TuiUiBuilder; use crate::tui_code_block_view::{TuiCodeBlockPayload, TuiCodeBlockView, TuiCodeBlockViewEvent}; use crate::tui_markdown::{render_formatted_text, TuiMarkdownBlockHooks, TuiMarkdownPalette}; @@ -215,7 +214,7 @@ impl TuiPlanView { ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { ("Creating ", Some(self.document_subject())) } - ToolCallDisplayState::Pending | ToolCallDisplayState::AwaitingApproval => { + ToolCallDisplayState::Pending | ToolCallDisplayState::Blocked => { ("Create plan", None) } ToolCallDisplayState::Succeeded => ("Created ", Some(self.document_subject())), @@ -231,7 +230,7 @@ impl TuiPlanView { ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { ("Updating plan", None) } - ToolCallDisplayState::Pending | ToolCallDisplayState::AwaitingApproval => { + ToolCallDisplayState::Pending | ToolCallDisplayState::Blocked => { ("Update plan", None) } ToolCallDisplayState::Succeeded => ("Updated plan", None), @@ -314,10 +313,7 @@ impl TuiView for TuiPlanView { let header_style = builder.primary_text_style().add_modifier(Modifier::BOLD); let (label, subject) = self.header_label(state); let mut header = vec![ - ( - format!("{} ", tool_call_glyph(state)), - tool_call_glyph_style(state, &builder), - ), + (format!("{} ", state.glyph()), state.glyph_style(&builder)), (label.to_owned(), header_style), ]; if let Some(subject) = subject { diff --git a/crates/warp_tui/src/tui_shell_command_view.rs b/crates/warp_tui/src/tui_shell_command_view.rs index 79edbb7ba7d..f232c62f097 100644 --- a/crates/warp_tui/src/tui_shell_command_view.rs +++ b/crates/warp_tui/src/tui_shell_command_view.rs @@ -22,14 +22,11 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block_sections::{ - render_fallback_tool_call_section, tool_call_glyph_style, tool_call_label_style, -}; +use crate::agent_block_sections::render_fallback_tool_call_section; use crate::terminal_block::TerminalBlockElement; use crate::terminal_use::user_controls_running_command; use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, CommandBlockState, - ResolvedCommandBlock, + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, }; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::{TuiCLISubagentView, TuiCLISubagentViewEvent}; @@ -258,15 +255,15 @@ impl TuiView for TuiShellCommandView { let builder = TuiUiBuilder::from_app(app); let display_state = tool_call_display_state(status.as_ref(), false, Some(block.details.state)); - let glyph_style = tool_call_glyph_style(display_state, &builder); - let mut label_style = tool_call_label_style(display_state, &builder); + let glyph_style = display_state.glyph_style(&builder); + let mut label_style = display_state.label_style(&builder); if self.header_mouse_state.lock().unwrap().is_hovered() { label_style = label_style.add_modifier(Modifier::BOLD); } let collapsed = self.state.is_collapsed() && !self.user_controls_command(); let label = tool_call_label(&self.action, status.as_ref(), false, Some(&block.details)); let header_spans = vec![ - (format!("{} ", tool_call_glyph(display_state)), glyph_style), + (format!("{} ", display_state.glyph()), glyph_style), (format!("{label} "), label_style), ]; diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 21cd6d6f56d..7a0131caae4 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -20,14 +20,26 @@ The frontend-neutral edit state, option snapshots, and the reusable selector thi Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availability.rs`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/harness_availability.rs)), `LLMPreferences`, `CloudAmbientAgentEnvironment`, `ConnectedSelfHostedWorkersModel`, `CloudAgentSettings`, `UserWorkspaces`. ### TUI plumbing -- [`crates/warp_tui/src/agent_block.rs (105-306) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/agent_block.rs#L105-L306) — `TuiToolCallView` enum plus `sync_action_views`, the lazy per-action child-view registration seam (currently `FileEdits`, `ShellCommand`). +- `crates/warp_tui/src/agent_block.rs` — `TuiToolCallView` plus `sync_action_views`, the lazy per-action child-view registration seam for `FileEdits`, `ShellCommand`, and `OrchestrationBlock`. - [`crates/warp_tui/src/terminal_session_view.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/terminal_session_view.rs) — renders transcript, inline menu, input box, footer; focuses the input at startup (620) and after restore flows (808, 839, 867). - [`crates/warp_tui/src/inline_menu.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/inline_menu.rs) — `TuiInlineMenuHandle`/`TuiInlineMenuSnapshot`; scroll/selection math shared with GUI via `warp_search_core::inline_menu::InlineMenuSelection`. - [`crates/warp_tui/src/tool_call_labels.rs (503-577) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tool_call_labels.rs#L503-L577) — existing static RunAgents status labels (kept for restored/terminal fallbacks). - [`crates/warp_tui/src/tui_builder.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tui_builder.rs) — `TuiUiBuilder` theme→style recipes; all colors derive from `WarpTheme`, no raw hex. - [`app/src/tui_export.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/tui_export.rs) — the sole `warp` → `warp_tui` export seam. -There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. +The `RunAgents` permission card is registered as a stateful `TuiAIBlock` child view. Session input replacement is derived from the front-of-queue blocker rather than stored as a separate suppression flag, so draft input state remains owned by the normal input view. + +### Local child runtime and participant identity (later stack layers) +The permission card is followed by three runtime layers: +- [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md) introduces `TuiSessions`, retaining a complete view and terminal manager for each focused or background terminal surface. +- [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md) introduces `TuiOrchestrationModel`, which materializes native local Oz children as background TUI sessions and owns only session/event-consumer runtime mappings. +- The rich-message change on top renders `MessagesReceivedFromAgents` using frontend-neutral participant discovery shared with the GUI. + +Incoming `ReceivedMessageDisplay` values carry a server-side sender run id, not a display name, status, or local conversation id. `BlocklistAIHistoryModel` already owns the durable data needed to interpret that id: the run-id reverse index, loaded conversations, immediate-parent links, parent-to-children index, participant names, and `ConversationStatus`. `app/src/ai/blocklist/orchestration_topology.rs` therefore owns the shared semantic bridge: +- `orchestrator_agent_id_for_conversation` resolves the current conversation's immediate parent agent. +- `resolve_orchestration_participant` maps the sender run id to role, local conversation id, and display name through the history index. + +This resolution is a one-parent lookup plus an indexed agent-id lookup, not a second graph traversal. `TuiOrchestrationModel` remains an ephemeral session materializer so restored, remote, or otherwise pre-existing conversations do not require duplicated participant metadata in the TUI coordinator. GUI and TUI apply their own presentation after the shared semantic result is resolved. ## Proposed changes ### 1. TUI orchestration block `crates/warp_tui/src/orchestration_block.rs` @@ -63,26 +75,47 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. +### 5. Full-view sessions and local child materialization +`TuiSessions` replaces the single-session root with a registry that retains focused and background `TuiTerminalSessionView`s. The root renders and routes input only to the focused session. `TuiOrchestrationModel` subscribes to every registered session's `StartAgentExecutor`, including child sessions, so nested local Oz children can be materialized without putting background views into the render or responder chain. + +The coordinator uses the shared local-launch helpers, creates the child's background session, establishes conversation lineage in `BlocklistAIHistoryModel`, applies inherited and requested model settings, registers event consumers, and submits the first prompt. Its state is limited to child-conversation → session and session → event-consumer ownership; conversation topology and participant metadata are not mirrored. + +### 6. Rich orchestration transcript messages +`TuiAIBlockSection::AgentMessage` preserves each received message payload. `agent_message.rs` resolves the current conversation's immediate orchestrator and the sender through the shared history/topology API, then applies TUI-only presentation: +- direct `ConversationStatus` glyph/style, +- deterministic sibling-based identity color and glyph, +- bold participant name, +- collapsed-by-default body with subject fallback and hanging indentation. +Opaque `EventsFromAgents` ids render no transcript row. Tool calls keep a separate `ToolCallDisplayState` because constructing and pending are tool-call states, not conversation lifecycle states. ## Testing and validation Focused unit coverage: - `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. - `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. -- `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. +- `orchestrated_agent_identity_styling_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. - `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. +- `orchestration_topology_tests.rs` covers shared participant resolution and immediate-parent semantics for nested agents. +- `agent_message_tests.rs` covers orchestrator/agent labels, direct conversation-status presentation, identity styling, collapse/expand behavior, wrapping, and subject fallback. +- `agent_block_tests.rs` covers rich-message section extraction, omission of opaque lifecycle ids and `WaitForEvents`, zero-height rendering for hidden-only exchanges, and block-owned collapse state. +- `tool_call_labels_tests.rs` independently covers tool-call-only presentation states. Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. ## Orchestration -The work ships as a four-PR Graphite stack, each mergeable on its own: -1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). -2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). -3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). -4. The final PR (this spec's remaining scope) — the TUI orchestration block, generalized input replacement, theming, and agent identity, reviewed against the PRODUCT invariants. +The implementation ships as a Graphite stack whose layers remain independently reviewable: +1. `harry/code-1822-generic-editor-view` — reusable TUI editor view; specified in [specs/code-1822-tui-generic-editor-view/TECH.md](../code-1822-tui-generic-editor-view/TECH.md). +2. `harry/code-1822-tui-option-selector` — reusable `TuiOptionSelector`; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). +3. `harry/code-1822-tui-orchestration-card` — permission/configuration card, input replacement, theming, and request identity. +4. `harry/code-1822-tui-multi-session` — retained full-view session registry; specified in [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md). +5. `harry/code-1822-tui-local-children` — native local Oz child materialization; specified in [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md). +6. `harry/code-1822-rich-child-message-rendering` — shared participant resolution and rich received-message rows. +7. `harry/code-1822-tui-tab-bar-component` and `harry/code-1822-orchestration-tab-bar` — child-session navigation and orchestration-specific tab presentation. +8. `harry/code-1822-cloud-agent-orchestration` — remote/cloud child materialization. ## Risks and mitigations - Catalog events arriving mid-configuration can reshape option lists — the selector preserves the selected id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. - Focus derivation vs. event ordering: `SpawningStarted` must flip `wants_focus` before the next render; both arrive through the same entity-event loop, and the render-time derivation (not cached state) makes late events self-correcting. - Theme switches would rebuild the identity palette; the card pins its palette at construction so in-flight requests keep stable identities, at the cost of using pre-switch colors until the next request. +- Participant lookup depends on history indexes being updated through canonical history-model mutation APIs. Tests and runtime launch paths use those APIs rather than adding render-time scans or mirroring participant state in `TuiOrchestrationModel`. diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 6bf0a95d383..f327e8401f1 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -1,7 +1,9 @@ -# TECH: `TuiOrchestrationModel` + background local child agents +# TECH: TUI local child agents and rich orchestration messages This change builds on the full-view `TuiSessions` container. Accepting a local `run_agents` request in the TUI creates native Oz children in background terminal sessions while the parent remains focused and receives orchestration traffic. +Received messages render as rich, collapsible participant rows in both parent +and child transcripts. ## Architecture ### Shared local Oz launch contract The GUI and TUI share the frontend-neutral parts of native child launch through @@ -63,8 +65,36 @@ The coordinator stores only orchestration runtime bookkeeping (`crates/warp_tui/src/orchestration_model.rs (31-39)`): - `child_session_by_conversation` maps a child conversation to its background session. - `event_consumers_by_session` records which conversation streams each live session consumes. -Conversation lineage remains canonical in `BlocklistAIHistoryModel`. Removing a -conversation also removes its id from `children_by_parent` +`TuiOrchestrationModel` intentionally does not duplicate participant names, +statuses, agent-id indexes, or ancestry. It is an ephemeral materializer for +TUI sessions; restored conversations and participants created by other +frontends can exist without passing through it. + +Conversation identity and lineage remain canonical in +`BlocklistAIHistoryModel`: +- `agent_id_to_conversation_id` resolves the server-side run id carried by an + incoming message to the loaded `AIConversation`. +- `parent_conversation_id` / `parent_agent_id` identify the current + conversation's immediate orchestrator. +- `children_by_parent` provides sibling order for deterministic TUI identity + styling. +- `AIConversation` owns the participant's display name and + `ConversationStatus`. + +`app/src/ai/blocklist/orchestration_topology.rs` exposes the semantic resolution +shared by GUI and TUI: +- `orchestrator_agent_id_for_conversation` resolves only the immediate parent, + with `parent_agent_id` as the fallback when the parent conversation is not + loaded. +- `resolve_orchestration_participant` uses the history model's reverse index to + return the participant role, local conversation id, and display name. + +This is not a second orchestration graph or a full-tree traversal. It bridges +the two identifiers available at render time: the current local conversation +id and `ReceivedMessageDisplay::sender_agent_id`. Frontends project the shared +semantic result into their own presentation: the GUI chooses an avatar and +navigation behavior, while the TUI chooses a terminal glyph/color identity. +Removing a conversation also removes its id from `children_by_parent` (`app/src/ai/blocklist/history_model.rs (2112-2182)`). ### Unsupported modes and failed launch cleanup Local CLI-harness and remote requests resolve as explicit per-child failures @@ -79,39 +109,74 @@ and echoes its id to `StartAgentExecutor`. The resulting cleanup event: - unregisters consumers when `TuiSessions` reports the removal. This leaves no dead child conversation, session, or streamer registration. ### Transcript rendering -`crates/warp_tui/src/agent_block.rs (775-888)`: +`crates/warp_tui/src/agent_block.rs`: - suppresses the `WaitForEvents` tool-call row, matching the GUI, -- renders sender and subject for each `MessagesReceivedFromAgents` entry, and -- renders the number of received lifecycle events for `EventsFromAgents`. -Lifecycle output currently contains event ids rather than event details, so the -TUI intentionally renders a count rather than a sender/status transition. +- preserves every `MessagesReceivedFromAgents` payload as an `AgentMessage` + section, and +- omits `EventsFromAgents` rows because those outputs contain opaque ids rather + than displayable participant or lifecycle data. + +`crates/warp_tui/src/agent_message.rs` owns TUI presentation: +1. Resolve the current conversation's immediate orchestrator through the + shared topology helper. +2. Resolve the sender run id through `BlocklistAIHistoryModel`. +3. Read the sender's display name and `ConversationStatus` from the resolved + conversation. +4. Assign a deterministic TUI identity from the sender's sibling order; use a + stable sender-id hash only when no loaded sibling relationship exists. +5. Render a collapsed-by-default row containing the conversation-status glyph, + participant identity glyph, bold name, and disclosure chevron. Expansion + shows the message body with a hanging indent, falling back to the subject + when the body is blank. + +Conversation rows use `ConversationStatus` directly. Tool calls retain the +separate `ToolCallDisplayState` because constructing and pending tool calls are +not conversation lifecycle states. Both use the same semantic +`TuiUiBuilder` color recipes without forcing their domain models into one enum. ## Exports `app/src/tui_export.rs (52-75)` exposes the shared child-launch functions and prepared result plus the `StartAgentExecutor` request/event/outcome types needed -by the TUI surface bridge. Server-client and execution-profile implementation -types remain behind the shared launch API. +by the TUI surface bridge. It also exports the frontend-neutral participant +resolution functions and result types from `orchestration_topology`. GUI +elements, TUI styles, server-client types, and execution-profile implementation +types remain behind their respective boundaries. ## Non-goals - Local CLI-harness children (Claude, Codex, OpenCode, Gemini). - Remote/cloud child materialization. - Navigation to or revealing background child sessions. - Removing completed child sessions; successful children remain retained like GUI hidden panes. -- Rich message bodies and per-event lifecycle status rendering. +- Rendering opaque lifecycle event ids as transcript content. ## Testing and validation - `crates/warp_tui/src/orchestration_model_tests.rs (154-221)` verifies that local-harness and remote requests resolve with explicit failures while leaving no child topology, extra session, or event-consumer state. It also verifies that failed-launch cleanup preserves unrelated retained sessions. -- `crates/warp_tui/src/agent_block_tests.rs (290-362)` renders orchestration messages and lifecycle - counts while asserting that `WaitForEvents` contributes no tool row. +- `app/src/ai/blocklist/orchestration_topology_tests.rs` verifies shared + participant discovery and that a grandchild resolves its direct parent, + rather than the tree root, as orchestrator. +- `crates/warp_tui/src/agent_message_tests.rs` verifies parent/orchestrator + labeling, direct `ConversationStatus` glyphs and styles, deterministic child + identity presentation, collapse behavior, wrapping, and subject fallback. +- `crates/warp_tui/src/agent_block_tests.rs` verifies that received messages + remain distinct rich sections, opaque lifecycle ids render no row, + `WaitForEvents` contributes no tool row, hidden-only exchanges reserve no + whitespace, and collapse state is owned by the agent block. +- `crates/warp_tui/src/tool_call_labels_tests.rs` keeps tool-call-only + constructing, pending, blocked, running, and terminal presentation covered + independently of conversation lifecycle state. - `app/src/ai/llms_tests.rs (936-1035)` verifies explicit child pins preserve GUI pane behavior, suppress redundant model-change events, and precede the TUI file-backed default. - `app/src/ai/blocklist/history_model_tests.rs (1930-1991)` verifies that removing a conversation cleans both its incoming child reference and its outgoing parent index. -- `cargo check -p warp_tui` passes. -- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` passes. -- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` passes. + +Validation commands: +- `cargo check -p warp_tui` +- `cargo nextest run -p warp_tui` +- `cargo nextest run -p warp -E 'test(orchestration_topology)'` +- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` +- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` +- `./script/format` ## Follow-ups - Add local CLI-harness children by reusing the existing local-harness preparation path. - Add a TUI-native remote child materializer. -- Add richer received-message and lifecycle-event rendering. - Add child-session navigation and status UI on top of the retained `TuiSessions` entries.