diff --git a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs index 4ba2d05f40a..61a700eb462 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs @@ -49,8 +49,8 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::{ }; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_topology::{ - aggregated_orchestrator_status, descendant_conversation_ids_in_pill_order, - descendant_conversation_ids_in_spawn_order, + aggregated_orchestrator_status, descendant_conversation_ids_in_spawn_order, + descendant_conversations_in_pill_order, }; use crate::ai::blocklist::telemetry::{ BlocklistOrchestrationTelemetryEvent, PillBarActionKind, PillBarInteractionEvent, @@ -613,9 +613,9 @@ impl OrchestrationPillBar { // Use the shared canonical pill ordering so the visible row and // keyboard navigation cannot drift. - let children: Vec<_> = descendant_conversation_ids_in_pill_order(history, orchestrator_id) + let children: Vec<_> = descendant_conversations_in_pill_order(history, orchestrator_id) .into_iter() - .filter_map(|id| history.conversation(&id)) + .filter_map(|descendant| history.conversation(&descendant.conversation_id)) .collect(); // Nothing to show if the orchestrator has no children yet. diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 5962e94f4a3..2ae881326ec 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -838,6 +838,10 @@ impl BlocklistAIHistoryModel { } conversation.set_pinned(pinned); conversation.write_updated_conversation_state(ctx); + ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata { + terminal_surface_id: self.terminal_surface_id_for_conversation(&conversation_id), + conversation_id, + }); } /// Sets a live conversation's server token, updates the reverse index, and diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index e1d95f34f01..dfe3783f7c4 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -5,6 +5,8 @@ //! orchestration pill bar so other surfaces (e.g. keyboard navigation and //! the agent-mode usage footer's credit rollup) can walk and order the same //! tree without duplicating the logic. +#[cfg(feature = "tui")] +use std::collections::HashSet; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::blocklist::BlocklistAIHistoryModel; @@ -100,6 +102,29 @@ pub fn resolve_orchestration_participant( } } +/// Returns the topmost loaded conversation in an orchestration tree. +/// +/// Conversations without descendants are not orchestration roots. Malformed +/// parent cycles and missing ancestors fail closed. +#[cfg(feature = "tui")] +pub fn orchestration_root_conversation_id( + 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) = history.resolved_parent_conversation_id_for_conversation(conversation) + else { + return (!history.child_conversation_ids_of(¤t).is_empty()).then_some(current); + }; + current = parent; + } + None +} + const DONE_STATUS_KEY: u8 = 3; fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 { @@ -181,6 +206,13 @@ pub fn has_local_orchestrated_children( }) } +/// One descendant in canonical orchestration pill order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OrderedOrchestrationDescendant { + pub conversation_id: AIConversationId, + pub spawn_index: usize, +} + /// Returns descendants in the canonical orchestration pill order: /// 1) pinned children /// 2) unpinned children @@ -189,10 +221,10 @@ pub fn has_local_orchestrated_children( /// This is the single ordering source used by both the pill bar and keyboard /// navigation. Callers should preserve the returned order rather than sorting /// the conversations again. -pub fn descendant_conversation_ids_in_pill_order( +pub fn descendant_conversations_in_pill_order( history: &BlocklistAIHistoryModel, parent_id: AIConversationId, -) -> Vec { +) -> Vec { let mut descendants = descendant_conversation_ids_in_spawn_order(history, parent_id) .into_iter() .enumerate() @@ -223,7 +255,12 @@ pub fn descendant_conversation_ids_in_pill_order( ); descendants .into_iter() - .map(|(_, _, _, _, conversation_id)| conversation_id) + .map( + |(_, _, _, spawn_index, conversation_id)| OrderedOrchestrationDescendant { + conversation_id, + spawn_index, + }, + ) .collect() } @@ -243,12 +280,16 @@ pub fn adjacent_orchestration_child_conversation_id( let orchestration_root_id = history .resolved_parent_conversation_id_for_conversation(active_conversation) .unwrap_or(active_conversation_id); - let descendant_ids = descendant_conversation_ids_in_pill_order(history, orchestration_root_id); - if descendant_ids.is_empty() { + let descendants = descendant_conversations_in_pill_order(history, orchestration_root_id); + if descendants.is_empty() { return None; } let conversation_ids = std::iter::once(orchestration_root_id) - .chain(descendant_ids) + .chain( + descendants + .into_iter() + .map(|descendant| descendant.conversation_id), + ) .collect::>(); let active_index = conversation_ids diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 51d6dcfaa6e..7b5011843b9 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -68,8 +68,10 @@ 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, + descendant_conversation_ids_in_spawn_order, descendant_conversations_in_pill_order, + orchestration_root_conversation_id, orchestrator_agent_id_for_conversation, + resolve_orchestration_participant, OrchestrationParticipantKind, + OrderedOrchestrationDescendant, ResolvedOrchestrationParticipant, }; pub use crate::ai::blocklist::view_util::format_credits; #[cfg(not(target_family = "wasm"))] diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index f9973dc0f9f..61a52cfaaea 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -22,6 +22,7 @@ //! See `specs/tui-input-view/TECH.md` for the full keybinding table. use std::ops::Range; +use std::rc::Rc; use string_offset::CharOffset; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; @@ -115,6 +116,8 @@ pub enum TuiInputViewEvent { AcceptedModel(LLMId), /// The user selected an action from the MCP menu. AcceptedMcp(TuiMcpAction), + /// Shift+Up should move focus from the first visual row to the region above. + MoveFocusUp, } // ───────────────────────────────────────────────────────────────────────────── @@ -170,6 +173,8 @@ pub struct TuiInputView { /// construction always provides this; isolated input tests omit it. transcript: Option>, keyboard_enhancement_supported: bool, + /// Consults the owner live before Shift+Up leaves the first visual row. + can_move_focus_up: Rc bool>, } impl Entity for TuiInputView { @@ -196,6 +201,7 @@ impl TuiInputView { suggestions_mode: ModelHandle, inline_menus: Vec, transcript: ViewHandle, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { Self::new_internal( @@ -204,6 +210,7 @@ impl TuiInputView { suggestions_mode, inline_menus, Some(transcript), + can_move_focus_up, ctx, ) } @@ -214,9 +221,18 @@ impl TuiInputView { input_mode: ModelHandle, suggestions_mode: ModelHandle, inline_menus: Vec, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { - Self::new_internal(model, input_mode, suggestions_mode, inline_menus, None, ctx) + Self::new_internal( + model, + input_mode, + suggestions_mode, + inline_menus, + None, + can_move_focus_up, + ctx, + ) } fn new_internal( @@ -225,6 +241,7 @@ impl TuiInputView { suggestions_mode: ModelHandle, inline_menus: Vec, transcript: Option>, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { ctx.subscribe_to_model(&model, |_, _, event, ctx| { @@ -247,6 +264,7 @@ impl TuiInputView { focused: false, transcript, keyboard_enhancement_supported: false, + can_move_focus_up: Rc::new(can_move_focus_up), } } @@ -463,6 +481,10 @@ impl TypedActionView for TuiInputView { TuiEditorInteractionOutcome::FollowCursor } TuiInputAction::EditorCommand(command) => { + if matches!(*command, TuiEditorCommand::SelectUp) && self.can_focus_above(ctx) { + ctx.emit(TuiInputViewEvent::MoveFocusUp); + return; + } // Only open the conversation list from normal agent input; in // `!` shell mode the `!` prefix is not part of `plain_text`, so // an empty shell command would otherwise trip this branch and @@ -562,6 +584,26 @@ impl TuiInputView { self.cursor_offset(ctx).as_usize() <= 1 && self.selection_range(ctx).is_none() } + /// Whether Shift+Up should leave the input instead of extending selection. + fn can_focus_above(&self, ctx: &AppContext) -> bool { + if !(self.can_move_focus_up)(ctx) || self.selection_range(ctx).is_some() { + return false; + } + + let model = self.model.as_ref(ctx); + let render = model.render_state().as_ref(ctx); + let Some(char_cell) = render.char_cell() else { + return false; + }; + + let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); + let hidden = char_cell.hidden_line_ranges(ctx); + char_cell + .display_lattice(&hidden) + .offset_to_display_point(cursor_offset) + .is_some_and(|point| point.row == 0) + } + // ── Scroll ───────────────────────────────────────────────────────────── // // The scroll offset and its clamping/follow policy live on the char-cell diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index f66230dda13..833f49bc443 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -3,7 +3,7 @@ //! These drive a real [`CodeEditorModel`] (TUI char-cell mode) behind a real //! [`TuiInputView`] so they exercise the exact render/layout/cursor path the //! presenter uses, not a reimplementation of it. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::ops::Range; use std::rc::Rc; @@ -314,7 +314,14 @@ fn build_view(ctx: &mut AppContext) -> ViewHandle { }, |ctx| { let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); - TuiInputView::new_for_test(model, input_mode, suggestions_mode, Vec::new(), ctx) + TuiInputView::new_for_test( + model, + input_mode, + suggestions_mode, + Vec::new(), + |_| false, + ctx, + ) }, ); view @@ -349,6 +356,7 @@ fn build_view_with_conversation_menu( input_mode, suggestions_mode, vec![inline_menu_for_view], + |_| false, ctx, ) }, @@ -400,6 +408,7 @@ fn build_view_with_inline_menu( input_mode, suggestions_mode, vec![inline_menu], + |_| false, ctx, ) }, @@ -441,6 +450,7 @@ fn build_view_with_model_menu( input_mode, suggestions_mode, vec![inline_menu], + |_| false, ctx, ) }, @@ -624,6 +634,100 @@ fn dispatch(view: &ViewHandle, ctx: &mut AppContext, actions: &[Tu }); } +#[test] +fn shift_up_requests_focus_above_only_on_first_row_without_selection() { + App::test((), |mut app| async move { + let (view, requests, available) = app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + add_test_semantic_selection(ctx); + let input_mode = BlocklistAIInputModel::mock(Rc::new(TestInputModePolicy), ctx); + let suggestions_mode = add_suggestions_mode(ctx, TuiInputSuggestionsMode::Closed); + let available = Rc::new(Cell::new(false)); + let available_for_view = available.clone(); + let (_window_id, view) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + move |ctx| { + let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); + TuiInputView::new_for_test( + model, + input_mode, + suggestions_mode, + Vec::new(), + move |_| available_for_view.get(), + ctx, + ) + }, + ); + let requests = Rc::new(RefCell::new(0usize)); + let captured = requests.clone(); + ctx.subscribe_to_view(&view, move |_, event, _| { + if let TuiInputViewEvent::MoveFocusUp = event { + *captured.borrow_mut() += 1; + } + }); + (view, requests, available) + }); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!(*requests.borrow(), 0); + + available.set(true); + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!(*requests.borrow(), 1); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::Editor(TuiEditorAction::InsertText( + "first\nsecond".to_string(), + ))], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!( + *requests.borrow(), + 1, + "second visual row stays in the input" + ); + + app.update(|ctx| { + view.update(ctx, |view, ctx| view.clear(ctx)); + type_str(&view, ctx, "abc"); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectLeft)], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!(*requests.borrow(), 1, "active selection stays in the input"); + }); +} + #[test] fn clear_selection_collapses_to_head_without_changing_text() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index b2a3b36a09c..d70ad108f97 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -16,6 +16,7 @@ const LOCATION_CLOUD_ID: &str = "cloud"; /// therefore always uses Oz. pub(super) fn normalize_tui_local_harness(state: &mut OrchestrationConfigState) { if matches!(state.execution_mode, RunAgentsExecutionMode::Local) + && !state.harness_type.trim().is_empty() && !state.harness_type.eq_ignore_ascii_case("oz") { state.harness_type = "oz".to_string(); diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 4ed26bf5360..aab1043de06 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -167,6 +167,16 @@ fn unapproved_local_request_forces_oz_harness() { assert_eq!(state.harness_type, "oz"); assert_eq!(state.model_id, ""); } +#[test] +fn local_request_with_implicit_oz_harness_preserves_explicit_model() { + let mut incoming = request("", RunAgentsExecutionMode::Local); + incoming.model_id = "claude-3-5-haiku".to_string(); + + let state = TuiOrchestrationBlock::config_state_from_request(&incoming, None); + + assert_eq!(state.harness_type, ""); + assert_eq!(state.model_id, "claude-3-5-haiku"); +} #[test] fn build_request_carries_card_fields_and_edited_run_wide_state() { diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 15d60927c07..de981edb6c2 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -1,13 +1,14 @@ -//! [`TuiOrchestrationModel`]: the TUI's child-agent coordinator. +//! [`TuiOrchestrationModel`]: TUI orchestration runtime and navigation state. //! //! The shared `StartAgentExecutor` (one per session surface) emits //! `CreateAgent` and waits for a frontend to materialize the child. In the //! GUI that materializer is `TerminalView` → `PaneGroup`'s hidden child //! panes; in the TUI, [`crate::session_registry::TuiSessions`] owns //! materialization. This singleton prepares native Oz children, requests -//! background session lifecycle changes, and tracks the session dimension of -//! the orchestration tree — conversation lineage itself stays in -//! `BlocklistAIHistoryModel`. +//! background session lifecycle changes, tracks the session dimension of the +//! orchestration tree, and projects that tree into the single visible tab bar. +//! Conversation lineage and ordering policy stay in `BlocklistAIHistoryModel` +//! and the shared topology helpers. //! //! Native (Oz) local children run in background TUI sessions. Local //! CLI-harness and remote child requests resolve with an explicit failure. @@ -16,18 +17,40 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use warp::tui_export::{ - apply_child_agent_model_override, inherit_child_agent_settings, prepare_local_oz_child_launch, - register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, - BlocklistAIHistoryModel, ConversationStatus, Harness, RenderableAIError, - StartAgentExecutionMode, StartAgentRequest, + apply_child_agent_model_override, descendant_conversations_in_pill_order, + inherit_child_agent_settings, orchestration_root_conversation_id, + prepare_local_oz_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, + AIConversationId, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, + Harness, RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; -use crate::session_registry::TuiSessionId; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::tab_bar::TuiTabBarPagingState; use crate::terminal_session_view::TuiTerminalSessionView; -/// The TUI's child-agent coordinator singleton. See the module docs. +/// One navigable child conversation in an orchestration snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationChild { + pub(crate) conversation_id: AIConversationId, + pub(crate) label: String, + pub(crate) spawn_index: usize, +} + +/// Live semantic state for the orchestration tab bar. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationSnapshot { + pub(crate) root_conversation_id: AIConversationId, + pub(crate) selected_conversation_id: AIConversationId, + pub(crate) children: Vec, + /// Stable child ID used to resolve the page start at the current width. + pub(crate) page_anchor: Option, + /// Whether the tab bar may override the anchor to reveal the selection. + pub(crate) reveal_selected: bool, +} + +/// The TUI's orchestration singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension /// only — conversation lineage is read from `BlocklistAIHistoryModel` @@ -35,6 +58,8 @@ pub(crate) struct TuiOrchestrationModel { child_session_by_conversation: HashMap, /// Conversations whose event streams are consumed by each live session. event_consumers_by_session: HashMap>, + /// Paging intent shared by the per-session tab-bar views. + tab_bar_paging: TuiTabBarPagingState, } pub(crate) enum TuiOrchestrationEvent { CreateLocalOzChildSession { @@ -47,6 +72,7 @@ pub(crate) enum TuiOrchestrationEvent { }, RemoveChildSession(TuiSessionId), } + pub(crate) struct MaterializedLocalOzChildSession { pub(crate) parent_session_id: TuiSessionId, pub(crate) session_id: TuiSessionId, @@ -66,12 +92,134 @@ 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 { + let history = BlocklistAIHistoryModel::handle(ctx); + let model = ctx.add_singleton_model(|_| Self { child_session_by_conversation: HashMap::new(), event_consumers_by_session: HashMap::new(), + tab_bar_paging: TuiTabBarPagingState::default(), + }); + let model_for_history = model.clone(); + ctx.subscribe_to_model(&history, move |_, event, ctx| { + let topology_changed = match event { + BlocklistAIHistoryEvent::StartedNewConversation { .. } + | BlocklistAIHistoryEvent::AppendedExchange { .. } + | BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } + | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } + | BlocklistAIHistoryEvent::SplitConversation { .. } + | BlocklistAIHistoryEvent::RemoveConversation { .. } + | BlocklistAIHistoryEvent::DeletedConversation { .. } + | BlocklistAIHistoryEvent::RestoredConversations { .. } + | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } + | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { + .. + } => true, + BlocklistAIHistoryEvent::CreatedSubtask { .. } + | BlocklistAIHistoryEvent::UpgradedTask { .. } + | BlocklistAIHistoryEvent::ReassignedExchange { .. } + | BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } + | BlocklistAIHistoryEvent::SetActiveConversation { .. } + | BlocklistAIHistoryEvent::ClearedActiveConversation { .. } + | BlocklistAIHistoryEvent::UpdatedTodoList { .. } + | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } + | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } + | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } + | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } + | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } + | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => false, + }; + + if topology_changed { + model_for_history.update(ctx, |model, ctx| model.topology_changed(ctx)); + } + }); + model + } + + /// Builds the current navigable tab tree for a selected conversation. + pub(crate) fn snapshot( + &self, + selected_conversation_id: AIConversationId, + ctx: &AppContext, + ) -> Option { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let root_conversation_id = + orchestration_root_conversation_id(history, selected_conversation_id)?; + let sessions = TuiSessions::as_ref(ctx); + let session_ids_by_conversation = sessions.session_ids_by_conversation(history); + session_ids_by_conversation.get(&root_conversation_id)?; + + let children = descendant_conversations_in_pill_order(history, root_conversation_id) + .into_iter() + .filter_map(|descendant| { + let conversation_id = descendant.conversation_id; + session_ids_by_conversation.get(&conversation_id)?; + let conversation = history.conversation(&conversation_id)?; + Some(TuiOrchestrationChild { + conversation_id, + label: conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_owned(), + spawn_index: descendant.spawn_index, + }) + }) + .collect::>(); + if children.is_empty() { + return None; + } + + let resolved_page = self.tab_bar_paging.resolve( + children.first().map(|child| child.conversation_id), + |anchor| { + children + .iter() + .any(|child| child.conversation_id == *anchor) + }, + ); + Some(TuiOrchestrationSnapshot { + root_conversation_id, + selected_conversation_id, + children, + page_anchor: resolved_page.page_anchor, + reveal_selected: resolved_page.reveal_selected, }) } + /// Stores an explicitly selected secondary page without switching sessions. + pub(crate) fn set_explicit_page( + &mut self, + page_anchor: AIConversationId, + ctx: &mut ModelContext, + ) { + self.tab_bar_paging.set_explicit_anchor(page_anchor); + ctx.notify(); + } + + /// Focuses the retained session for a conversation and resumes automatic reveal. + pub(crate) fn focus_conversation_session( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> Option { + let history = BlocklistAIHistoryModel::as_ref(ctx); + orchestration_root_conversation_id(history, conversation_id)?; + let session_id = *TuiSessions::as_ref(ctx) + .session_ids_by_conversation(history) + .get(&conversation_id)?; + self.tab_bar_paging.clear_explicit_anchor(); + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.focus_session(session_id, ctx); + }); + Some(session_id) + } + + fn topology_changed(&mut self, ctx: &mut ModelContext) { + ctx.notify(); + } + /// Routes a `CreateAgent` request the same two ways as the GUI's /// per-mode dispatch, with unsupported modes resolving as clean per-child /// failures. @@ -195,6 +343,9 @@ impl TuiOrchestrationModel { self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); self.register_event_consumer(session_id, conversation_id, ctx); + session_view.update(ctx, |view, ctx| { + view.initialize_orchestrated_child_conversation(conversation_id, ctx); + }); let prompt = request.prompt; session_view.update(ctx, |view, ctx| { @@ -278,6 +429,7 @@ impl TuiOrchestrationModel { } self.child_session_by_conversation .retain(|_, child_session_id| *child_session_id != session_id); + ctx.notify(); } } diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 1d4b7e33ddb..62806f8e84a 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,5 +1,5 @@ use warp::tui_export::{ - register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, Harness, StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, }; use warpui::platform::WindowStyle; @@ -42,6 +42,32 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } +fn add_child_session( + app: &mut App, + fixture: &OrchestrationFixture, + parent_conversation_id: AIConversationId, + name: &str, +) -> (TuiSessionId, AIConversationId) { + let (session, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update(|ctx| { + TuiSessions::register_session(&fixture.sessions, session, manager, false, ctx) + }); + let conversation_id = app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + session_id.surface_id(), + name.to_owned(), + parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + history.set_active_conversation_id(conversation_id, session_id.surface_id(), ctx); + conversation_id + }) + }); + (session_id, conversation_id) +} + /// Registers a session with a live active conversation. fn add_dispatching_session( app: &mut App, @@ -51,7 +77,6 @@ fn add_dispatching_session( let (session, manager) = add_test_terminal_session(app, fixture.window_id); app.update(|ctx| TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx)) } - /// Creates a standalone executor and relays its frontend materialization /// events into the coordinator. fn add_relayed_executor( @@ -166,6 +191,107 @@ fn local_harness_children_fail_cleanly() { }); } +#[test] +fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let parent_session_id = add_dispatching_session(&mut app, &fixture, true); + let parent_conversation_id = app.read(|ctx| { + BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(parent_session_id.surface_id()) + .expect("parent conversation") + .id() + }); + let (first_session_id, first_child_id) = + add_child_session(&mut app, &fixture, parent_conversation_id, "first-child"); + let (second_session_id, second_child_id) = + add_child_session(&mut app, &fixture, parent_conversation_id, "second-child"); + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.start_new_child_conversation( + warpui::EntityId::new(), + "missing-session".to_owned(), + parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + }); + }); + + app.read(|ctx| { + let model = TuiOrchestrationModel::as_ref(ctx); + let parent = model + .snapshot(parent_conversation_id, ctx) + .expect("parent has navigable children"); + let child = model + .snapshot(first_child_id, ctx) + .expect("child resolves the same tree"); + assert_eq!(parent.root_conversation_id, parent_conversation_id); + assert_eq!(child.root_conversation_id, parent_conversation_id); + assert_eq!( + parent + .children + .iter() + .map(|child| child.conversation_id) + .collect::>(), + vec![first_child_id, second_child_id] + ); + assert_eq!( + parent + .children + .iter() + .map(|child| child.spawn_index) + .collect::>(), + vec![0, 1] + ); + }); + + app.update(|ctx| { + let selected = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.focus_conversation_session(second_child_id, ctx) + }); + assert_eq!(selected, Some(second_session_id)); + }); + app.read(|ctx| { + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .snapshot(second_child_id, ctx) + .expect("tab snapshot"); + assert_eq!(snapshot.page_anchor, Some(first_child_id)); + assert!(snapshot.reveal_selected); + }); + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.set_explicit_page(second_child_id, ctx); + }); + }); + app.read(|ctx| { + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .snapshot(parent_conversation_id, ctx) + .expect("tab snapshot"); + assert_eq!(snapshot.page_anchor, Some(second_child_id)); + assert!(!snapshot.reveal_selected); + }); + + app.update(|ctx| { + let selected = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.focus_conversation_session(first_child_id, ctx) + }); + assert_eq!(selected, Some(first_session_id)); + }); + app.read(|ctx| { + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .snapshot(first_child_id, ctx) + .expect("tab snapshot"); + assert_eq!( + TuiSessions::as_ref(ctx).focused_session_id(), + Some(first_session_id) + ); + assert_eq!(snapshot.page_anchor, Some(first_child_id)); + assert!(snapshot.reveal_selected); + }); + }); +} + #[test] fn remote_children_fail_cleanly() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index bb2e9c3d817..9babe6bf1d5 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -9,8 +9,8 @@ use std::path::PathBuf; use pathfinder_geometry::vector::Vector2F; use warp::tui_export::{ - BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, ServerConversationToken, - TerminalManagerTrait, TerminalSurfaceResult, + AIConversationId, BannerState, BlocklistAIHistoryModel, IsSharedSessionCreator, + LocalTtyTerminalManager, ServerConversationToken, TerminalManagerTrait, TerminalSurfaceResult, }; use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; @@ -194,6 +194,34 @@ impl TuiSessions { orchestration: &ModelHandle, ctx: &mut AppContext, ) { + let sessions_for_model_updates = sessions.clone(); + ctx.observe_model(orchestration, move |_, ctx| { + let focused_view = sessions_for_model_updates + .as_ref(ctx) + .focused_session() + .map(|session| session.view().clone()); + if let Some(focused_view) = focused_view { + focused_view.update(ctx, |view, ctx| { + view.refresh_orchestration_tab_state(ctx); + }); + } + }); + + let sessions_for_focus_updates = sessions.clone(); + ctx.subscribe_to_model(sessions, move |_, event, ctx| { + let TuiSessionsEvent::FocusChanged(session_id) = event else { + return; + }; + let focused_view = sessions_for_focus_updates + .as_ref(ctx) + .session(*session_id) + .map(|session| session.view().clone()); + if let Some(focused_view) = focused_view { + focused_view.update(ctx, |view, ctx| { + view.refresh_orchestration_tab_state(ctx); + }); + } + }); let sessions = sessions.clone(); let orchestration_for_events = orchestration.clone(); ctx.subscribe_to_model(orchestration, move |_, event, ctx| match event { @@ -325,6 +353,21 @@ impl TuiSessions { self.sessions.iter().find(|session| session.id == id) } + /// Builds the loaded conversation-to-session index used by one topology snapshot. + pub(crate) fn session_ids_by_conversation( + &self, + history: &BlocklistAIHistoryModel, + ) -> HashMap { + self.sessions + .iter() + .flat_map(|session| { + history + .all_live_conversations_for_terminal_surface(session.id.surface_id()) + .map(move |conversation| (conversation.id(), session.id)) + }) + .collect() + } + /// Whether no session has been registered. pub(crate) fn is_empty(&self) -> bool { self.sessions.is_empty() diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs index 16bead73c37..29a1664fbfa 100644 --- a/crates/warp_tui/src/tab_bar.rs +++ b/crates/warp_tui/src/tab_bar.rs @@ -24,7 +24,7 @@ const DIVIDER_PADDING_RIGHT: u16 = 2; const ELLIPSIS: &str = "..."; /// Stable tab data rendered by [`TuiTabBarView`]. -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct TuiTab { pub key: String, pub label: String, @@ -32,7 +32,7 @@ pub struct TuiTab { } /// Styled text rendered before a tab label. -#[derive(Clone)] +#[derive(Clone, PartialEq)] struct TuiTabLeading { text: String, style: TuiStyle, @@ -59,7 +59,7 @@ impl TuiTab { } /// Caller-supplied styles for the tab bar and its semantic states. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TuiTabBarStyles { pub background: Option, pub leading: TuiStyle, @@ -70,7 +70,7 @@ pub struct TuiTabBarStyles { } /// Caller-owned semantic state and presentation options for a tab bar. -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct TuiTabBarConfig { pub leading: Option, pub main_tab: Option, @@ -104,6 +104,54 @@ impl TuiTabBarConfig { } } +/// Caller-owned responsive paging intent for a tab bar. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TuiTabBarPagingState { + explicit_anchor: Option, +} +impl Default for TuiTabBarPagingState { + fn default() -> Self { + Self { + explicit_anchor: None, + } + } +} + +impl TuiTabBarPagingState { + /// Preserves the page beginning at `anchor` instead of revealing selection. + pub(crate) fn set_explicit_anchor(&mut self, anchor: K) { + self.explicit_anchor = Some(anchor); + } + + /// Resumes automatic selected-tab reveal. + pub(crate) fn clear_explicit_anchor(&mut self) { + self.explicit_anchor = None; + } +} + +impl TuiTabBarPagingState { + /// Resolves paging intent against the owner's current ordered keys. + pub(crate) fn resolve( + &self, + default_anchor: Option, + explicit_anchor_is_valid: impl FnOnce(&K) -> bool, + ) -> TuiTabBarResolvedPage { + let explicit_anchor = self + .explicit_anchor + .as_ref() + .and_then(|anchor| explicit_anchor_is_valid(anchor).then_some(anchor)); + TuiTabBarResolvedPage { + page_anchor: explicit_anchor.cloned().or(default_anchor), + reveal_selected: explicit_anchor.is_none(), + } + } +} + +/// Effective paging inputs resolved from [`TuiTabBarPagingState`]. +pub(crate) struct TuiTabBarResolvedPage { + pub(crate) page_anchor: Option, + pub(crate) reveal_selected: bool, +} /// Invalid caller-supplied tab-bar configuration. #[derive(Clone, Debug, Eq, PartialEq)] pub enum TuiTabBarConfigError { @@ -210,6 +258,9 @@ impl TuiTabBarView { config: TuiTabBarConfig, ctx: &mut ViewContext, ) -> Result<(), TuiTabBarConfigError> { + if self.config == config { + return Ok(()); + } let live_keys = validated_live_keys(&config)?; self.config = config; self.reconcile_mouse_states(live_keys); @@ -224,6 +275,10 @@ impl TuiTabBarView { self.mouse_states.entry(key).or_default(); } } + /// Whether the current configuration contains any main or secondary tabs. + pub(crate) fn has_tabs(&self) -> bool { + self.config.main_tab.is_some() || !self.config.tabs.is_empty() + } /// Resolves the adjacent tab in semantic order, wrapping at either end. pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { diff --git a/crates/warp_tui/src/tab_bar_tests.rs b/crates/warp_tui/src/tab_bar_tests.rs index 224a1a80798..9823d8d0cb1 100644 --- a/crates/warp_tui/src/tab_bar_tests.rs +++ b/crates/warp_tui/src/tab_bar_tests.rs @@ -7,7 +7,8 @@ use warpui_core::{App, AppContext, TuiView}; use super::{ deterministic_pages_at_width, minimum_label_width, minimum_row_width, page_variant_at_width, validated_live_keys, TuiTab, TuiTabBarConfig, TuiTabBarConfigError, - TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, TuiTabBarStyles, TuiTabBarView, + TuiTabBarNavigationDirection, TuiTabBarPagingState, TuiTabBarSecondaryEdge, TuiTabBarStyles, + TuiTabBarView, }; fn key(key: u8) -> String { @@ -38,6 +39,40 @@ fn view(config: TuiTabBarConfig) -> TuiTabBarView { TuiTabBarView::new(config).unwrap() } +#[test] +fn tab_availability_is_derived_from_the_retained_config() { + let empty = view(config(Vec::new())); + assert!(!empty.has_tabs()); + + let secondary = view(config(vec![tab(1, "one")])); + assert!(secondary.has_tabs()); + + let mut main_only = config(Vec::new()); + main_only.main_tab = Some(tab(1, "main")); + assert!(view(main_only).has_tabs()); +} +#[test] +fn paging_state_preserves_only_a_valid_explicit_anchor() { + let mut state = TuiTabBarPagingState::default(); + let automatic = state.resolve(Some(key(1)), |_| false); + assert_eq!(automatic.page_anchor, Some(key(1))); + assert!(automatic.reveal_selected); + + state.set_explicit_anchor(key(2)); + let explicit = state.resolve(Some(key(1)), |anchor| anchor == "2"); + assert_eq!(explicit.page_anchor, Some(key(2))); + assert!(!explicit.reveal_selected); + + let invalid = state.resolve(Some(key(1)), |_| false); + assert_eq!(invalid.page_anchor, Some(key(1))); + assert!(invalid.reveal_selected); + + state.clear_explicit_anchor(); + let cleared = state.resolve(Some(key(1)), |_| true); + assert_eq!(cleared.page_anchor, Some(key(1))); + assert!(cleared.reveal_selected); +} + fn render( view: &TuiTabBarView, width: u16, diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index befaea02f1d..c02159fd981 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -70,11 +70,17 @@ use crate::keybindings::{ }; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; +use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::orchestration_block::TuiOrchestrationBlock; +use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationSnapshot}; use crate::resume::TuiExitSummaryHandle; use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; +use crate::tab_bar::{ + TuiTab, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, + TuiTabBarView, +}; use crate::terminal_content_element::TuiTerminalContentElement; use crate::terminal_use::{ hide_agent_requested_command_from_top_level, terminal_use_conversation_to_resume, @@ -97,6 +103,8 @@ use self::input_detection::InputDetectionState; /// Width used before the first layout pass pushes the real terminal width into the editor. const INITIAL_INPUT_WIDTH: u16 = 80; const MAX_INPUT_TEXT_ROWS: u16 = 6; +const ORCHESTRATION_TAB_BAR_FOCUSED_FLAG: &str = "TuiOrchestrationTabBarFocused"; +const ORCHESTRATION_TAB_LABEL_MAX_COLUMNS: u16 = 20; /// The footer hint shown while the ctrl-c exit confirmation is armed. const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit"; @@ -240,6 +248,16 @@ pub(crate) enum TuiTerminalSessionAction { ForwardUserPtyBytes(Vec), /// Toggle the latest exposed inline plan. TogglePlan, + /// Return keyboard focus from tabs to the session's default interaction target. + FocusDefaultInteractionTarget, + /// Select the previous tab using the tab view's semantic order. + SelectPreviousOrchestrationTab, + /// Select the next tab using the tab view's semantic order. + SelectNextOrchestrationTab, + /// Select the first child tab, excluding the orchestrator. + SelectFirstOrchestrationChild, + /// Select the last child tab, excluding the orchestrator. + SelectLastOrchestrationChild, } /// The authenticated terminal/session surface rendered inside [`RootTuiView`]. @@ -296,6 +314,8 @@ pub(crate) struct TuiTerminalSessionView { /// detect blocker transitions in [`Self::sync_blocker_focus`]. Input /// visibility itself is derived at render time, never stored. active_blocker_view_id: Option, + orchestration_tab_bar: ViewHandle, + orchestration_tabs_focused: bool, } /// Registers the session surface's keybindings. Called once at TUI startup @@ -344,6 +364,69 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP) .with_key_binding("ctrl-p"), ]); + + // Tab navigation is user-remappable, unlike the reserved session-control + // bindings above, so the two groups use different registration APIs. + let tab_context = + id!(TuiTerminalSessionView::ui_name()) & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + app.register_editable_bindings([ + EditableBinding::new( + "tui:orchestration_tabs:previous", + "Select the previous orchestration tab", + TuiTerminalSessionAction::SelectPreviousOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("left"), + EditableBinding::new( + "tui:orchestration_tabs:previous", + "Select the previous orchestration tab", + TuiTerminalSessionAction::SelectPreviousOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-tab"), + EditableBinding::new( + "tui:orchestration_tabs:next", + "Select the next orchestration tab", + TuiTerminalSessionAction::SelectNextOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("right"), + EditableBinding::new( + "tui:orchestration_tabs:next", + "Select the next orchestration tab", + TuiTerminalSessionAction::SelectNextOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("tab"), + EditableBinding::new( + "tui:orchestration_tabs:first_child", + "Select the first child agent", + TuiTerminalSessionAction::SelectFirstOrchestrationChild, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-left"), + EditableBinding::new( + "tui:orchestration_tabs:last_child", + "Select the last child agent", + TuiTerminalSessionAction::SelectLastOrchestrationChild, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-right"), + EditableBinding::new( + "tui:orchestration_tabs:focus_input", + "Return focus to the session input", + TuiTerminalSessionAction::FocusDefaultInteractionTarget, + ) + .with_context_predicate(tab_context) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-down"), + ]); } impl TuiTerminalSessionView { @@ -358,12 +441,18 @@ impl TuiTerminalSessionView { self.focus_current_owner_if_active(ctx); } - fn focus_current_owner(&self, ctx: &mut ViewContext) { + fn focus_current_owner(&mut self, ctx: &mut ViewContext) { match self.input_target() { - TuiInputTarget::Disabled | TuiInputTarget::Pty => ctx.focus_self(), + TuiInputTarget::Disabled | TuiInputTarget::Pty => { + self.orchestration_tabs_focused = false; + ctx.focus_self(); + } TuiInputTarget::AgentEditor => { if let Some(blocker) = self.active_blocking_child(ctx) { + self.orchestration_tabs_focused = false; ctx.focus(&blocker); + } else if self.orchestration_tabs_focused { + ctx.focus_self(); } else { ctx.focus(&self.input_view); } @@ -371,9 +460,14 @@ impl TuiTerminalSessionView { } } - fn focus_current_owner_if_active(&self, ctx: &mut ViewContext) { + fn focus_current_owner_if_active(&mut self, ctx: &mut ViewContext) { if self.is_focused_session(ctx) { + let tabs_were_focused = self.orchestration_tabs_focused; self.focus_current_owner(ctx); + if tabs_were_focused && !self.orchestration_tabs_focused { + self.refresh_orchestration_tab_bar(ctx); + ctx.notify(); + } } } @@ -382,6 +476,7 @@ impl TuiTerminalSessionView { ctx.focus(&self.input_view); } } + fn resume_after_user_controlled_command( &mut self, block_id: &BlockId, @@ -822,6 +917,8 @@ impl TuiTerminalSessionView { let inline_menus_for_input = inline_menus.clone(); let suggestions_mode_for_input = suggestions_mode.clone(); let transcript_for_input = transcript.clone(); + let orchestration_tab_bar = ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); + let orchestration_tab_bar_for_input = orchestration_tab_bar.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( input_editor_model, @@ -829,6 +926,7 @@ impl TuiTerminalSessionView { suggestions_mode_for_input, inline_menus_for_input, transcript_for_input, + move |ctx| orchestration_tab_bar_for_input.as_ref(ctx).has_tabs(), ctx, ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) @@ -865,6 +963,9 @@ impl TuiTerminalSessionView { TuiInputViewEvent::AcceptedMcp(action) => { view.handle_accepted_mcp_action(*action, ctx); } + TuiInputViewEvent::MoveFocusUp => { + view.focus_orchestration_tabs(ctx); + } }); ctx.subscribe_to_model(&action_model, |view, action_model, event, ctx| { let BlocklistAIActionEvent::FinishedAction { action_id, .. } = event else { @@ -880,6 +981,23 @@ impl TuiTerminalSessionView { ctx.focus(&view.input_view); } }); + ctx.subscribe_to_view(&orchestration_tab_bar, |view, _, event, ctx| match event { + TuiTabBarEvent::SelectTab(conversation_id) => { + view.switch_to_orchestration_tab( + Some(conversation_id.clone()), + view.orchestration_tabs_focused, + ctx, + ); + } + TuiTabBarEvent::PageChanged(page_anchor) => { + let Ok(page_anchor) = AIConversationId::try_from(page_anchor.clone()) else { + return; + }; + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.set_explicit_page(page_anchor, ctx); + }); + } + }); // The input box border color and the footer's shell-mode hint depend // on the input mode. ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| ctx.notify()); @@ -894,7 +1012,9 @@ impl TuiTerminalSessionView { ); ctx.subscribe_to_model(&conversation_selection, |view, _, _, ctx| { view.refresh_exit_summary(ctx); - ctx.notify(); + if view.is_focused_session(ctx) { + view.refresh_orchestration_tab_state(ctx); + } }); // The zero state's "What's new" section: fetch the changelog once at @@ -1057,16 +1177,7 @@ impl TuiTerminalSessionView { ctx.spawn_stream_local( throttle(WAKEUP_THROTTLE_PERIOD, wakeups_rx), |view, _, ctx| { - { - let mut model = view.terminal_model.lock(); - if !model.is_alt_screen_active() { - model.block_list_mut().update_background_block_height(); - model.block_list_mut().update_active_block_height(); - } - } - view.update_process_input_focus(ctx); - - ctx.notify(); + view.handle_terminal_wakeup(ctx); }, |_, _| {}, ); @@ -1104,6 +1215,8 @@ impl TuiTerminalSessionView { next_restore_request_id: 0, exit_summary, active_blocker_view_id: None, + orchestration_tab_bar, + orchestration_tabs_focused: false, } } @@ -1122,6 +1235,224 @@ impl TuiTerminalSessionView { }); } + /// Initializes a background child session with the conversation it owns. + pub(crate) fn initialize_orchestrated_child_conversation( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.conversation_selection.update(ctx, |selection, ctx| { + selection.select_existing_conversation(conversation_id, AgentViewEntryOrigin::Tui, ctx); + }); + } + + /// Resolves live semantic orchestration state for this session. + fn compute_orchestration_tab_snapshot( + &self, + ctx: &AppContext, + ) -> Option { + if !ctx.has_singleton_model::() + || !ctx.has_singleton_model::() + { + return None; + } + let selected_conversation_id = self + .conversation_selection + .as_ref(ctx) + .selected_conversation_id(ctx)?; + TuiOrchestrationModel::as_ref(ctx).snapshot(selected_conversation_id, ctx) + } + /// Refreshes this session's retained bar from live semantic state. + pub(crate) fn refresh_orchestration_tab_state(&mut self, ctx: &mut ViewContext) { + let snapshot = self.compute_orchestration_tab_snapshot(ctx); + let tabs_were_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); + if let Some(snapshot) = snapshot.as_ref() { + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + } else { + self.clear_orchestration_tab_bar(ctx); + } + let tabs_are_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); + let availability_changed = tabs_were_available != tabs_are_available; + let mut focus_changed = false; + if !tabs_are_available && self.orchestration_tabs_focused { + self.orchestration_tabs_focused = false; + focus_changed = true; + self.focus_current_owner(ctx); + } + if availability_changed || focus_changed { + ctx.notify(); + } + } + + /// Gives keyboard focus to the orchestration tab bar when it is available. + fn focus_orchestration_tabs(&mut self, ctx: &mut ViewContext) { + if !self.orchestration_tab_bar.as_ref(ctx).has_tabs() { + return; + } + self.set_orchestration_tab_focus(true, ctx); + } + + /// Applies tab-focus mode, synchronizes presentation, and resolves the focus owner. + fn set_orchestration_tab_focus(&mut self, focused: bool, ctx: &mut ViewContext) { + self.orchestration_tabs_focused = focused; + self.focus_current_owner(ctx); + self.refresh_orchestration_tab_bar(ctx); + ctx.notify(); + } + + fn refresh_orchestration_tab_bar(&self, ctx: &mut ViewContext) { + if let Some(snapshot) = self.compute_orchestration_tab_snapshot(ctx) { + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); + } + } + + fn switch_to_orchestration_tab( + &mut self, + key: Option, + keep_tab_focus: bool, + ctx: &mut ViewContext, + ) { + let Some(conversation_id) = key.and_then(|key| AIConversationId::try_from(key).ok()) else { + return; + }; + self.switch_to_orchestration_conversation(conversation_id, keep_tab_focus, ctx); + } + + /// Switches to the retained session that owns an orchestration conversation. + fn switch_to_orchestration_conversation( + &mut self, + conversation_id: AIConversationId, + keep_tab_focus: bool, + ctx: &mut ViewContext, + ) { + let session_id = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.focus_conversation_session(conversation_id, ctx) + }); + let Some(session_id) = session_id else { + return; + }; + if session_id.surface_id() == self.terminal_surface_id { + self.refresh_orchestration_tab_state(ctx); + self.set_orchestration_tab_focus(keep_tab_focus, ctx); + return; + } + self.orchestration_tabs_focused = false; + ctx.notify(); + let target_view = TuiSessions::as_ref(ctx) + .session(session_id) + .map(|session| session.view().clone()); + let Some(target_view) = target_view else { + return; + }; + target_view.update(ctx, |target, target_ctx| { + target.set_orchestration_tab_focus(keep_tab_focus, target_ctx); + }); + } + + /// Builds the tab child-view configuration for an orchestration snapshot. + fn orchestration_tab_bar_config( + &self, + snapshot: &TuiOrchestrationSnapshot, + builder: &TuiUiBuilder, + ) -> TuiTabBarConfig { + let palette = builder.agent_identity_palette(); + let mut children_in_spawn_order = snapshot.children.iter().collect::>(); + children_in_spawn_order.sort_by_key(|child| child.spawn_index); + let identity_indices = assign_agent_identity_indices( + children_in_spawn_order + .iter() + .map(|child| child.label.as_str()), + palette.len(), + ); + let identity_by_conversation = children_in_spawn_order + .into_iter() + .map(|child| child.conversation_id) + .zip(identity_indices) + .collect::>(); + let tabs = snapshot + .children + .iter() + .map(|child| { + let identity = palette + .get( + identity_by_conversation + .get(&child.conversation_id) + .copied() + .unwrap_or_default(), + ) + .or_else(|| palette.first()) + .cloned() + .unwrap_or_default(); + TuiTab::new(child.conversation_id.to_string(), child.label.clone()) + .with_leading_text(identity.glyph, identity.style) + }) + .collect(); + let mut config = TuiTabBarConfig::new(tabs); + config.leading = Some(" Agents: ".to_owned()); + config.main_tab = Some(TuiTab::new( + snapshot.root_conversation_id.to_string(), + "orchestrator", + )); + config.selected_key = Some(snapshot.selected_conversation_id.to_string()); + config.focused = self.orchestration_tabs_focused; + config.page_anchor = snapshot.page_anchor.map(|id| id.to_string()); + config.reveal_selected = snapshot.reveal_selected; + config.maximum_label_columns = Some(ORCHESTRATION_TAB_LABEL_MAX_COLUMNS); + config.secondary_gap_columns = 3; + config.styles = builder.orchestration_tab_bar_styles(); + config + } + + /// Synchronizes the retained tab child view from current orchestration state. + fn sync_orchestration_tab_bar( + &self, + snapshot: &TuiOrchestrationSnapshot, + builder: &TuiUiBuilder, + ctx: &mut ViewContext, + ) { + let config = self.orchestration_tab_bar_config(snapshot, builder); + self.set_orchestration_tab_bar_config(config, ctx); + } + + fn clear_orchestration_tab_bar(&self, ctx: &mut ViewContext) { + self.set_orchestration_tab_bar_config(TuiTabBarConfig::new(Vec::new()), ctx); + } + + fn set_orchestration_tab_bar_config( + &self, + config: TuiTabBarConfig, + ctx: &mut ViewContext, + ) { + let result = self + .orchestration_tab_bar + .update(ctx, |tab_bar, ctx| tab_bar.set_config(config, ctx)); + if let Err(error) = result { + report_error!( + anyhow::Error::new(error) + .context("Failed to update orchestration tab bar configuration"), + warp_errors::ReportErrorLogMode::OncePerRun + ); + } + } + + /// Footer shown while orchestration tabs own keyboard focus. + fn render_orchestration_tab_footer(&self, builder: &TuiUiBuilder) -> Box { + let primary = builder.primary_text_style(); + let muted = builder.muted_text_style(); + TuiText::from_spans([ + ("Tab or ← →".to_string(), primary), + (" to navigate ".to_string(), muted), + ("Shift + ← →".to_string(), primary), + (" to go to start/end ".to_string(), muted), + ("Shift + ↓".to_string(), primary), + (" to send a message".to_string(), muted), + ]) + .truncate() + .finish() + } + /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) @@ -1131,6 +1462,7 @@ impl TuiTerminalSessionView { pub(crate) fn activate(&mut self, ctx: &mut ViewContext) { self.focus_current_owner(ctx); self.write_exit_summary(ctx); + ctx.notify(); } /// Whether this view projects the focused session. @@ -1424,6 +1756,22 @@ impl TuiTerminalSessionView { ctx.emit(TuiTerminalSessionEvent::Resize(size_update)); ctx.notify(); } + /// Refreshes terminal model geometry and redraws only when this session is visible. + fn handle_terminal_wakeup(&mut self, ctx: &mut ViewContext) -> bool { + { + let mut model = self.terminal_model.lock(); + if !model.is_alt_screen_active() { + model.block_list_mut().update_background_block_height(); + model.block_list_mut().update_active_block_height(); + } + } + let is_focused = self.is_focused_session(ctx); + if is_focused { + self.update_process_input_focus(ctx); + ctx.notify(); + } + is_focused + } /// Re-renders on history events that can change the warping indicator: /// the selected conversation's status changing, or an exchange starting @@ -1565,11 +1913,11 @@ impl TuiTerminalSessionView { /// Builds the status footer under the input box. The left slot shows one /// hint at a time — the ctrl-c exit confirmation while armed, else a /// transient notice, else the shell-mode callout, else the conversations - /// callout while the input is empty and no inline menu is visible; the - /// active model and working directory are pushed to the right edge behind a - /// flex spacer. Every child truncates to a single row, so the row lays out - /// one row tall. - fn render_footer(&self, ctx: &AppContext) -> TuiFlex { + /// callout while the input is empty and no inline menu is visible, else the + /// orchestration-tab callout; the active model and working directory are + /// pushed to the right edge behind a flex spacer. Every child truncates to + /// a single row, so the row lays out one row tall. + fn render_footer(&self, orchestration_tabs_available: bool, ctx: &AppContext) -> TuiFlex { let builder = TuiUiBuilder::from_app(ctx); let muted = builder.muted_text_style(); let mut left = TuiFlex::row(); @@ -1593,6 +1941,8 @@ impl TuiTerminalSessionView { Some((transient, style)) } else if self.is_shell_mode(ctx) { Some((SHELL_MODE_HINT, builder.shell_mode_accent_style())) + } else if orchestration_tabs_available { + Some(("Shift + ↑ sub-agents", muted)) } else { None }; @@ -2302,11 +2652,18 @@ impl TuiView for TuiTerminalSessionView { } fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - vec![self.transcript.id(), self.input_view.id()] + vec![ + self.transcript.id(), + self.input_view.id(), + self.orchestration_tab_bar.id(), + ] } fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { let mut context = Self::default_keymap_context(); + if self.orchestration_tabs_focused && self.input_target().agent_editor_owns_input() { + context.set.insert(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + } if self.is_conversation_restore_loading() { context.set.insert(SESSION_CAN_CANCEL_RESTORE_FLAG); } @@ -2366,6 +2723,8 @@ impl TuiView for TuiTerminalSessionView { .and_then(|menu| menu.render(ctx)) }) .flatten(); + let builder = TuiUiBuilder::from_app(ctx); + let orchestration_tabs_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the // fixed binding registered in [`Self::init`], so no element-level key @@ -2457,7 +2816,6 @@ impl TuiView for TuiTerminalSessionView { .finish(), ); } - let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) { builder.shell_mode_accent_style() } else { @@ -2473,11 +2831,13 @@ impl TuiView for TuiTerminalSessionView { .with_max_rows(MAX_INPUT_TEXT_ROWS + 2) .finish(), ); - content = content.child( - TuiConstrainedBox::new(self.render_footer(ctx).finish()) - .with_max_rows(1) - .finish(), - ); + let footer = if self.orchestration_tabs_focused { + self.render_orchestration_tab_footer(&builder) + } else { + self.render_footer(orchestration_tabs_available, ctx) + .finish() + }; + content = content.child(TuiConstrainedBox::new(footer).with_max_rows(1).finish()); } let content = content.finish(); let terminal_content = @@ -2492,11 +2852,19 @@ impl TuiView for TuiTerminalSessionView { // the PTY's columns match the width block content actually renders at // (the GUI wraps its view root, but its padding is sub-cell; here it is // 4 whole columns). - TuiContainer::new(terminal_content.finish()) + let session = TuiContainer::new(terminal_content.finish()) .with_padding_x(2) .with_padding_top(2) .with_padding_bottom(1) - .finish() + .finish(); + if orchestration_tabs_available { + TuiFlex::column() + .child(TuiChildView::new(&self.orchestration_tab_bar).finish()) + .flex_child(session) + .finish() + } else { + session + } } } @@ -2513,6 +2881,37 @@ impl TypedActionView for TuiTerminalSessionView { self.hand_back_terminal_use_control(ctx) } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), + TuiTerminalSessionAction::FocusDefaultInteractionTarget => { + self.set_orchestration_tab_focus(false, ctx) + } + TuiTerminalSessionAction::SelectPreviousOrchestrationTab => { + let key = self + .orchestration_tab_bar + .as_ref(ctx) + .navigation_target(TuiTabBarNavigationDirection::Previous); + self.switch_to_orchestration_tab(key, true, ctx); + } + TuiTerminalSessionAction::SelectNextOrchestrationTab => { + let key = self + .orchestration_tab_bar + .as_ref(ctx) + .navigation_target(TuiTabBarNavigationDirection::Next); + self.switch_to_orchestration_tab(key, true, ctx); + } + TuiTerminalSessionAction::SelectFirstOrchestrationChild => { + let key = self + .orchestration_tab_bar + .as_ref(ctx) + .secondary_edge_target(TuiTabBarSecondaryEdge::First); + self.switch_to_orchestration_tab(key, true, ctx); + } + TuiTerminalSessionAction::SelectLastOrchestrationChild => { + let key = self + .orchestration_tab_bar + .as_ref(ctx) + .secondary_edge_target(TuiTabBarSecondaryEdge::Last); + self.switch_to_orchestration_tab(key, true, ctx); + } TuiTerminalSessionAction::ForwardUserPtyBytes(bytes) => { // Raw passthrough: the bytes are already the app's escape // sequence, so write them to the PTY unmodified. diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index fb72100d4c1..e2edef825d5 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -1,25 +1,73 @@ use warp::appearance::Appearance; use warp::tui_export::{ - export_conversation_markdown, PtyIntent, PtyIntentEvent, SizeInfo, SizeUpdate, + export_conversation_markdown, register_tui_session_view_test_singletons, PtyIntent, + PtyIntentEvent, SizeInfo, SizeUpdate, +}; +use warpui::platform::WindowStyle; +use warpui::{ + AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, SingletonEntity, UpdateModel, ViewHandle, }; -use warpui::EntityIdMap; use warpui_core::elements::tui::{ TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, }; use warpui_core::keymap::{Context, Keystroke, Trigger}; -use warpui_core::{App, AppContext}; +use warpui_core::{App, AppContext, TuiView}; use super::{ export_file_success_message, raw_prompt_if_not_blank, render_left_footer_hint, - TuiTerminalSessionEvent, + TuiTerminalSessionEvent, ORCHESTRATION_TAB_BAR_FOCUSED_FLAG, }; +use crate::autoupdate::TuiAutoupdater; use crate::keybindings::{ CONTEXTUAL_PLAN_TOGGLE_BINDING_NAME, KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG, PLAN_TOGGLE_AVAILABLE_FLAG, PLAN_TOGGLE_BINDING_NAME, }; +use crate::orchestration_model::TuiOrchestrationModel; +use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; use crate::tui_builder::TuiUiBuilder; +struct FocusTestFixture { + window_id: warpui_core::WindowId, + sessions: ModelHandle, +} + +fn focus_test_fixture(app: &mut App) -> FocusTestFixture { + register_tui_session_view_test_singletons(app); + add_test_semantic_selection(app); + app.update(TuiAutoupdater::register); + let (window_id, _) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); + let orchestration = app.update(TuiOrchestrationModel::register); + app.update(|ctx| TuiSessions::wire_orchestration(&sessions, &orchestration, ctx)); + FocusTestFixture { + window_id, + sessions, + } +} + +fn add_focus_test_session( + app: &mut App, + fixture: &FocusTestFixture, + focus: bool, +) -> (ViewHandle, TuiSessionId) { + let (view, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update(|ctx| { + TuiSessions::register_session(&fixture.sessions, view.clone(), manager, focus, ctx) + }); + (view, session_id) +} + fn render_element(mut element: Box, ctx: &AppContext, width: u16) -> TuiBuffer { let mut rendered_views = EntityIdMap::default(); let mut layout_ctx = TuiLayoutContext { @@ -205,3 +253,69 @@ fn resize_event_maps_to_pty_resize_intent() { assert_eq!(actual_update.new_size().rows(), 8); assert_eq!(actual_update.new_size().columns(), 42); } + +#[test] +fn alternate_screen_clears_orchestration_tab_focus_and_bindings() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (view, _) = add_focus_test_session(&mut app, &fixture, true); + + view.update(&mut app, |view, ctx| { + view.orchestration_tabs_focused = true; + view.terminal_model.lock().process_bytes("\u{1b}[?1049h"); + view.focus_current_owner(ctx); + }); + view.read(&app, |view, ctx| { + assert!(!view.orchestration_tabs_focused); + assert!(!view + .keymap_context(ctx) + .set + .contains(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG)); + }); + }); +} + +#[test] +fn orchestration_updates_refresh_only_the_focused_session() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (foreground, foreground_id) = add_focus_test_session(&mut app, &fixture, true); + let (background, background_id) = add_focus_test_session(&mut app, &fixture, false); + + background.update(&mut app, |view, _| { + view.orchestration_tabs_focused = true; + }); + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |_, ctx| { + ctx.notify(); + }); + }); + + assert_eq!( + app.read_model(&fixture.sessions, |sessions, _| { + sessions.focused_session_id() + }), + Some(foreground_id) + ); + assert!(app + .read(|ctx| { ctx.check_view_or_child_focused(fixture.window_id, &foreground.id()) })); + assert!(background.read(&app, |view, _| view.orchestration_tabs_focused)); + + app.update_model(&fixture.sessions, |sessions, ctx| { + assert!(sessions.focus_session(background_id, ctx)); + }); + assert!(!background.read(&app, |view, _| view.orchestration_tabs_focused)); + }); +} + +#[test] +fn terminal_wakeup_redraws_only_the_focused_session() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (foreground, _) = add_focus_test_session(&mut app, &fixture, true); + let (background, _) = add_focus_test_session(&mut app, &fixture, false); + + assert!(foreground.update(&mut app, |view, ctx| { view.handle_terminal_wakeup(ctx) })); + assert!(!background.update(&mut app, |view, ctx| { view.handle_terminal_wakeup(ctx) })); + }); +} diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 1def68907f3..f11a4e03654 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -18,6 +18,7 @@ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; use crate::orchestrated_agent_identity_styling::{agent_identity_palette, AgentIdentity}; +use crate::tab_bar::TuiTabBarStyles; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -255,6 +256,42 @@ impl TuiUiBuilder { self.primary_text_style().add_modifier(Modifier::BOLD) } + /// Styles for the reusable component when rendered as orchestration tabs. + pub(crate) fn orchestration_tab_bar_styles(&self) -> TuiTabBarStyles { + let background = self.orchestration_surface_background(); + let selected_fill = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + let selected_background = cell_color(selected_fill); + let selected_foreground = + cell_color(self.warp_theme.font_color(selected_fill.into_solid())); + TuiTabBarStyles { + background: Some(background), + leading: self.orchestration_tab_bar_label_style(), + chrome: self.orchestration_tab_bar_chrome_style(), + tab: self.muted_text_style().bg(background), + selected_focused: TuiStyle::default() + .fg(selected_foreground) + .bg(selected_background) + .add_modifier(Modifier::BOLD), + selected_unfocused: self + .primary_text_style() + .bg(background) + .add_modifier(Modifier::BOLD), + } + } + + /// Bold fixed-label style over the orchestration tab-bar background. + pub(crate) fn orchestration_tab_bar_label_style(&self) -> TuiStyle { + self.primary_text_style() + .bg(self.orchestration_surface_background()) + .add_modifier(Modifier::BOLD) + } + + /// Muted divider/overflow style over the orchestration tab background. + pub(crate) fn orchestration_tab_bar_chrome_style(&self) -> TuiStyle { + self.muted_text_style() + .bg(self.orchestration_surface_background()) + } + /// The deterministic agent identity palette for this theme. See /// [`crate::orchestrated_agent_identity_styling`]. pub(crate) fn agent_identity_palette(&self) -> Vec { diff --git a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md new file mode 100644 index 00000000000..b4d599974eb --- /dev/null +++ b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md @@ -0,0 +1,102 @@ +# PRODUCT: TUI Orchestration Conversation Tab Bar +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Component: [specs/code-1822-tui-tab-bar-component/PRODUCT.md](../code-1822-tui-tab-bar-component/PRODUCT.md) + +## Summary +The Warp TUI shows a tab bar for an orchestration tree so users can see and switch among the orchestrator and its navigable child-agent conversations. The bar supports keyboard and mouse navigation, preserves each session's state, and paginates predictably when all child tabs do not fit. + +## Figma +- Orchestration bar visible but unfocused: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-20498&m=dev +- Orchestration bar focused: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-19947&m=dev +- Focused bar with a truncated tab and overflow: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=881-21464&m=dev + +## Goals +- Make every navigable conversation in a local TUI orchestration tree directly reachable without opening the conversation picker. +- Keep keyboard navigation fast while preserving mouse access to every visible tab and overflow control. + +## Non-goals +- Pinning or unpinning agents from the TUI. Existing pin state may affect ordering for parity with the GUI. +- Showing conversations that do not have a retained, navigable TUI session. +- Restoring or materializing a missing session when its conversation would otherwise appear in the tree. +- Killing a selected child with `Ctrl+C`. This will be implemented in a follow-up PR above the tab-bar PR; this PR retains the existing session-level `Ctrl+C` behavior and does not advertise killing in the focused footer. +- Adding GUI-style per-agent menus such as opening a child in another pane or tab. + +## Behavior +### Visibility and contents +1. The tab bar appears at the top of the normal TUI session surface when the focused conversation belongs to an orchestration tree with at least one navigable child-agent conversation. +2. The same orchestration tree is shown while the orchestrator or any navigable descendant session is focused. Switching among members never changes which tree the bar represents. +3. The bar contains: + - The `Agents:` label. + - An optional main tab for the orchestrator, fixed at the leading edge. + - A divider after the orchestrator. + - One tab for each navigable child-agent conversation. +4. A conversation is included only while it maps to a retained TUI session that can be focused immediately. Failed, synthetic, unloaded, removed, or otherwise non-navigable conversations are omitted. +5. The orchestrator tab is labeled `orchestrator`. Each child tab uses the child's stable agent identity glyph and agent name. +6. The active conversation is the selected tab. There is no second pending or highlighted selection distinct from the active conversation. +7. When the bar is unfocused, the selected tab remains visibly emphasized without using the focused selection background. When the bar is focused, the selected tab uses the focused magenta selection treatment from the designs. +8. Tab colors and text styles adapt to the active terminal theme. The bar remains legible in dark, light, and custom themes without fixed foreground or background colors. + +### Dynamic ordering +9. Child tabs use the same canonical ordering as the GUI orchestration pill bar. The TUI must not maintain a separate ordering policy. +10. The GUI ordering is applied exactly: + - Pinned children precede unpinned children. + - Within each pin bucket, blocked children come first. + - Errored children follow blocked children. + - In-progress, transient-error, and waiting children follow errored children. + - Successful and cancelled children follow active children, ordered by most recent modification first. + - Spawn order breaks remaining ties. +11. The row reorders as status, recency, or persisted pin state changes. Labels, selection, keyboard navigation, and pagination all use the same updated order. +12. Reordering never changes the active conversation by itself. + +### Entering and leaving keyboard focus +13. While an orchestration tab bar is available and the input is focused, the normal footer shows the `Shift + ↑ sub-agents` hint from the design. +14. `Shift+Up` focuses the tab bar only when: + - The input cursor is on the first visual row of the wrapped input (display row zero), and + - The input has no active text selection. +15. On any other input row, or while text is selected, `Shift+Up` keeps its existing text-selection behavior. +16. Focusing the bar initially selects the already-active conversation; focus alone never switches sessions. +17. `Shift+Down` leaves the tab bar and focuses the active session's normal interaction target. This is normally the input, but an active blocking interaction or full-screen terminal surface retains its existing focus precedence. +18. While the bar is focused, its footer shows: `Tab or ← → to navigate Shift + ← → to go to start/end Shift + ↓ to send a message`. + +### Keyboard navigation +19. While the bar is focused, `Right` and `Tab` immediately switch to the next conversation in the current canonical order. +20. While the bar is focused, `Left` and `Shift+Tab` immediately switch to the previous conversation in the current canonical order. +21. Previous/next navigation wraps across the complete sequence of the orchestrator followed by the ordered child tabs. +22. `Shift+Left` switches to the first child-agent conversation. +23. `Shift+Right` switches to the last child-agent conversation. +24. The orchestrator is excluded from the `Shift+Left` and `Shift+Right` destinations. +25. A keyboard-driven session switch keeps the tab bar focused in the target session so repeated navigation continues without another `Shift+Up`. +26. Keyboard navigation always starts from the active conversation in the complete canonical order, even when an explicitly selected overflow page does not contain the active tab. `Tab` behaves like `Right`, and `Shift+Tab` behaves like `Left`. +27. Once keyboard navigation switches conversations, the newly active tab becomes the navigation origin and is kept visible. + +### Mouse navigation +28. Every visible orchestrator or child tab is clickable whether or not the tab bar currently has keyboard focus. +29. Clicking a tab immediately switches to that conversation's retained session. +30. If the bar was focused before the click, it remains focused after the switch. +31. If the bar was not focused before the click, the target session's normal interaction target receives focus. The click does not force keyboard focus onto the tab bar. +32. Clicking the already-active tab does not reset its transcript, input draft, cursor, selection, scroll position, or running work. + +### Width, truncation, and overflow +33. The orchestrator remains fixed at the leading edge. Only the child-tab region paginates. +34. The orchestration bar configures the reusable component with a maximum label width of 20 terminal display cells, including the ellipsis. +35. A child label wider than its maximum is truncated with `...`. Truncation is display-cell aware so wide Unicode characters never corrupt alignment. +36. The final visible tab on a page may be truncated further when needed to preserve the applicable overflow arrow, matching the supplied narrow/overflow design. +37. A right overflow arrow appears when later child tabs are hidden. A left overflow arrow appears when earlier child tabs are hidden. An arrow that has no page in its direction is not shown as actionable. +38. Clicking an overflow arrow changes only the visible child page: + - It does not switch conversations. + - It does not select a tab. + - It does not change keyboard focus. In particular, clicking an arrow while the input is focused leaves the input focused. +39. The selected page is shared across all sessions in the same orchestration tree. Switching conversations does not reset it. +40. The active tab is automatically revealed after session selection or dynamic reordering unless the user explicitly paged away with an overflow arrow. Selecting another tab already on the visible page does not shift or re-anchor that page. +41. After an explicit overflow click, the chosen page remains visible even if the active tab is on another page. The next keyboard selection follows the active conversation's canonical neighbor per (26), not the visible page edge. +42. Selecting a tab clears the explicit paged-away state and keeps the selected tab visible. +43. Terminal resizing recomputes which complete or truncated tabs fit, preserves a valid page when possible, and never clips an overflow arrow or writes outside the row. +44. At very narrow widths, the bar prioritizes the `Agents:` label, orchestrator tab, divider, and an applicable overflow control before child labels. It remains a single row. + +### Session and lifecycle behavior +45. Switching tabs focuses the existing retained session; it does not rebuild, restore, clone, or move the conversation. +46. Each session preserves its own transcript position, input draft, cursor, text selection, input scroll, blocking interaction, PTY state, and running agent state while another tab is active. +47. A newly materialized navigable child appears without requiring the user to leave the current session. +48. A removed or failed session disappears without leaving a clickable stale tab. The remaining tabs, page boundaries, and selection are recomputed immediately. +49. Status-driven reordering and child additions/removals never steal keyboard focus from the input or tab bar. +50. The tab bar remains available above normal blocking interactions. Existing alternate-screen behavior continues to own the complete terminal surface while active. diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md new file mode 100644 index 00000000000..2f3598e836a --- /dev/null +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -0,0 +1,147 @@ +# TECH: TUI Orchestration Conversation Tab Bar +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md](./PRODUCT.md) +Component: [specs/code-1822-tui-tab-bar-component/TECH.md](../code-1822-tui-tab-bar-component/TECH.md) +Inspected commit: `0bfc788907e2b27c3488c581fee92e2f67a18ef1` + +## Context +The preceding stack gives every native local child a retained full TUI session, but only the focused session is projected and there is no navigation chrome between related sessions: +- [`crates/warp_tui/src/sessions.rs (20-175) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/sessions.rs#L20-L175) — `TuiSessionId`, retained `TuiSession` views/managers, and `TuiSessions::focus_session`. `FocusChanged` updates projection state but currently has no focus-routing subscriber. +- [`crates/warp_tui/src/root_view.rs (36-82) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/root_view.rs#L36-L82) — the root renders and exposes only `TuiSessions::focused_session()`, so switching sessions is a full-view swap rather than transcript replacement. +- [`crates/warp_tui/src/orchestration_model.rs (30-266) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/orchestration_model.rs#L30-L266) — `TuiOrchestrationModel` materializes local native children, retains the child-conversation/session mapping needed for failed-launch cleanup, and subscribes to every session's `StartAgentExecutor`. +- [`app/src/ai/blocklist/history_model.rs (403-568) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/history_model.rs#L403-L568) — the shared history model owns loaded conversations, parent resolution, and the restart-durable `children_by_parent` index. +- [`app/src/ai/blocklist/history_model.rs (963-1018) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/history_model.rs#L963-L1018) — `terminal_surface_id_for_conversation` and `active_conversation` provide the authoritative conversation↔surface mapping. This mapping remains correct when a session restores a different conversation, unlike `TuiOrchestrationModel::child_session_by_conversation`. + +The GUI already owns the canonical orchestration ordering and tree traversal: +- [`app/src/ai/blocklist/orchestration_topology.rs (107-180) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/orchestration_topology.rs#L107-L180) — `descendant_conversations_in_pill_order` implements pin/status/recency/spawn ordering, and adjacent navigation establishes wraparound semantics. +- [`app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs (600-670) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs#L600-L670) — the GUI renders an orchestrator first, then descendants in the shared canonical order. Its current active-child lookup climbs one parent; PRODUCT (2) requires the TUI to climb to the top orchestration root so every member session shows the same complete tree. + +The focused session already owns the relevant input and focus transitions: +- [`crates/warp_tui/src/terminal_session_view.rs (746-1037) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/terminal_session_view.rs#L746-L1037) — input construction, history subscriptions, background-session focus guards, blocker precedence, and input restoration. +- [`crates/warp_tui/src/terminal_session_view.rs (2166-2342) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/terminal_session_view.rs#L2166-L2342) — the current session render tree, including alt-screen replacement, transcript, blocker handling, input, and footer. +- [`crates/warp_tui/src/input/view.rs (61-170) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/input/view.rs#L61-L170) and [`crates/warp_tui/src/input/view.rs (584-675) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/input/view.rs#L584-L675) — `Shift+Up` currently dispatches `SelectUp` unconditionally and the input applies vertical selection directly. +- [`crates/warp_tui/src/editor_element.rs (271-408) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/editor_element.rs#L271-L408) — the char-cell display lattice is the source of truth for wrapped visual rows and cursor placement. + +## Proposed changes +### 1. Export and reuse the GUI topology policy +Re-export `descendant_conversations_in_pill_order` through `app/src/tui_export.rs`; do not duplicate its sort keys in `warp_tui`. + +`TuiOrchestrationModel` reuses the GUI's descendant ordering while resolving the complete tree required by PRODUCT (2): +1. Receive the focused session's selected conversation ID from `TuiTerminalSessionView`; do not substitute the history model's “most recently streamed” active pointer for the input selection. +2. Follow resolved parent conversation IDs until reaching the top orchestration root, with a visited set to make malformed cycles fail closed. +3. Read all descendants and their stable spawn indices from `descendant_conversations_in_pill_order`. +4. Filter the orchestrator and descendants through the authoritative history surface mapping and a retained-session lookup. +5. Omit the bar unless the orchestrator and at least one child are immediately navigable. + +Filtering occurs after canonical ordering so the relative order of navigable descendants remains identical to the GUI. The model does not use `child_session_by_conversation` for rendering or navigation; that map remains narrowly scoped to failed-child cleanup. + +Add `TuiSessions::session_ids_by_conversation` to build one authoritative loaded conversation→session index from the history model's live conversations and retained TUI sessions per snapshot. Snapshot filtering and navigation then use constant-time lookups without maintaining another persistent index. Restored and transferred conversations remain self-correcting because the temporary index is rebuilt from live history state. + +### 2. Orchestration-owned tab state and actions +`TuiOrchestrationModel` owns TUI-specific orchestration runtime state: child launch materialization and cleanup, semantic tab snapshots, session navigation, and the one page state needed by the single visible tab bar: +- The orchestration root whose page is retained. +- The child page-anchor conversation ID. +- Whether the user explicitly paged away from the active tab. + +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn indices, page anchor, and whether the component should reveal an off-page selection. Each retained session caches its latest snapshot only as presentation state. The focused session refreshes that cache from live history/session state on topology changes, selection changes, explicit paging, and activation; hidden sessions refresh when activated. Render reads the cache and never reconstructs topology. The session view assigns theme-specific identities from spawn order and maps semantic tab state to current-theme styles before updating its generalized component. + +Model operations: +- `focus_conversation_session` resolves the target's retained session, captures the snapshot's current fallback anchor when initializing page state for a tree, calls `TuiSessions::focus_session`, and clears explicit paging without replacing the current page anchor. The component reveals the deterministic page containing an off-page selection. +- `set_explicit_page` records the page anchor emitted by the tab bar and marks it explicit. It never calls `focus_session`. +- A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. +- Removed roots clear the active page state; removed children clamp invalid anchors through fresh snapshot resolution. + +Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and react to session removal through the registry lifecycle. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. A single `TuiSessions` observer forwards orchestration-model invalidation only to the focused session; retained hidden sessions do no tab work until activation. + +### 3. Session switching and focus handoff +The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. + +Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionView`: +- `FocusDefaultInteractionTarget` +- `SelectPrevious` / `SelectNext` +- `SelectFirstChild` / `SelectLastChild` + +Register `Left`, `Right`, `Tab`, `Shift+Tab`, `Shift+Left`, `Shift+Right`, and `Shift+Down` only under the tab-focused context. Keep the existing session-level `Ctrl+C` binding unchanged; the focused footer omits kill copy in this PR. + +The input's `MoveFocusUp` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask `TuiTabBarView` for a target from its semantic tab order; navigation remains relative to the active conversation even when the user has explicitly paged the active tab out of view. First/last child navigation resolves through the view's secondary order. The session view delegates semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. + +Track whether the session view itself currently owns tab-bar focus. A switch performs two coordinated operations: +1. Ask `TuiOrchestrationModel` to select/focus the target retained session. +2. Focus the target `TuiTerminalSessionView` and set its tab-focused mode for keyboard switches or already-focused mouse switches; otherwise invoke a target-session helper that applies existing precedence: alternate screen, active blocker, then input. +The source session clears its tab-focused mode during cross-session navigation. Process and blocker ownership also clear tab focus, and model-driven refreshes invoke focus APIs only for the focused session. + +### 4. Input boundary handoff +Add a generic `MoveFocusUp` event and owner-supplied availability predicate to `TuiInputView`; do not import orchestration types into the input module. The owner updates a shared boolean together with its cached orchestration snapshot, so the input's action-time predicate remains constant-time and cannot diverge from rendered bar visibility. + +When handling `SelectUp`, the input requests focus above only if: +- Its owner's live predicate reports an above-target available. +- The selection is empty. +- The cursor's display point is display row zero. + +Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. The owner predicate reads the availability value synchronized with the retained presentation snapshot. + +### 5. Session rendering and owner callbacks +Own `TuiTabBarView` as a retained child of `TuiTerminalSessionView`. Each session retains its own bar and cached semantic snapshot. Only the focused session maps fresh model state into `TuiTabBarConfig`; hidden sessions continue processing agent and terminal state without synchronizing or rendering their bars. Activation refreshes the target session before it is projected, and `TuiTabBarView::set_config` no-ops identical configurations. + +Restructure the normal render tree into: +- A full-width optional orchestration tab row. +- The existing horizontally padded transcript/input/footer column beneath it. + +The alt-screen early return remains unchanged and therefore owns the complete pane. Blocking cards remain inside the normal session column, so the tab bar stays available above them. + +When the bar is unfocused, render the normal footer with the conditional `Shift + ↑ sub-agents` hint. When focused, keep the input visible but blurred and replace the normal footer with the PRODUCT (18) navigation footer. + +Tab and overflow interactions emit semantic child-view events subscribed by `TuiTerminalSessionView`: +- Tab clicks select immediately. The owner preserves tab focus only if it was already active; otherwise it focuses the target session's normal interaction target. +- Overflow clicks carry the page anchor computed by the component to the model's `set_explicit_page` operation and never call a focus API, so input focus remains unchanged. + +### 6. Styling and identities +Add semantic orchestration-tab style recipes to `TuiUiBuilder` rather than embedding raw colors in the view. Supply those styles through `TuiTabBarConfig`: +- Magenta-tinted bar background. +- Focused selected tab background and contrasting text. +- Unfocused active-tab emphasis. +- Muted divider, overflow, and non-selected labels. + +Reuse `agent_identity_palette` and `assign_agent_identity_indices`. Assign identities from stable spawn order, then project them into dynamic pill order so a status change never changes a child's glyph or color. Each tab-view entry receives only the resulting optional leading glyph/style. + +## Testing and validation +### Integration tests +Extend `orchestration_model_tests.rs`, `session_registry_tests.rs`, `terminal_session_view_tests.rs`, and `input/view_tests.rs`: +- Exact reuse of GUI pin/status/error/active/done-recency/spawn ordering after filtering non-navigable sessions — PRODUCT (4, 9-12). +- The same root and tabs from orchestrator and child sessions; newly materialized and removed sessions — PRODUCT (1-5, 47-49). +- Shared active-tree page state, explicit-page persistence, active reveal, and semantic keyboard navigation while the active tab is off-page — PRODUCT (26-27, 39-42). +- Wrapped-row and selection-aware `Shift+Up` behavior; `Shift+Down` restoration — PRODUCT (13-18). +- Wrapped adjacent navigation, child-only first/last jumps, and tab focus preserved across full-session projection — PRODUCT (19-25). +- Focused and unfocused mouse switches, no-op active clicks, overflow clicks that neither select nor move focus, and blocker precedence — PRODUCT (28-32, 38, 45-46). +- Tab bar above normal blockers and omitted by alt-screen replacement — PRODUCT (50). +- Existing `Ctrl+C` behavior and the absence of kill-agent footer copy remain regression-covered. + +### Live verification +Use `tui-testing` render-to-lines coverage for 40-, 80-, and 132-column widths and dark/light themes. Then use `tui-verify-change` with `./script/run-tui` to verify: +- `Shift+Up` from wrapped and selected input states. +- Continuous keyboard switching while child agents reorder dynamically. +- Mouse switching from input focus and tab focus. +- Multiple overflow pages, explicit paging with the active tab off-page, and terminal resize. +- Input drafts and running child state preserved across repeated switches. + +Run focused validation first: +- `cargo nextest run -p warp_tui orchestration` +- `cargo nextest run -p warp_tui terminal_session` +- `cargo nextest run -p warp_tui input` + +Before PR submission, run `./script/format`, the repository-prescribed Clippy command, and `./script/presubmit`. + +## Parallelization +The reusable component lands in the lower `harry/code-1822-tui-tab-bar-component` PR. Do not split this integration PR across child agents: topology snapshots, shared page state, full-session focus switching, input handoff, and render tests cross the same TUI views and should be implemented coherently. Implement sequentially in one checkout: model/session lookup and paging, input/focus integration, then rendering and end-to-end validation. Long-running focused test groups may run concurrently after implementation. + +## Risks and mitigations +- **Dynamic order versus explicit paging:** store page anchors by conversation ID, not index, and distinguish automatic reveal from explicit paging. Re-resolve every anchor against the fresh canonical order. +- **Hidden-session presentation drift:** hidden sessions intentionally retain stale presentation caches while their semantic models continue updating. Refresh the complete snapshot and bar configuration on `TuiSessionsEvent::FocusChanged` before relying on the target session's cached availability or navigation order. +- **Layout/event drift:** each size-switch row owns the tabs and overflow callbacks it renders, while keyboard navigation uses the same semantic config held by the retained tab view. The orchestration model resolves emitted stable IDs against fresh state and no-ops unavailable targets. +- **Focus without projection:** make every tab selection update `TuiSessions` and the target view's focus mode in one owner action; test both root projection and responder focus. +- **Stale child-session cache:** use history's authoritative conversation→surface mapping plus `TuiSessions` lookup for navigation. Keep the existing child map only for launch cleanup. +- **Identity changes during reorder:** assign identities in stable spawn order, independent of canonical display order. +- **Terminal-model deadlock:** tab membership, paging, and focus require no new `TerminalModel::lock()` call; visual-row detection reads the editor's char-cell render state instead. + +## Follow-ups +- Add `Ctrl+C` to kill the selected child while the tab bar is focused, including its focused-footer hint and terminal-state behavior, in the next PR above this one.