From 5d26de4a0ecd4c24b04f84a81fe07d7202641965 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 14:25:06 -0400 Subject: [PATCH 1/7] rich message component --- app/src/ai/execution_profiles/profiles.rs | 2 +- crates/warp_tui/src/agent_block.rs | 34 ++- crates/warp_tui/src/agent_block_sections.rs | 48 +--- crates/warp_tui/src/agent_block_tests.rs | 119 ++++++++-- crates/warp_tui/src/agent_message.rs | 159 +++++++++++++ crates/warp_tui/src/agent_message_tests.rs | 220 ++++++++++++++++++ crates/warp_tui/src/lib.rs | 2 + crates/warp_tui/src/status.rs | 62 +++++ crates/warp_tui/src/status_tests.rs | 50 ++++ crates/warp_tui/src/tool_call_labels.rs | 88 +++---- crates/warp_tui/src/tool_call_labels_tests.rs | 23 +- crates/warp_tui/src/tui_file_edits_view.rs | 12 +- crates/warp_tui/src/tui_plan_view.rs | 39 ++-- crates/warp_tui/src/tui_shell_command_view.rs | 13 +- 14 files changed, 694 insertions(+), 177 deletions(-) create mode 100644 crates/warp_tui/src/agent_message.rs create mode 100644 crates/warp_tui/src/agent_message_tests.rs create mode 100644 crates/warp_tui/src/status.rs create mode 100644 crates/warp_tui/src/status_tests.rs 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/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index c64569e3f56..7f236252991 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,9 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => { + render_agent_message(&self.collapsible_states, message, app) + } }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -1082,26 +1088,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(_) @@ -1285,6 +1279,9 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => { + render_agent_message(&self.collapsible_states, message, app) + } }; // One row of bottom padding separates sections; the last section @@ -1348,7 +1345,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..e90a344da3f 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,7 +78,7 @@ 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 +/// streaming in (see `TuiStatusState::Constructing`); `block` carries /// the terminal block's ground truth for shell-command tool calls (see /// `ResolvedCommandBlock`). pub(crate) fn render_fallback_tool_call_section( @@ -128,12 +90,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..b332a03bdde 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; @@ -319,6 +320,13 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); + 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 wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -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,22 +364,7 @@ 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)], ); }); }); @@ -781,6 +768,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 +1813,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..254d3d971e8 --- /dev/null +++ b/crates/warp_tui/src/agent_message.rs @@ -0,0 +1,159 @@ +//! Rich TUI rendering for messages received from orchestration participants. + +use warp::tui_export::{ + BlocklistAIHistoryModel, ConversationStatus, MessageId, ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{tui_collapsible, Modifier, TuiContainer, TuiElement, 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::status::TuiStatusState; +use crate::tui_builder::TuiUiBuilder; + +/// Render-ready identity and lifecycle presentation for a message sender. +struct AgentMessagePresentation { + name: String, + status: TuiStatusState, + identity: AgentIdentity, +} +/// Maps a shared conversation lifecycle into the common TUI status contract. +fn agent_status(status: &ConversationStatus) -> TuiStatusState { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents => TuiStatusState::Running, + ConversationStatus::Success => TuiStatusState::Succeeded, + ConversationStatus::Error => TuiStatusState::Failed, + ConversationStatus::Cancelled => TuiStatusState::Cancelled, + ConversationStatus::Blocked { .. } => TuiStatusState::Blocked, + } +} + +/// Resolves a sender's name and sibling-stable identity. +fn message_presentation( + sender_agent_id: &str, + builder: &TuiUiBuilder, + app: &AppContext, +) -> AgentMessagePresentation { + let history = BlocklistAIHistoryModel::as_ref(app); + let sender = history + .conversation_id_for_agent_id(sender_agent_id) + .and_then(|conversation_id| history.conversation(&conversation_id)) + .or_else(|| { + history + .all_live_conversations() + .into_iter() + .map(|(_, conversation)| conversation) + .find(|conversation| { + conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) + }) + }); + let name = sender + .and_then(|conversation| conversation.agent_name()) + .unwrap_or("Unknown agent") + .to_owned(); + let status = sender + .map(|conversation| agent_status(conversation.status())) + .unwrap_or(TuiStatusState::Running); + let palette = builder.agent_identity_palette(); + let sibling_identity_index = sender.and_then(|conversation| { + let parent_id = conversation.parent_conversation_id()?; + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == conversation.id())?; + let indices = assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + palette.len(), + ); + indices.get(sender_index).copied() + }); + let fallback_identity_index = (!palette.is_empty()).then(|| { + usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() + }); + let identity = sibling_identity_index + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or(AgentIdentity { + glyph: "⟡", + style: builder.accent_text_style(), + }); + 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, + app: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(app); + let presentation = message_presentation(&message.sender_agent_id, &builder, app); + let header_spans = [ + ( + format!("{} ", presentation.status.glyph()), + presentation.status.glyph_style(&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..b5fc5683ab9 --- /dev/null +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -0,0 +1,220 @@ +use warp::tui_export::{ + AIConversationId, Appearance, 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, agent_status, render_agent_message}; +use crate::agent_block::CollapsibleSectionStates; +use crate::status::TuiStatusState; +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"; + +/// Registers the appearance and history models needed by the renderer. +fn register_models(app: &mut App) { + app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); +} + +/// 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| { + history.start_new_conversation(EntityId::new(), false, false, false, ctx) + }) + }) +} + +/// 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, +) { + 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()); + conversation.set_run_id(run_id.to_owned()); + history.set_parent_for_conversation(conversation_id, parent_id); + history.update_conversation_status(surface_id, conversation_id, status, ctx); + }); + }); +} + +/// 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_map_to_shared_tui_statuses() { + assert_eq!( + agent_status(&ConversationStatus::InProgress), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::TransientError), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::WaitingForEvents), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::Blocked { + blocked_action: "approval".to_owned(), + }), + TuiStatusState::Blocked + ); + assert_eq!( + agent_status(&ConversationStatus::Success), + TuiStatusState::Succeeded + ); + assert_eq!( + agent_status(&ConversationStatus::Error), + TuiStatusState::Failed + ); + assert_eq!( + agent_status(&ConversationStatus::Cancelled), + TuiStatusState::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, 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, + TuiStatusState::Running + .glyph_style(&builder) + .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, 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, 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", " "), + 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..04801e87df1 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; @@ -40,6 +41,7 @@ mod resume; mod session_registry; mod skills_menu; mod slash_commands; +mod status; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/status.rs b/crates/warp_tui/src/status.rs new file mode 100644 index 00000000000..0897e574898 --- /dev/null +++ b/crates/warp_tui/src/status.rs @@ -0,0 +1,62 @@ +//! Shared status presentation for compact TUI transcript rows. + +use warpui_core::elements::tui::TuiStyle; + +use crate::tui_builder::TuiUiBuilder; + +/// Coarse UI state shared by tool calls and orchestration participants. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TuiStatusState { + /// Content is still being assembled and may be incomplete. + Constructing, + /// Work is queued but has not started. + Pending, + /// Work is blocked on user input or approval. + Blocked, + /// Work is actively executing. + Running, + /// Work completed successfully. + Succeeded, + /// Work completed with an error. + Failed, + /// Work was cancelled. + Cancelled, +} + +impl TuiStatusState { + /// The compact leading glyph for this status. + 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 status 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 status. + 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() + } + } + } +} + +#[cfg(test)] +#[path = "status_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/status_tests.rs b/crates/warp_tui/src/status_tests.rs new file mode 100644 index 00000000000..18ca2fa56e4 --- /dev/null +++ b/crates/warp_tui/src/status_tests.rs @@ -0,0 +1,50 @@ +use warp::tui_export::Appearance; +use warpui_core::App; + +use super::TuiStatusState; +use crate::tui_builder::TuiUiBuilder; + +#[test] +fn status_glyphs_match_the_shared_transcript_contract() { + assert_eq!(TuiStatusState::Constructing.glyph(), "○"); + assert_eq!(TuiStatusState::Pending.glyph(), "○"); + assert_eq!(TuiStatusState::Blocked.glyph(), "■"); + assert_eq!(TuiStatusState::Running.glyph(), "●"); + assert_eq!(TuiStatusState::Succeeded.glyph(), "✓"); + assert_eq!(TuiStatusState::Failed.glyph(), "×"); + assert_eq!(TuiStatusState::Cancelled.glyph(), "■"); +} + +#[test] +fn status_styles_reuse_semantic_builder_styles() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|ctx| { + let builder = TuiUiBuilder::from_app(ctx); + assert_eq!( + TuiStatusState::Pending.glyph_style(&builder), + builder.dim_text_style() + ); + assert_eq!( + TuiStatusState::Blocked.glyph_style(&builder), + builder.attention_glyph_style() + ); + assert_eq!( + TuiStatusState::Running.glyph_style(&builder), + builder.attention_glyph_style() + ); + assert_eq!( + TuiStatusState::Succeeded.glyph_style(&builder), + builder.success_glyph_style() + ); + assert_eq!( + TuiStatusState::Failed.glyph_style(&builder), + builder.error_text_style() + ); + assert_eq!( + TuiStatusState::Cancelled.glyph_style(&builder), + builder.muted_text_style() + ); + }); + }); +} diff --git a/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index a7656c322ff..6c21d28db94 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, @@ -12,7 +11,7 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use self::ToolCallDisplayState as State; +use crate::status::TuiStatusState as State; /// Ground-truth state of the terminal block backing a shell-command tool /// call, resolved by the caller. When a block exists, its state supersedes @@ -53,7 +52,7 @@ pub(crate) fn tool_call_display_state( status: Option<&AIActionStatus>, output_streaming: bool, block_state: Option, -) -> ToolCallDisplayState { +) -> State { // A block existing means the command actually started executing, so its // state is authoritative over the action status/result. match block_state { @@ -72,7 +71,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 +85,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 +98,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 +117,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 +133,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 +166,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 +176,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 +188,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 +204,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 +247,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 +288,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 +303,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 +312,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 +330,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 +339,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 +349,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 +363,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 +373,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 +384,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 +401,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 +410,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 +428,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 +441,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 +453,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 +462,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 +493,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 +536,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 +551,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 +577,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 +589,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..6aa4a362ac2 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,7 +6,8 @@ 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}; +use crate::status::TuiStatusState; /// Builds a `Finished` status wrapping the given result. fn finished(result: AIAgentActionResultType) -> AIActionStatus { @@ -43,6 +44,26 @@ fn command_action(command: &str) -> AIAgentAction { } } +#[test] +fn tool_call_states_map_to_shared_tui_statuses() { + assert_eq!( + tool_call_display_state(None, true, None), + TuiStatusState::Constructing + ); + assert_eq!( + tool_call_display_state(None, false, None), + TuiStatusState::Pending + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), + TuiStatusState::Blocked + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), + TuiStatusState::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..44425e03ab7 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -36,9 +36,9 @@ 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::status::TuiStatusState; +use crate::tool_call_labels::tool_call_display_state; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -253,7 +253,7 @@ impl TuiFileEditsView { } /// The action's display state, driving the header glyph and styling. - fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { + fn display_state(&self, app: &AppContext) -> TuiStatusState { let status = self .action_model .as_ref(app) @@ -364,13 +364,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..f66c75369d4 100644 --- a/crates/warp_tui/src/tui_plan_view.rs +++ b/crates/warp_tui/src/tui_plan_view.rs @@ -17,9 +17,9 @@ 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::status::TuiStatusState; +use crate::tool_call_labels::tool_call_display_state; use crate::tui_builder::TuiUiBuilder; use crate::tui_code_block_view::{TuiCodeBlockPayload, TuiCodeBlockView, TuiCodeBlockViewEvent}; use crate::tui_markdown::{render_formatted_text, TuiMarkdownBlockHooks, TuiMarkdownPalette}; @@ -193,7 +193,7 @@ impl TuiPlanView { } } - fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { + fn display_state(&self, app: &AppContext) -> TuiStatusState { let status = self .action_model .as_ref(app) @@ -209,18 +209,16 @@ impl TuiPlanView { } } - fn header_label(&self, state: ToolCallDisplayState) -> (&'static str, Option) { + fn header_label(&self, state: TuiStatusState) -> (&'static str, Option) { if matches!(&self.action.action, AIAgentActionType::CreateDocuments(_)) { match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { + TuiStatusState::Constructing | TuiStatusState::Running => { ("Creating ", Some(self.document_subject())) } - ToolCallDisplayState::Pending | ToolCallDisplayState::AwaitingApproval => { - ("Create plan", None) - } - ToolCallDisplayState::Succeeded => ("Created ", Some(self.document_subject())), - ToolCallDisplayState::Failed => ("Failed to create plan", None), - ToolCallDisplayState::Cancelled => ("Create plan cancelled", None), + TuiStatusState::Pending | TuiStatusState::Blocked => ("Create plan", None), + TuiStatusState::Succeeded => ("Created ", Some(self.document_subject())), + TuiStatusState::Failed => ("Failed to create plan", None), + TuiStatusState::Cancelled => ("Create plan cancelled", None), } } else { debug_assert!(matches!( @@ -228,15 +226,11 @@ impl TuiPlanView { AIAgentActionType::EditDocuments(_) )); match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { - ("Updating plan", None) - } - ToolCallDisplayState::Pending | ToolCallDisplayState::AwaitingApproval => { - ("Update plan", None) - } - ToolCallDisplayState::Succeeded => ("Updated plan", None), - ToolCallDisplayState::Failed => ("Failed to update plan", None), - ToolCallDisplayState::Cancelled => ("Update plan cancelled", None), + TuiStatusState::Constructing | TuiStatusState::Running => ("Updating plan", None), + TuiStatusState::Pending | TuiStatusState::Blocked => ("Update plan", None), + TuiStatusState::Succeeded => ("Updated plan", None), + TuiStatusState::Failed => ("Failed to update plan", None), + TuiStatusState::Cancelled => ("Update plan cancelled", None), } } } @@ -314,10 +308,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), ]; From 2bc735e79e60e190c031c5300bf8e50887e6b7ba Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 22:45:38 -0400 Subject: [PATCH 2/7] add defaults --- crates/warp_tui/src/agent_block.rs | 17 +-- crates/warp_tui/src/agent_block_tests.rs | 21 ++- crates/warp_tui/src/agent_message.rs | 80 +++++------ crates/warp_tui/src/agent_message_tests.rs | 50 ++++++- .../orchestrated_agent_identity_styling.rs | 9 ++ crates/warp_tui/src/orchestration_model.rs | 134 ++++++++++++++++++ 6 files changed, 247 insertions(+), 64 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 7f236252991..65974650105 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -973,9 +973,7 @@ impl TuiAIBlock { app, ) } - TuiAIBlockSection::AgentMessage(message) => { - render_agent_message(&self.collapsible_states, message, app) - } + TuiAIBlockSection::AgentMessage(_) => return None, }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -1279,9 +1277,12 @@ impl TuiAIBlock { app, ) } - TuiAIBlockSection::AgentMessage(message) => { - render_agent_message(&self.collapsible_states, message, 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 @@ -1327,8 +1328,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()), diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index b332a03bdde..8772f84a916 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -41,6 +41,7 @@ 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::orchestration_model::TuiOrchestrationModel; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_plan_view::TuiPlanViewAction; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -320,13 +321,7 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - 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(), - }; + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -336,6 +331,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 { @@ -366,6 +368,10 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { block.sections(app_ctx), 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")); }); }); } @@ -805,6 +811,7 @@ fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { fn agent_message_defaults_collapsed_and_expands_through_block_state() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let received = received_message("run-1", "progress", "Starting work"); let message_id = agent_message_section_id(&received); let block = test_agent_block( diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index 254d3d971e8..a6df1dc2fd8 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -1,16 +1,16 @@ //! Rich TUI rendering for messages received from orchestration participants. use warp::tui_export::{ - BlocklistAIHistoryModel, ConversationStatus, MessageId, ReceivedMessageDisplay, + AIConversationId, BlocklistAIHistoryModel, ConversationStatus, MessageId, + ReceivedMessageDisplay, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{tui_collapsible, Modifier, TuiContainer, TuiElement, 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::orchestrated_agent_identity_styling::{stable_hash, AgentIdentity}; +use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationParticipantKind}; use crate::status::TuiStatusState; use crate::tui_builder::TuiUiBuilder; @@ -36,55 +36,41 @@ fn agent_status(status: &ConversationStatus) -> TuiStatusState { /// 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 sender = history - .conversation_id_for_agent_id(sender_agent_id) - .and_then(|conversation_id| history.conversation(&conversation_id)) - .or_else(|| { - history - .all_live_conversations() - .into_iter() - .map(|(_, conversation)| conversation) - .find(|conversation| { - conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) - }) - }); - let name = sender - .and_then(|conversation| conversation.agent_name()) - .unwrap_or("Unknown agent") - .to_owned(); + let palette = builder.agent_identity_palette(); + let participant = TuiOrchestrationModel::as_ref(app).participant_snapshot( + current_conversation_id, + sender_agent_id, + palette.len(), + app, + ); + let sender = participant + .conversation_id + .and_then(|conversation_id| history.conversation(&conversation_id)); + let name = participant.display_name; let status = sender .map(|conversation| agent_status(conversation.status())) .unwrap_or(TuiStatusState::Running); - let palette = builder.agent_identity_palette(); - let sibling_identity_index = sender.and_then(|conversation| { - let parent_id = conversation.parent_conversation_id()?; - let siblings = history.child_conversations_of(parent_id); - let sender_index = siblings - .iter() - .position(|sibling| sibling.id() == conversation.id())?; - let indices = assign_agent_identity_indices( - siblings - .iter() - .map(|sibling| sibling.agent_name().unwrap_or("Agent")), - palette.len(), - ); - indices.get(sender_index).copied() - }); let fallback_identity_index = (!palette.is_empty()).then(|| { usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() }); - let identity = sibling_identity_index - .or(fallback_identity_index) - .and_then(|index| palette.get(index)) - .cloned() - .unwrap_or(AgentIdentity { - glyph: "⟡", - style: builder.accent_text_style(), - }); + let identity = match participant.kind { + TuiOrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), + TuiOrchestrationParticipantKind::Child => participant + .identity_index + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + TuiOrchestrationParticipantKind::Unknown => fallback_identity_index + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + }; AgentMessagePresentation { name, status, @@ -101,10 +87,16 @@ pub(crate) fn agent_message_section_id(message: &ReceivedMessageDisplay) -> Mess 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, &builder, app); + let presentation = message_presentation( + &message.sender_agent_id, + current_conversation_id, + &builder, + app, + ); let header_spans = [ ( format!("{} ", presentation.status.glyph()), diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs index b5fc5683ab9..bb25f582fdc 100644 --- a/crates/warp_tui/src/agent_message_tests.rs +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -9,22 +9,31 @@ use warpui_core::{App, EntityId}; use super::{agent_message_section_id, agent_status, render_agent_message}; use crate::agent_block::CollapsibleSectionStates; +use crate::orchestration_model::TuiOrchestrationModel; use crate::status::TuiStatusState; 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) { app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); } /// 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| { - history.start_new_conversation(EntityId::new(), false, false, false, ctx) + let conversation_id = + history.start_new_conversation(EntityId::new(), false, false, false, ctx); + history + .conversation_mut(&conversation_id) + .expect("parent conversation was just created") + .set_run_id(PARENT_RUN_ID.to_string()); + conversation_id }) }) } @@ -36,7 +45,7 @@ fn add_child( name: &str, run_id: &str, status: ConversationStatus, -) { +) -> AIConversationId { app.update(|ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { let surface_id = EntityId::new(); @@ -49,6 +58,36 @@ fn add_child( conversation.set_run_id(run_id.to_owned()); history.set_parent_for_conversation(conversation_id, parent_id); 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}"); }); }); } @@ -116,7 +155,7 @@ fn running_child_message_matches_the_design_layout_and_styles() { 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, ctx), + render_agent_message(&states, &message, parent_id, ctx), TuiRect::new(0, 0, 80, 2), ctx, ); @@ -143,7 +182,7 @@ fn running_child_message_matches_the_design_layout_and_styles() { states.set_collapsed(agent_message_section_id(&message), false); let expanded = presenter.present_element( - render_agent_message(&states, &message, ctx), + render_agent_message(&states, &message, parent_id, ctx), TuiRect::new(0, 0, 80, 2), ctx, ); @@ -186,7 +225,7 @@ fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { states.set_collapsed(agent_message_section_id(&received), false); let mut presenter = TuiPresenter::new(); let wrapped = presenter.present_element( - render_agent_message(&states, &received, ctx), + render_agent_message(&states, &received, parent_id, ctx), TuiRect::new(0, 0, 24, 4), ctx, ); @@ -206,6 +245,7 @@ fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { render_agent_message( &states, &message(UI_RUN_ID, "Finished verification", " "), + parent_id, ctx, ), TuiRect::new(0, 0, 40, 2), 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/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 15d60927c07..d64ed1b9863 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -24,9 +24,39 @@ use warp::tui_export::{ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; +use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session_registry::TuiSessionId; use crate::terminal_session_view::TuiTerminalSessionView; +/// Semantic role of one orchestration participant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiOrchestrationParticipantKind { + Orchestrator, + Child, + Unknown, +} + +/// Plain-data participant presentation shared by orchestration surfaces. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationParticipantSnapshot { + pub(crate) kind: TuiOrchestrationParticipantKind, + pub(crate) conversation_id: Option, + pub(crate) display_name: String, + pub(crate) identity_index: Option, +} + +impl TuiOrchestrationParticipantSnapshot { + /// Fallback for a sender that cannot be linked to the current tree. + fn unknown() -> Self { + Self { + kind: TuiOrchestrationParticipantKind::Unknown, + conversation_id: None, + display_name: "Unknown agent".to_string(), + identity_index: None, + } + } +} + /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -57,6 +87,26 @@ pub(crate) struct MaterializedLocalOzChildSession { pub(crate) conversation_name: String, } +/// Resolves the topmost loaded orchestration parent, rejecting cycles. +fn orchestration_root( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Option { + history.conversation(&conversation_id)?; + let mut current = conversation_id; + let mut visited = HashSet::new(); + while visited.insert(current) { + let conversation = history.conversation(¤t)?; + let Some(parent_id) = + history.resolved_parent_conversation_id_for_conversation(conversation) + else { + return Some(current); + }; + current = parent_id; + } + None +} + impl Entity for TuiOrchestrationModel { type Event = TuiOrchestrationEvent; } @@ -64,6 +114,90 @@ impl Entity for TuiOrchestrationModel { impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { + /// Creates an unsubscribed model for participant-rendering tests. + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self { + child_session_by_conversation: HashMap::new(), + event_consumers_by_session: HashMap::new(), + } + } + + /// Resolves a sender's role, label, conversation, and stable child identity. + pub(crate) fn participant_snapshot( + &self, + current_conversation_id: AIConversationId, + sender_agent_id: &str, + identity_palette_len: usize, + ctx: &AppContext, + ) -> TuiOrchestrationParticipantSnapshot { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let Some(root_conversation_id) = orchestration_root(history, current_conversation_id) + else { + return TuiOrchestrationParticipantSnapshot::unknown(); + }; + let current_conversation = history.conversation(¤t_conversation_id); + let root_conversation = history.conversation(&root_conversation_id); + let orchestrator_agent_id = root_conversation + .and_then(|conversation| conversation.orchestration_agent_id()) + .or_else(|| { + current_conversation + .and_then(|conversation| conversation.parent_agent_id()) + .map(str::to_owned) + }); + let sender_conversation = history + .conversation_id_for_agent_id(sender_agent_id) + .and_then(|conversation_id| history.conversation(&conversation_id)) + .or_else(|| { + history + .all_live_conversations() + .into_iter() + .map(|(_, conversation)| conversation) + .find(|conversation| { + conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) + }) + }); + let sender_is_orchestrator = orchestrator_agent_id.as_deref() == Some(sender_agent_id) + || sender_conversation + .is_some_and(|conversation| conversation.id() == root_conversation_id); + if sender_is_orchestrator { + return TuiOrchestrationParticipantSnapshot { + kind: TuiOrchestrationParticipantKind::Orchestrator, + conversation_id: Some(root_conversation_id), + display_name: "Orchestrator".to_string(), + identity_index: None, + }; + } + let Some(sender_conversation) = sender_conversation else { + return TuiOrchestrationParticipantSnapshot::unknown(); + }; + let identity_index = sender_conversation + .parent_conversation_id() + .and_then(|parent_id| { + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == sender_conversation.id())?; + let indices = assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + identity_palette_len, + ); + indices.get(sender_index).copied() + }); + TuiOrchestrationParticipantSnapshot { + kind: TuiOrchestrationParticipantKind::Child, + conversation_id: Some(sender_conversation.id()), + display_name: sender_conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(), + identity_index, + } + } + /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { ctx.add_singleton_model(|_| Self { From ada86082044eaa48335920c1f350732c05c95860 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 14:55:43 -0400 Subject: [PATCH 3/7] update shared code --- .../block/view_impl/orchestration.rs | 70 ++++----- .../ai/blocklist/orchestration_topology.rs | 78 ++++++++++ .../blocklist/orchestration_topology_tests.rs | 80 +++++++++++ app/src/tui_export.rs | 4 + crates/warp_tui/src/agent_block_sections.rs | 7 +- crates/warp_tui/src/agent_block_tests.rs | 3 - crates/warp_tui/src/agent_message.rs | 96 +++++++++---- crates/warp_tui/src/agent_message_tests.rs | 71 +++++----- crates/warp_tui/src/lib.rs | 1 - crates/warp_tui/src/orchestration_model.rs | 133 ------------------ crates/warp_tui/src/status.rs | 62 -------- crates/warp_tui/src/status_tests.rs | 50 ------- crates/warp_tui/src/tool_call_labels.rs | 56 +++++++- crates/warp_tui/src/tool_call_labels_tests.rs | 16 ++- crates/warp_tui/src/tui_file_edits_view.rs | 5 +- specs/CODE-1822/TECH.md | 49 +++++-- specs/code-1822-tui-local-children/TECH.md | 99 ++++++++++--- 17 files changed, 490 insertions(+), 390 deletions(-) delete mode 100644 crates/warp_tui/src/status.rs delete mode 100644 crates/warp_tui/src/status_tests.rs diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index f15ed6bee0c..6af5e6afd99 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,27 @@ 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 avatar = match participant.kind { + OrchestrationParticipantKind::Orchestrator => OrchestrationAvatar::Orchestrator, + OrchestrationParticipantKind::Agent | OrchestrationParticipantKind::Unknown => { + OrchestrationAvatar::agent(participant.display_name.clone()) } + }; + OrchestrationParticipant { + display_name: participant.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 +133,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 +333,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 +411,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..1e419b24088 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -15,6 +15,84 @@ pub enum OrchestrationNavigationDirection { Next, } +/// Semantic role of a participant in an orchestration transcript. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrchestrationParticipantKind { + Orchestrator, + Agent, + Unknown, +} + +/// Frontend-independent identity for an orchestration participant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedOrchestrationParticipant { + pub kind: OrchestrationParticipantKind, + pub conversation_id: Option, + pub display_name: String, +} + +impl ResolvedOrchestrationParticipant { + fn orchestrator(conversation_id: Option) -> Self { + Self { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id, + display_name: "Orchestrator".to_string(), + } + } + + fn unknown() -> Self { + Self { + kind: OrchestrationParticipantKind::Unknown, + conversation_id: None, + display_name: "Unknown agent".to_string(), + } + } +} + +/// 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(); + }; + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(conversation_id), + display_name: conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(), + } +} + 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..133b008f350 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), + display_name: "Orchestrator".to_string(), + } + ); + assert_eq!( + resolve_orchestration_participant(history, &root_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(root_id), + display_name: "Agent".to_string(), + } + ); + }); + }); +} + #[test] fn pill_order_keys_prioritize_attention_then_in_progress_then_done() { let blocked = ConversationStatus::Blocked { 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_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index e90a344da3f..f09051fe3d3 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -78,9 +78,10 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box, diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 8772f84a916..7f4838cfe62 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -41,7 +41,6 @@ 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::orchestration_model::TuiOrchestrationModel; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_plan_view::TuiPlanViewAction; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -321,7 +320,6 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -811,7 +809,6 @@ fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { fn agent_message_defaults_collapsed_and_expands_through_block_state() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let received = received_message("run-1", "progress", "Starting work"); let message_id = agent_message_section_id(&received); let block = test_agent_block( diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index a6df1dc2fd8..6ea4fe618a3 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -1,38 +1,79 @@ //! Rich TUI rendering for messages received from orchestration participants. use warp::tui_export::{ - AIConversationId, BlocklistAIHistoryModel, ConversationStatus, MessageId, + 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, TuiText}; +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::{stable_hash, AgentIdentity}; -use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationParticipantKind}; -use crate::status::TuiStatusState; +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: TuiStatusState, + status: ConversationStatus, identity: AgentIdentity, } -/// Maps a shared conversation lifecycle into the common TUI status contract. -fn agent_status(status: &ConversationStatus) -> TuiStatusState { + +/// Compact glyph for a conversation's lifecycle status. +fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { match status { ConversationStatus::InProgress | ConversationStatus::TransientError - | ConversationStatus::WaitingForEvents => TuiStatusState::Running, - ConversationStatus::Success => TuiStatusState::Succeeded, - ConversationStatus::Error => TuiStatusState::Failed, - ConversationStatus::Cancelled => TuiStatusState::Cancelled, - ConversationStatus::Blocked { .. } => TuiStatusState::Blocked, + | 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, @@ -42,31 +83,36 @@ fn message_presentation( ) -> AgentMessagePresentation { let history = BlocklistAIHistoryModel::as_ref(app); let palette = builder.agent_identity_palette(); - let participant = TuiOrchestrationModel::as_ref(app).participant_snapshot( - current_conversation_id, + 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, - palette.len(), - app, + orchestrator_agent_id.as_deref(), ); let sender = participant .conversation_id .and_then(|conversation_id| history.conversation(&conversation_id)); let name = participant.display_name; let status = sender - .map(|conversation| agent_status(conversation.status())) - .unwrap_or(TuiStatusState::Running); + .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 identity = match participant.kind { - TuiOrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), - TuiOrchestrationParticipantKind::Child => participant - .identity_index + OrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), + OrchestrationParticipantKind::Agent => 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(), - TuiOrchestrationParticipantKind::Unknown => fallback_identity_index + OrchestrationParticipantKind::Unknown => fallback_identity_index .and_then(|index| palette.get(index)) .cloned() .unwrap_or_default(), @@ -99,8 +145,8 @@ pub(crate) fn render_agent_message( ); let header_spans = [ ( - format!("{} ", presentation.status.glyph()), - presentation.status.glyph_style(&builder), + format!("{} ", conversation_status_glyph(&presentation.status)), + conversation_status_glyph_style(&presentation.status, &builder), ), ( format!("{} ", presentation.identity.glyph), diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs index bb25f582fdc..f2158bd59df 100644 --- a/crates/warp_tui/src/agent_message_tests.rs +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -1,38 +1,39 @@ use warp::tui_export::{ - AIConversationId, Appearance, BlocklistAIHistoryModel, ConversationStatus, - ReceivedMessageDisplay, + 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, agent_status, render_agent_message}; +use super::{agent_message_section_id, conversation_status_glyph, render_agent_message}; use crate::agent_block::CollapsibleSectionStates; -use crate::orchestration_model::TuiOrchestrationModel; -use crate::status::TuiStatusState; 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) { - app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); + 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(EntityId::new(), false, false, false, ctx); - history - .conversation_mut(&conversation_id) - .expect("parent conversation was just created") - .set_run_id(PARENT_RUN_ID.to_string()); + 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 }) }) @@ -55,8 +56,14 @@ fn add_child( .conversation_mut(&conversation_id) .expect("child conversation was just created"); conversation.set_agent_name(name.to_owned()); - conversation.set_run_id(run_id.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 }) @@ -104,36 +111,30 @@ fn message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { } #[test] -fn conversation_statuses_map_to_shared_tui_statuses() { +fn conversation_statuses_render_expected_glyphs() { assert_eq!( - agent_status(&ConversationStatus::InProgress), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::InProgress), + "●" ); assert_eq!( - agent_status(&ConversationStatus::TransientError), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::TransientError), + "●" ); assert_eq!( - agent_status(&ConversationStatus::WaitingForEvents), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::WaitingForEvents), + "●" ); assert_eq!( - agent_status(&ConversationStatus::Blocked { + conversation_status_glyph(&ConversationStatus::Blocked { blocked_action: "approval".to_owned(), }), - TuiStatusState::Blocked - ); - assert_eq!( - agent_status(&ConversationStatus::Success), - TuiStatusState::Succeeded + "■" ); + assert_eq!(conversation_status_glyph(&ConversationStatus::Success), "✓"); + assert_eq!(conversation_status_glyph(&ConversationStatus::Error), "×"); assert_eq!( - agent_status(&ConversationStatus::Error), - TuiStatusState::Failed - ); - assert_eq!( - agent_status(&ConversationStatus::Cancelled), - TuiStatusState::Cancelled + conversation_status_glyph(&ConversationStatus::Cancelled), + "■" ); } @@ -172,8 +173,8 @@ fn running_child_message_matches_the_design_layout_and_styles() { let builder = TuiUiBuilder::from_app(ctx); assert_eq!( frame.buffer[(0, 0)].fg, - TuiStatusState::Running - .glyph_style(&builder) + builder + .attention_glyph_style() .fg .expect("running status has a foreground") ); diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 04801e87df1..45eae6f7cfa 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -41,7 +41,6 @@ mod resume; mod session_registry; mod skills_menu; mod slash_commands; -mod status; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index d64ed1b9863..78339abe9d4 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -24,39 +24,9 @@ use warp::tui_export::{ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; -use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session_registry::TuiSessionId; use crate::terminal_session_view::TuiTerminalSessionView; -/// Semantic role of one orchestration participant. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TuiOrchestrationParticipantKind { - Orchestrator, - Child, - Unknown, -} - -/// Plain-data participant presentation shared by orchestration surfaces. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TuiOrchestrationParticipantSnapshot { - pub(crate) kind: TuiOrchestrationParticipantKind, - pub(crate) conversation_id: Option, - pub(crate) display_name: String, - pub(crate) identity_index: Option, -} - -impl TuiOrchestrationParticipantSnapshot { - /// Fallback for a sender that cannot be linked to the current tree. - fn unknown() -> Self { - Self { - kind: TuiOrchestrationParticipantKind::Unknown, - conversation_id: None, - display_name: "Unknown agent".to_string(), - identity_index: None, - } - } -} - /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -87,26 +57,6 @@ pub(crate) struct MaterializedLocalOzChildSession { pub(crate) conversation_name: String, } -/// Resolves the topmost loaded orchestration parent, rejecting cycles. -fn orchestration_root( - history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, -) -> Option { - history.conversation(&conversation_id)?; - let mut current = conversation_id; - let mut visited = HashSet::new(); - while visited.insert(current) { - let conversation = history.conversation(¤t)?; - let Some(parent_id) = - history.resolved_parent_conversation_id_for_conversation(conversation) - else { - return Some(current); - }; - current = parent_id; - } - None -} - impl Entity for TuiOrchestrationModel { type Event = TuiOrchestrationEvent; } @@ -114,89 +64,6 @@ impl Entity for TuiOrchestrationModel { impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { - /// Creates an unsubscribed model for participant-rendering tests. - #[cfg(test)] - pub(crate) fn new_for_test() -> Self { - Self { - child_session_by_conversation: HashMap::new(), - event_consumers_by_session: HashMap::new(), - } - } - - /// Resolves a sender's role, label, conversation, and stable child identity. - pub(crate) fn participant_snapshot( - &self, - current_conversation_id: AIConversationId, - sender_agent_id: &str, - identity_palette_len: usize, - ctx: &AppContext, - ) -> TuiOrchestrationParticipantSnapshot { - let history = BlocklistAIHistoryModel::as_ref(ctx); - let Some(root_conversation_id) = orchestration_root(history, current_conversation_id) - else { - return TuiOrchestrationParticipantSnapshot::unknown(); - }; - let current_conversation = history.conversation(¤t_conversation_id); - let root_conversation = history.conversation(&root_conversation_id); - let orchestrator_agent_id = root_conversation - .and_then(|conversation| conversation.orchestration_agent_id()) - .or_else(|| { - current_conversation - .and_then(|conversation| conversation.parent_agent_id()) - .map(str::to_owned) - }); - let sender_conversation = history - .conversation_id_for_agent_id(sender_agent_id) - .and_then(|conversation_id| history.conversation(&conversation_id)) - .or_else(|| { - history - .all_live_conversations() - .into_iter() - .map(|(_, conversation)| conversation) - .find(|conversation| { - conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) - }) - }); - let sender_is_orchestrator = orchestrator_agent_id.as_deref() == Some(sender_agent_id) - || sender_conversation - .is_some_and(|conversation| conversation.id() == root_conversation_id); - if sender_is_orchestrator { - return TuiOrchestrationParticipantSnapshot { - kind: TuiOrchestrationParticipantKind::Orchestrator, - conversation_id: Some(root_conversation_id), - display_name: "Orchestrator".to_string(), - identity_index: None, - }; - } - let Some(sender_conversation) = sender_conversation else { - return TuiOrchestrationParticipantSnapshot::unknown(); - }; - let identity_index = sender_conversation - .parent_conversation_id() - .and_then(|parent_id| { - let siblings = history.child_conversations_of(parent_id); - let sender_index = siblings - .iter() - .position(|sibling| sibling.id() == sender_conversation.id())?; - let indices = assign_agent_identity_indices( - siblings - .iter() - .map(|sibling| sibling.agent_name().unwrap_or("Agent")), - identity_palette_len, - ); - indices.get(sender_index).copied() - }); - TuiOrchestrationParticipantSnapshot { - kind: TuiOrchestrationParticipantKind::Child, - conversation_id: Some(sender_conversation.id()), - display_name: sender_conversation - .agent_name() - .filter(|name| !name.is_empty()) - .unwrap_or("Agent") - .to_string(), - identity_index, - } - } /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { diff --git a/crates/warp_tui/src/status.rs b/crates/warp_tui/src/status.rs deleted file mode 100644 index 0897e574898..00000000000 --- a/crates/warp_tui/src/status.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Shared status presentation for compact TUI transcript rows. - -use warpui_core::elements::tui::TuiStyle; - -use crate::tui_builder::TuiUiBuilder; - -/// Coarse UI state shared by tool calls and orchestration participants. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum TuiStatusState { - /// Content is still being assembled and may be incomplete. - Constructing, - /// Work is queued but has not started. - Pending, - /// Work is blocked on user input or approval. - Blocked, - /// Work is actively executing. - Running, - /// Work completed successfully. - Succeeded, - /// Work completed with an error. - Failed, - /// Work was cancelled. - Cancelled, -} - -impl TuiStatusState { - /// The compact leading glyph for this status. - 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 status 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 status. - 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() - } - } - } -} - -#[cfg(test)] -#[path = "status_tests.rs"] -mod tests; diff --git a/crates/warp_tui/src/status_tests.rs b/crates/warp_tui/src/status_tests.rs deleted file mode 100644 index 18ca2fa56e4..00000000000 --- a/crates/warp_tui/src/status_tests.rs +++ /dev/null @@ -1,50 +0,0 @@ -use warp::tui_export::Appearance; -use warpui_core::App; - -use super::TuiStatusState; -use crate::tui_builder::TuiUiBuilder; - -#[test] -fn status_glyphs_match_the_shared_transcript_contract() { - assert_eq!(TuiStatusState::Constructing.glyph(), "○"); - assert_eq!(TuiStatusState::Pending.glyph(), "○"); - assert_eq!(TuiStatusState::Blocked.glyph(), "■"); - assert_eq!(TuiStatusState::Running.glyph(), "●"); - assert_eq!(TuiStatusState::Succeeded.glyph(), "✓"); - assert_eq!(TuiStatusState::Failed.glyph(), "×"); - assert_eq!(TuiStatusState::Cancelled.glyph(), "■"); -} - -#[test] -fn status_styles_reuse_semantic_builder_styles() { - App::test((), |app| async move { - app.add_singleton_model(|_| Appearance::mock()); - app.read(|ctx| { - let builder = TuiUiBuilder::from_app(ctx); - assert_eq!( - TuiStatusState::Pending.glyph_style(&builder), - builder.dim_text_style() - ); - assert_eq!( - TuiStatusState::Blocked.glyph_style(&builder), - builder.attention_glyph_style() - ); - assert_eq!( - TuiStatusState::Running.glyph_style(&builder), - builder.attention_glyph_style() - ); - assert_eq!( - TuiStatusState::Succeeded.glyph_style(&builder), - builder.success_glyph_style() - ); - assert_eq!( - TuiStatusState::Failed.glyph_style(&builder), - builder.error_text_style() - ); - assert_eq!( - TuiStatusState::Cancelled.glyph_style(&builder), - builder.muted_text_style() - ); - }); - }); -} diff --git a/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index 6c21d28db94..1b4c81fba30 100644 --- a/crates/warp_tui/src/tool_call_labels.rs +++ b/crates/warp_tui/src/tool_call_labels.rs @@ -10,8 +10,10 @@ use warp::tui_export::{ StartAgentExecutionMode, SuggestNewConversationResult, }; use warp_core::command::ExitCode; +use warpui_core::elements::tui::TuiStyle; -use crate::status::TuiStatusState as State; +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 @@ -42,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 @@ -52,7 +104,7 @@ pub(crate) fn tool_call_display_state( status: Option<&AIActionStatus>, output_streaming: bool, block_state: Option, -) -> State { +) -> ToolCallDisplayState { // A block existing means the command actually started executing, so its // state is authoritative over the action status/result. match block_state { diff --git a/crates/warp_tui/src/tool_call_labels_tests.rs b/crates/warp_tui/src/tool_call_labels_tests.rs index 6aa4a362ac2..a2ab0ad6d06 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,8 +6,10 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use super::{tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock}; -use crate::status::TuiStatusState; +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 { @@ -45,22 +47,22 @@ fn command_action(command: &str) -> AIAgentAction { } #[test] -fn tool_call_states_map_to_shared_tui_statuses() { +fn tool_call_statuses_map_to_tool_call_display_states() { assert_eq!( tool_call_display_state(None, true, None), - TuiStatusState::Constructing + ToolCallDisplayState::Constructing ); assert_eq!( tool_call_display_state(None, false, None), - TuiStatusState::Pending + ToolCallDisplayState::Pending ); assert_eq!( tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), - TuiStatusState::Blocked + ToolCallDisplayState::Blocked ); assert_eq!( tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), - TuiStatusState::Running + ToolCallDisplayState::Running ); } diff --git a/crates/warp_tui/src/tui_file_edits_view.rs b/crates/warp_tui/src/tui_file_edits_view.rs index 44425e03ab7..bdbca9930f5 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -37,8 +37,7 @@ use warpui_core::elements::MouseStateHandle; use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; use crate::editor_element::{TuiEditorElement, TuiEditorStyles}; -use crate::status::TuiStatusState; -use crate::tool_call_labels::tool_call_display_state; +use crate::tool_call_labels::{tool_call_display_state, ToolCallDisplayState}; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -253,7 +252,7 @@ impl TuiFileEditsView { } /// The action's display state, driving the header glyph and styling. - fn display_state(&self, app: &AppContext) -> TuiStatusState { + fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { let status = self .action_model .as_ref(app) diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 21cd6d6f56d..a98b6077ed6 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`, 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..8ee5161f519 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, 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. From a90b7cafc14a6f1ffbabf63a21570012d4439923 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 15:28:16 -0400 Subject: [PATCH 4/7] fix exmpty padding for exchange --- crates/warp_tui/src/agent_block.rs | 10 ++++++- crates/warp_tui/src/agent_block_tests.rs | 33 ++++++++++++++++++++++ specs/CODE-1822/TECH.md | 2 +- specs/code-1822-tui-local-children/TECH.md | 4 +-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 65974650105..07431fac0aa 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -1193,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 { diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 7f4838cfe62..cf6e6418df0 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -374,6 +374,39 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { }); } +#[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 { diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index a98b6077ed6..7a0131caae4 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -96,7 +96,7 @@ Focused unit coverage: - `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`, and block-owned collapse state. +- `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. diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 8ee5161f519..f327e8401f1 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -159,8 +159,8 @@ types remain behind their respective boundaries. 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, and collapse state is owned by the - agent block. + `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. From 8f2f010fc5b564346086529383988591a633c3f5 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 09:02:04 -0400 Subject: [PATCH 5/7] fix bads import --- crates/warp_tui/src/tui_plan_view.rs | 33 ++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/warp_tui/src/tui_plan_view.rs b/crates/warp_tui/src/tui_plan_view.rs index f66c75369d4..e1449542282 100644 --- a/crates/warp_tui/src/tui_plan_view.rs +++ b/crates/warp_tui/src/tui_plan_view.rs @@ -18,8 +18,7 @@ use warpui_core::{ }; use crate::keybindings::plan_toggle_hint; -use crate::status::TuiStatusState; -use crate::tool_call_labels::tool_call_display_state; +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}; @@ -193,7 +192,7 @@ impl TuiPlanView { } } - fn display_state(&self, app: &AppContext) -> TuiStatusState { + fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { let status = self .action_model .as_ref(app) @@ -209,16 +208,18 @@ impl TuiPlanView { } } - fn header_label(&self, state: TuiStatusState) -> (&'static str, Option) { + fn header_label(&self, state: ToolCallDisplayState) -> (&'static str, Option) { if matches!(&self.action.action, AIAgentActionType::CreateDocuments(_)) { match state { - TuiStatusState::Constructing | TuiStatusState::Running => { + ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { ("Creating ", Some(self.document_subject())) } - TuiStatusState::Pending | TuiStatusState::Blocked => ("Create plan", None), - TuiStatusState::Succeeded => ("Created ", Some(self.document_subject())), - TuiStatusState::Failed => ("Failed to create plan", None), - TuiStatusState::Cancelled => ("Create plan cancelled", None), + ToolCallDisplayState::Pending | ToolCallDisplayState::Blocked => { + ("Create plan", None) + } + ToolCallDisplayState::Succeeded => ("Created ", Some(self.document_subject())), + ToolCallDisplayState::Failed => ("Failed to create plan", None), + ToolCallDisplayState::Cancelled => ("Create plan cancelled", None), } } else { debug_assert!(matches!( @@ -226,11 +227,15 @@ impl TuiPlanView { AIAgentActionType::EditDocuments(_) )); match state { - TuiStatusState::Constructing | TuiStatusState::Running => ("Updating plan", None), - TuiStatusState::Pending | TuiStatusState::Blocked => ("Update plan", None), - TuiStatusState::Succeeded => ("Updated plan", None), - TuiStatusState::Failed => ("Failed to update plan", None), - TuiStatusState::Cancelled => ("Update plan cancelled", None), + ToolCallDisplayState::Constructing | ToolCallDisplayState::Running => { + ("Updating plan", None) + } + ToolCallDisplayState::Pending | ToolCallDisplayState::Blocked => { + ("Update plan", None) + } + ToolCallDisplayState::Succeeded => ("Updated plan", None), + ToolCallDisplayState::Failed => ("Failed to update plan", None), + ToolCallDisplayState::Cancelled => ("Update plan cancelled", None), } } } From 1ee6f9942a1cd1da69d99cb3b3bf99cbe11ce441 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 15:09:18 -0400 Subject: [PATCH 6/7] add display name fn --- .../block/view_impl/orchestration.rs | 13 +++++---- .../ai/blocklist/orchestration_topology.rs | 29 ++++++++++++------- .../blocklist/orchestration_topology_tests.rs | 6 ++-- crates/warp_tui/src/orchestration_model.rs | 1 - 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index 6af5e6afd99..47bbd2afef6 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -91,20 +91,21 @@ fn participant_for_agent_id( agent_id, orchestrator_agent_id, ); - let avatar = match participant.kind { + let display_name = participant.kind.display_name().to_string(); + let avatar = match &participant.kind { OrchestrationParticipantKind::Orchestrator => OrchestrationAvatar::Orchestrator, - OrchestrationParticipantKind::Agent | OrchestrationParticipantKind::Unknown => { - OrchestrationAvatar::agent(participant.display_name.clone()) + OrchestrationParticipantKind::Agent { .. } | OrchestrationParticipantKind::Unknown => { + OrchestrationAvatar::agent(display_name.clone()) } }; OrchestrationParticipant { - display_name: participant.display_name, + display_name, avatar, - conversation_id: match participant.kind { + conversation_id: match &participant.kind { OrchestrationParticipantKind::Orchestrator | OrchestrationParticipantKind::Unknown => { None } - OrchestrationParticipantKind::Agent => participant.conversation_id, + OrchestrationParticipantKind::Agent { .. } => participant.conversation_id, }, } } diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index 1e419b24088..e1d95f34f01 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -16,19 +16,28 @@ pub enum OrchestrationNavigationDirection { } /// Semantic role of a participant in an orchestration transcript. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum OrchestrationParticipantKind { Orchestrator, - Agent, + 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, - pub display_name: String, } impl ResolvedOrchestrationParticipant { @@ -36,7 +45,6 @@ impl ResolvedOrchestrationParticipant { Self { kind: OrchestrationParticipantKind::Orchestrator, conversation_id, - display_name: "Orchestrator".to_string(), } } @@ -44,7 +52,6 @@ impl ResolvedOrchestrationParticipant { Self { kind: OrchestrationParticipantKind::Unknown, conversation_id: None, - display_name: "Unknown agent".to_string(), } } } @@ -82,14 +89,14 @@ pub fn resolve_orchestration_participant( 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, + kind: OrchestrationParticipantKind::Agent { name }, conversation_id: Some(conversation_id), - display_name: conversation - .agent_name() - .filter(|name| !name.is_empty()) - .unwrap_or("Agent") - .to_string(), } } diff --git a/app/src/ai/blocklist/orchestration_topology_tests.rs b/app/src/ai/blocklist/orchestration_topology_tests.rs index 133b008f350..6c61fc46e49 100644 --- a/app/src/ai/blocklist/orchestration_topology_tests.rs +++ b/app/src/ai/blocklist/orchestration_topology_tests.rs @@ -69,15 +69,15 @@ fn participant_resolution_uses_the_direct_parent_as_orchestrator() { ResolvedOrchestrationParticipant { kind: OrchestrationParticipantKind::Orchestrator, conversation_id: Some(child_id), - display_name: "Orchestrator".to_string(), } ); assert_eq!( resolve_orchestration_participant(history, &root_run_id, Some(&child_run_id)), ResolvedOrchestrationParticipant { - kind: OrchestrationParticipantKind::Agent, + kind: OrchestrationParticipantKind::Agent { + name: "Agent".to_string(), + }, conversation_id: Some(root_id), - display_name: "Agent".to_string(), } ); }); diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 78339abe9d4..15d60927c07 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -64,7 +64,6 @@ impl Entity for TuiOrchestrationModel { impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { - /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { ctx.add_singleton_model(|_| Self { From 5cc1ee0a5add936647f9c265059ded00ee2b8978 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 16:13:09 -0400 Subject: [PATCH 7/7] fix failing compilation --- crates/warp_tui/src/agent_message.rs | 39 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index 6ea4fe618a3..3297a8a0a5b 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -94,28 +94,35 @@ fn message_presentation( let sender = participant .conversation_id .and_then(|conversation_id| history.conversation(&conversation_id)); - let name = participant.display_name; 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 identity = match participant.kind { - OrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), - OrchestrationParticipantKind::Agent => 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 => fallback_identity_index - .and_then(|index| palette.get(index)) - .cloned() - .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,