diff --git a/app/Cargo.toml b/app/Cargo.toml index 7ddcf236938..6c02928ed18 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,7 +845,13 @@ voice_input = ["dep:voice_input"] system_theme = [] tab_close_button_on_left = [] team_features_override = [] -test-util = ["cloud_object_client/test-util", "warp_server_auth/test-util"] +test-util = [ + "cloud_object_client/test-util", + "warp_server_auth/test-util", + "http_client/test-util", + "warp_core/test-util", + "ai/test-util", +] team_workflows = ["team_features_override"] terminal_lifecycle_recovery = [] toggle_bootstrap_block = [] diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index e63e4cada62..4d75f5e1bbd 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 1bc116efc2c..ca76df1bfdf 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,7 +115,7 @@ impl AuthManager { } } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index bef0f00bacb..38f3dae17e6 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index 10a733d79cb..010e9871e38 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -86,6 +86,8 @@ mod tracing; mod tui; #[cfg(feature = "tui")] pub mod tui_export; +#[cfg(all(feature = "tui", any(test, feature = "test-util")))] +mod tui_test_support; mod ui_components; mod undo_close; mod uri; @@ -648,7 +650,7 @@ impl LaunchMode { } } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index f3da4863500..87d951709f9 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,7 +449,7 @@ impl ServerApi { } } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1287,7 +1287,7 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index b3ceac851ab..39859eb4a22 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,7 +356,7 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn mock(ctx: &mut ModelContext) -> Self { use super::server_api::ServerApiProvider; diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index a5960592194..870697b03dc 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -448,11 +448,14 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(test)] +#[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn init_and_register_user_preferences(ctx: &mut AppContext) { - let (public_prefs, _parse_error) = init_public_user_preferences(); + let public_prefs = Box::::default(); + let private_prefs = settings::PrivatePreferences::new(Box::< + user_preferences::in_memory::InMemoryPreferences, + >::default()); ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs)); - ctx.add_singleton_model(move |_| init_private_user_preferences()); + ctx.add_singleton_model(move |_| private_prefs); } #[cfg(test)] diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 4b8d5af0031..9746e0292f8 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -122,6 +122,34 @@ pub struct TerminalSurfaceInit { pub colors: ColorList, pub inactive_pty_reads_rx: InactiveReceiver>>, } + +#[cfg(any(test, all(feature = "tui", feature = "test-util")))] +impl TerminalSurfaceInit { + /// Creates mock terminal surface inputs without spawning a PTY. + pub fn new_for_test(ctx: &mut AppContext) -> Self { + let (_wakeups_tx, wakeups_rx) = async_channel::unbounded(); + let (_events_tx, events_rx) = async_channel::unbounded(); + let (pty_reads_tx, pty_reads_rx) = + async_broadcast::broadcast(PTY_READS_BROADCAST_CHANNEL_SIZE); + drop(pty_reads_tx); + let sessions = ctx.add_model(|_| Sessions::new_for_test()); + let model_events = + ctx.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx)); + let model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); + let colors = model.lock().colors(); + let size_info = model.lock().block_list().size().to_owned(); + Self { + wakeups_rx, + model_events, + model, + sessions, + size_info, + colors, + inactive_pty_reads_rx: pty_reads_rx.deactivate(), + } + } +} + /// A newly constructed terminal surface and its manager post-wiring callback. pub struct TerminalSurfaceResult { pub surface: ViewHandle, diff --git a/app/src/tui/mcp.rs b/app/src/tui/mcp.rs index efec313be12..3e7634540f5 100644 --- a/app/src/tui/mcp.rs +++ b/app/src/tui/mcp.rs @@ -82,6 +82,18 @@ pub struct TuiMcpManager { } impl TuiMcpManager { + /// Creates an empty MCP aggregate for frontend tests. + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] + pub(crate) fn new_for_test(_ctx: &mut ModelContext) -> Self { + Self { + snapshot: TuiMcpSnapshot { + config_path: PathBuf::new(), + config_state: TuiMcpConfigState::Missing, + servers: Vec::new(), + }, + } + } + pub fn new(ctx: &mut ModelContext) -> Self { ctx.subscribe_to_model(&FileBasedMCPManager::handle(ctx), |me, _, _, ctx| { me.refresh(ctx); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index a74ed383360..79f53709438 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -162,6 +162,8 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +pub use crate::tui_test_support::register_tui_session_view_test_singletons; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs new file mode 100644 index 00000000000..5819810fbd8 --- /dev/null +++ b/app/src/tui_test_support.rs @@ -0,0 +1,114 @@ +//! Test-only app initialization used by the external `warp_tui` crate. + +use ai::api_keys::ApiKeyManager; +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; +use warpui::SingletonEntity as _; + +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; +use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +use crate::ai::blocklist::orchestration_events::OrchestrationEventService; +use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions, QueuedQueryModel}; +use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::harness_availability::HarnessAvailabilityModel; +use crate::ai::llms::LLMPreferences; +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; +use crate::auth::auth_manager::AuthManager; +use crate::auth::AuthStateProvider; +use crate::cloud_object::model::persistence::CloudModel; +use crate::code_review::git_repo_model::GitRepoModels; +use crate::network::NetworkStatus; +use crate::server::server_api::ServerApiProvider; +use crate::server::sync_queue::SyncQueue; +use crate::settings::manager::SettingsManager; +use crate::settings::{init_and_register_user_preferences, AISettings}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::user_config::WarpConfig; +use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::LaunchMode; + +/// Registers the app models required to construct full TUI session views in tests. +/// +/// Registration order mirrors model subscription dependencies. +pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); + app.update(init_and_register_user_preferences); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + app.add_singleton_model(ApiKeyManager::new); + + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|ctx| { + let (team_client, workspace_client) = { + let provider = ServerApiProvider::as_ref(ctx); + (provider.get_team_client(), provider.get_workspace_client()) + }; + UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) + }); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| crate::appearance::Appearance::mock()); + + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + app.add_singleton_model(LLMPreferences::new); + app.add_singleton_model(HarnessAvailabilityModel::new); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); + app.add_singleton_model(BlocklistAIPermissions::new); + app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); + + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + app.add_singleton_model(QueuedQueryModel::new); + app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + app.add_singleton_model(OrchestrationEventService::new); + app.add_singleton_model(LocalAgentTaskSyncModel::new); + app.add_singleton_model(OrchestrationEventStreamer::new); + app.add_singleton_model(|_| ActiveAgentViewsModel::new()); + app.add_singleton_model(|_| GitRepoModels::new()); + app.add_singleton_model(|ctx| { + CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) + }); + app.add_singleton_model(AgentConversationsModel::new); + + app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); + app.add_singleton_model(|ctx| { + crate::changelog_model::ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()) + }); + app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); + app.update(crate::settings::TuiAutoupdateSettings::register); + app.update(crate::settings::CodeSettings::register); + app.update(crate::settings::FontSettings::register); + app.update(crate::settings::InputSettings::register); + app.update(crate::settings::InputModeSettings::register); + app.update(crate::settings::SelectionSettings::register); + app.update(crate::settings::ScrollSettings::register); + app.update(crate::settings::EmacsBindingsSettings::register); + app.update(crate::terminal::general_settings::GeneralSettings::register); + + app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); + app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(repo_metadata::watcher::DirectoryWatcher::new); + #[cfg(feature = "local_fs")] + app.add_singleton_model(repo_metadata::RepoMetadataModel::new); + app.add_singleton_model( + crate::warp_managed_paths_watcher::WarpManagedPathsWatcher::new_for_testing, + ); + app.add_singleton_model(crate::workflows::local_workflows::LocalWorkflows::new); + app.add_singleton_model(crate::ai::skills::SkillManager::new); +} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index b410f026290..57f97ad96a7 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,7 +107,7 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self { theme_config: WarpThemeConfig::new(), diff --git a/app/src/warp_managed_paths_watcher.rs b/app/src/warp_managed_paths_watcher.rs index cb20e062b2e..00c31b44a3a 100644 --- a/app/src/warp_managed_paths_watcher.rs +++ b/app/src/warp_managed_paths_watcher.rs @@ -241,7 +241,7 @@ impl WarpManagedPathsWatcher { Self::new_internal(ctx, true) } - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub(crate) fn new_for_testing(ctx: &mut ModelContext) -> Self { Self::new_internal(ctx, false) } diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 0bd9292f212..d3f7af17ce8 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -122,7 +122,7 @@ pub struct CreateTeamResponse { } impl UserWorkspaces { - #[cfg(test)] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub fn mock( team_client: Arc, workspace_client: Arc, diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 2cd3caec544..6543e718d54 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -35,6 +35,7 @@ mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; +mod session_registry; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index f74586b115f..4ed26bf5360 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -34,6 +34,7 @@ fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRe name: "researcher".to_string(), prompt: "research".to_string(), title: "Researcher".to_string(), + agent_identity_uid: String::new(), }], plan_id: "plan-1".to_string(), harness_auth_secret_name: None, diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index ccf5dbe39f7..35f06c77fdf 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -1,50 +1,39 @@ //! [`RootTuiView`]: the login-gated root view of the `warp-tui` front-end. -use warp::tui_export::TerminalSurfaceInit; use warp::{TuiLoginModel, TuiLoginPhase}; +use warpui::SingletonEntity as _; use warpui_core::elements::tui::{TuiChildView, TuiElement}; use warpui_core::keymap::macros::*; use warpui_core::keymap::FixedBinding; use warpui_core::platform::TerminationMode; use warpui_core::{ - keymap, AppContext, Entity, EntityId, SingletonEntity, TuiView, TypedActionView, ViewContext, - ViewHandle, + keymap, AppContext, Entity, EntityId, TuiView, TypedActionView, ViewContext, ViewHandle, }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::resume::TuiExitSummaryHandle; +use crate::session_registry::TuiSessions; use crate::terminal_session_view::TuiTerminalSessionView; use crate::ui::{login_failed, login_placeholder, terminal_starting}; -/// Whether the authenticated terminal session has been created yet. Mirrors the -/// GUI root view's `AuthOnboardingState` split between the pre-session login gate -/// and the live terminal session. -enum RootTuiState { - /// Login gate: no terminal session exists yet. The placeholder shown is - /// chosen from the current [`TuiLoginPhase`]. - Auth, - /// The authenticated terminal session. - Terminal(ViewHandle), -} - /// Typed actions handled by [`RootTuiView`]. #[derive(Debug, Clone)] pub enum RootTuiAction { - /// Exit the app. Bound to ctrl-c in the root's keymap context; the - /// terminal session's deeper `Interrupt` binding wins while a session - /// exists, so this fires only on the pre-session placeholders (which say - /// "Press Ctrl-C to exit") — keeping the app exitable in every state. + /// Exits the app while no terminal session is focused. ExitApp, } -/// The app-level TUI shell. It gates the authenticated terminal session on login state. +/// Whether the root is presenting authentication or the live session container. +enum RootTuiState { + Auth, + Terminal, +} + +/// The app-level TUI shell, projecting only the focused full session view. pub struct RootTuiView { state: RootTuiState, - exit_summary: TuiExitSummaryHandle, } -/// Registers the root view's keybindings. Called once at TUI startup from -/// `keybindings::init`. +/// Registers the root view's keybindings. pub fn init(app: &mut AppContext) { app.register_fixed_bindings([FixedBinding::new( "ctrl-c", @@ -55,35 +44,27 @@ pub fn init(app: &mut AppContext) { } impl RootTuiView { - pub(crate) fn new(exit_summary: TuiExitSummaryHandle) -> Self { + /// Creates the login-gated root view. + pub(crate) fn new() -> Self { Self { state: RootTuiState::Auth, - exit_summary, } } - /// Creates the terminal child view once login has completed, or returns the - /// existing one if it was already created. Callers notify the root so it - /// re-renders from the login placeholder to the terminal session. - pub(crate) fn create_terminal_session( - &mut self, - surface_init: TerminalSurfaceInit, - keyboard_enhancement_supported: bool, - ctx: &mut ViewContext, - ) -> ViewHandle { - if let RootTuiState::Terminal(terminal_session) = &self.state { - return terminal_session.clone(); + + /// Transitions from the authentication gate to the live session container. + pub(crate) fn show_terminal(&mut self, ctx: &mut ViewContext) { + self.state = RootTuiState::Terminal; + ctx.notify(); + } + + fn focused_session_view(&self, ctx: &AppContext) -> Option> { + if !ctx.has_singleton_model::() { + return None; } - let exit_summary = self.exit_summary.clone(); - let terminal_session = ctx.add_typed_action_tui_view(|ctx| { - TuiTerminalSessionView::new( - surface_init, - exit_summary, - keyboard_enhancement_supported, - ctx, - ) - }); - self.state = RootTuiState::Terminal(terminal_session.clone()); - terminal_session + + TuiSessions::as_ref(ctx) + .focused_session() + .map(|session| session.view().clone()) } } @@ -96,20 +77,18 @@ impl TuiView for RootTuiView { "RootTuiView" } - fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - // The TUI runtime uses this for child focus and event routing; only the - // live terminal session participates. - match &self.state { - RootTuiState::Terminal(terminal_session) => vec![terminal_session.id()], + fn child_view_ids(&self, ctx: &AppContext) -> Vec { + match self.state { RootTuiState::Auth => Vec::new(), + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| vec![view.id()]) + .unwrap_or_default(), } } fn render(&self, ctx: &AppContext) -> Box { - match &self.state { - RootTuiState::Terminal(terminal_session) => { - TuiChildView::new(terminal_session).finish() - } + match self.state { RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { @@ -118,11 +97,14 @@ impl TuiView for RootTuiView { } => login_placeholder(verification_uri.as_deref(), user_code.as_deref()), TuiLoginPhase::Failed { message } => login_failed(message.as_str()), }, + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| TuiChildView::new(&view).finish()) + .unwrap_or_else(terminal_starting), } } fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { - // Propagate focus context into the input view so keystrokes reach it. let mut context = keymap::Context::default(); context.set.insert("RootTuiView"); context @@ -138,3 +120,7 @@ impl TypedActionView for RootTuiView { } } } + +#[cfg(test)] +#[path = "root_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs new file mode 100644 index 00000000000..195f2c942c5 --- /dev/null +++ b/crates/warp_tui/src/root_view_tests.rs @@ -0,0 +1,82 @@ +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, UpdateModel}; +use warpui_core::{App, TuiView as _, WindowId}; + +use super::RootTuiView; +use crate::session_registry::{TuiSessions, TuiSessionsEvent}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; + +fn add_root(app: &mut App) -> (WindowId, warpui_core::ViewHandle) { + app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }) +} + +#[test] +fn root_projects_only_the_focused_retained_session_view() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + add_test_semantic_selection(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let (window_id, root) = add_root(&mut app); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); + root.update(&mut app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { + TuiSessionsEvent::SessionAdded(_) => {} + TuiSessionsEvent::FocusChanged(_) => ctx.notify(), + }); + }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); + root.update(&mut app, |root, ctx| root.show_terminal(ctx)); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); + }); + let focused_window_view = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); + }); + + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(second_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![second_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &second_view_id)); + assert_ne!(ctx.focused_view_id(window_id), focused_window_view); + }); + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(first_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); + }); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index f487a195d4a..6ca994e4fb4 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -2,7 +2,7 @@ //! //! [`run`] boots the real headless Warp app via [`warp::run_tui`]. Once shared //! initialization is done, the mount built here starts the TUI driver and -//! defers creating the transcript-capable terminal session until login. +//! defers creating the first terminal session until login. use std::collections::HashMap; use std::ffi::OsString; @@ -13,18 +13,19 @@ use clap::Parser; use pathfinder_geometry::vector::Vector2F; use warp::tui_export::{ Appearance, BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, - ServerConversationToken, TerminalManagerTrait, TerminalSurfaceResult, + ServerConversationToken, TerminalSurfaceResult, }; use warp::{TuiLoginEvent, TuiLoginModel, TuiLoginPhase}; use warp_core::telemetry::TelemetryEvent as _; use warp_errors::report_error; -use warpui::SingletonEntity; +use warpui::SingletonEntity as _; use warpui_core::platform::{TerminationMode, WindowStyle}; -use warpui_core::runtime::{spawn_tui_driver, TuiDriverHandle}; -use warpui_core::{AddWindowOptions, AppContext, Entity, ModelHandle, ViewHandle}; +use warpui_core::runtime::spawn_tui_driver; +use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessions, TuiSessionsEvent}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -51,21 +52,6 @@ fn parse_resume_token(token: String) -> Result { Ok(ServerConversationToken::new(token)) } -/// Holds the live TUI driver and, after login, the terminal manager. -struct TuiSession { - #[expect(dead_code, reason = "keeps the TUI driver alive for the TUI session")] - driver: TuiDriverHandle, - keyboard_enhancement_supported: bool, - manager: Option>>, - resume_token: Option, -} - -impl Entity for TuiSession { - type Event = (); -} - -impl SingletonEntity for TuiSession {} - /// Boots the headless Warp app and mounts the transcript-capable TUI session. pub fn run() -> Result<()> { // If this process was re-exec'd as a Warp worker (e.g. the terminal @@ -103,7 +89,7 @@ pub fn run() -> Result<()> { result } -/// Creates the login-gated TUI root and starts the headless draw + input driver. +/// Creates the login-gated root and starts the headless draw and input driver. fn init( resume_token: Option, exit_summary: TuiExitSummaryHandle, @@ -127,37 +113,35 @@ fn init( appearance.set_theme(theme, ctx); }); - let banner = ctx.add_model(|_| BannerState::default()); let (window_id, root) = ctx.add_tui_window( AddWindowOptions { window_style: WindowStyle::NotStealFocus, ..Default::default() }, - |_| RootTuiView::new(exit_summary), + |_| RootTuiView::new(), ); match spawn_tui_driver(ctx, window_id, root.clone()) { Ok(driver) => { - let keyboard_enhancement_supported = driver.keyboard_enhancement_supported(); - let session = ctx.add_singleton_model(|_| TuiSession { - driver, - keyboard_enhancement_supported, - manager: None, - resume_token, + let sessions = + ctx.add_singleton_model(|_| TuiSessions::new(driver, exit_summary, resume_token)); + root.update(ctx, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { + TuiSessionsEvent::SessionAdded(_) => {} + TuiSessionsEvent::FocusChanged(_) => ctx.notify(), + }); }); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { - // Already authenticated at mount: create the session now. - create_terminal_session_after_login(&session, &root, &banner, ctx); + // Already authenticated at mount: create the first session now. + create_terminal_session_after_login(&sessions, &root, ctx); } else { // Otherwise wait for login to complete and create it then. - let session_for_login = session.clone(); + let sessions_for_login = sessions.clone(); let root_for_login = root.clone(); - let banner_for_login = banner.clone(); let login_model = TuiLoginModel::handle(ctx); ctx.subscribe_to_model(&login_model, move |_, event, ctx| match event { TuiLoginEvent::LoggedIn => create_terminal_session_after_login( - &session_for_login, + &sessions_for_login, &root_for_login, - &banner_for_login, ctx, ), }); @@ -171,24 +155,21 @@ fn init( } } -/// Creates and retains the terminal manager after login. +/// Creates the focused bootstrap session after login. fn create_terminal_session_after_login( - session: &ModelHandle, + sessions: &ModelHandle, root: &ViewHandle, - banner: &ModelHandle, ctx: &mut AppContext, ) { - if session.read(ctx, |session, _| session.manager.is_some()) { + if sessions.read(ctx, |sessions, _| !sessions.is_empty()) { return; } - let root = root.clone(); - let (resume_token, keyboard_enhancement_supported) = session.read(ctx, |session, _| { - ( - session.resume_token.clone(), - session.keyboard_enhancement_supported, - ) - }); + let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); + let window_id = root.window_id(ctx); + let (exit_summary, keyboard_enhancement_supported) = + sessions.read(ctx, |sessions, _| sessions.surface_context()); + let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( std::env::current_dir().ok(), HashMap::::from_iter(std::env::vars_os()), @@ -201,12 +182,13 @@ fn create_terminal_session_after_login( TRANSCRIPT_BLOCK_SPACING, ctx, move |surface_init, ctx| { - let surface = root.update(ctx, |root, ctx| { - let surface = - root.create_terminal_session(surface_init, keyboard_enhancement_supported, ctx); - // Re-render the root so it swaps the login placeholder for the session. - ctx.notify(); - surface + let surface = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new( + surface_init, + exit_summary, + keyboard_enhancement_supported, + ctx, + ) }); TerminalSurfaceResult { surface, @@ -227,11 +209,10 @@ fn create_terminal_session_after_login( }, ); - session.update(ctx, |session, ctx| { - session.manager = Some(manager.manager); - session.resume_token = None; - ctx.notify(); + sessions.update(ctx, |sessions, ctx| { + sessions.add_session(manager.surface, manager.manager, true, ctx); }); + root.update(ctx, |root, ctx| root.show_terminal(ctx)); } #[cfg(test)] diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs new file mode 100644 index 00000000000..18043d346ed --- /dev/null +++ b/crates/warp_tui/src/session_registry.rs @@ -0,0 +1,181 @@ +//! [`TuiSessions`]: registry and foreground selection for live TUI sessions. +//! +//! Every session is a full [`TuiTerminalSessionView`] backed by a retained +//! terminal manager. The container owns session lifetime and focus; the root +//! view renders and routes input only to the focused session. + +use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; +use warpui::SingletonEntity; +use warpui_core::runtime::TuiDriverHandle; +use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; + +use crate::resume::TuiExitSummaryHandle; +use crate::terminal_session_view::TuiTerminalSessionView; + +/// Identifies a TUI terminal session. +/// +/// A session and its eagerly-created view have the same lifetime, so the +/// view's entity id is also the terminal surface id used by shared AI models. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct TuiSessionId(EntityId); + +impl TuiSessionId { + /// The raw entity id used at shared-model boundaries. + pub(crate) fn surface_id(self) -> EntityId { + self.0 + } +} + +/// A live TUI session: its full view and the manager retaining its PTY. +pub(crate) struct TuiSession { + id: TuiSessionId, + view: ViewHandle, + /// Retained for the session's lifetime to keep its PTY and event loop alive. + _manager: ModelHandle>, +} + +impl TuiSession { + /// The session's full terminal view. + pub(crate) fn view(&self) -> &ViewHandle { + &self.view + } +} + +/// Events emitted as the session set or focus changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiSessionsEvent { + /// A session was registered, possibly in the background. + SessionAdded(TuiSessionId), + /// The focused session changed to this id. + FocusChanged(TuiSessionId), +} + +/// Owns all live TUI sessions and the focused-session selection. +pub(crate) struct TuiSessions { + /// TUI-specific process driver. Its handle restores terminal mode on + /// drop, so the app-lifetime session singleton must retain it. + _driver: Option, + keyboard_enhancement_supported: bool, + exit_summary: TuiExitSummaryHandle, + sessions: Vec, + focused_session_id: Option, + resume_token: Option, +} + +impl Entity for TuiSessions { + type Event = TuiSessionsEvent; +} + +impl SingletonEntity for TuiSessions {} + +impl TuiSessions { + /// Creates the app's session container. + pub(crate) fn new( + driver: TuiDriverHandle, + exit_summary: TuiExitSummaryHandle, + resume_token: Option, + ) -> Self { + let keyboard_enhancement_supported = driver.keyboard_enhancement_supported(); + Self { + _driver: Some(driver), + keyboard_enhancement_supported, + exit_summary, + sessions: Vec::new(), + focused_session_id: None, + resume_token, + } + } + + /// Creates a driverless container for unit tests. + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self { + _driver: None, + keyboard_enhancement_supported: false, + exit_summary: TuiExitSummaryHandle::default(), + sessions: Vec::new(), + focused_session_id: None, + resume_token: None, + } + } + + /// Registers an eagerly-created session view and optionally focuses it. + pub(crate) fn add_session( + &mut self, + view: ViewHandle, + manager: ModelHandle>, + focus: bool, + ctx: &mut ModelContext, + ) -> TuiSessionId { + let id = TuiSessionId(view.id()); + debug_assert!( + self.session(id).is_none(), + "a session must not be registered twice" + ); + self.sessions.push(TuiSession { + id, + view, + _manager: manager, + }); + ctx.emit(TuiSessionsEvent::SessionAdded(id)); + if focus { + self.focus_session(id, ctx); + } + ctx.notify(); + id + } + + /// Returns the process-level context used to create session views. + pub(crate) fn surface_context(&self) -> (TuiExitSummaryHandle, bool) { + ( + self.exit_summary.clone(), + self.keyboard_enhancement_supported, + ) + } + + /// Focuses a registered session. Returns whether focus changed. + pub(crate) fn focus_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) -> bool { + if self.focused_session_id == Some(id) || self.session(id).is_none() { + return false; + } + self.focused_session_id = Some(id); + let view = self + .session(id) + .expect("focused session was validated above") + .view + .clone(); + view.update(ctx, |view, ctx| view.activate(ctx)); + ctx.emit(TuiSessionsEvent::FocusChanged(id)); + ctx.notify(); + true + } + + /// The focused session's id. + pub(crate) fn focused_session_id(&self) -> Option { + self.focused_session_id + } + + /// The focused session. + pub(crate) fn focused_session(&self) -> Option<&TuiSession> { + self.focused_session_id.and_then(|id| self.session(id)) + } + + /// Looks up a registered session. + pub(crate) fn session(&self, id: TuiSessionId) -> Option<&TuiSession> { + self.sessions.iter().find(|session| session.id == id) + } + + /// Whether no session has been registered. + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + /// Consumes the startup resume token. + pub(crate) fn take_resume_token(&mut self) -> Option { + self.resume_token.take() + } +} + +#[cfg(test)] +#[path = "session_registry_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs new file mode 100644 index 00000000000..4e45d9010bf --- /dev/null +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -0,0 +1,108 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::App; + +use super::{TuiSessions, TuiSessionsEvent}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session, TestHostView}; + +type CapturedEvents = Rc>>; + +fn capture_events(app: &mut App) -> CapturedEvents { + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let captured = events.clone(); + app.update(|ctx| { + let sessions = TuiSessions::handle(ctx); + ctx.subscribe_to_model(&sessions, move |_, event, _| { + captured.borrow_mut().push(*event); + }); + }); + events +} + +#[test] +fn add_and_focus_drive_events() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + add_test_semantic_selection(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let window_id = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ) + .0 + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); + let events = capture_events(&mut app); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + assert_eq!(first_id.surface_id(), first_view_id); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![ + TuiSessionsEvent::SessionAdded(first_id), + TuiSessionsEvent::FocusChanged(first_id), + ], + ); + let first_focused_view_id = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + assert_eq!(second_id.surface_id(), second_view_id); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::SessionAdded(second_id)], + ); + assert_eq!( + app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), + Some(first_id), + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(second_id, ctx)); + assert!(!sessions.focus_session(second_id, ctx)); + }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &second_view_id) })); + assert_ne!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(second_id)], + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(first_id, ctx)); + }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(first_id)], + ); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index c1c0a49c823..76ca9d80c32 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -69,6 +69,7 @@ use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; +use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -345,16 +346,28 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { + self.focus_current_owner_if_active(ctx); + } + + fn focus_current_owner(&self, ctx: &mut ViewContext) { if self.process_owns_input() { - if !ctx.is_self_focused() { - ctx.focus_self(); - } - } else if ctx.is_self_focused() { - if let Some(blocker) = self.active_blocking_child(ctx) { - ctx.focus(&blocker); - } else { - ctx.focus(&self.input_view); - } + ctx.focus_self(); + } else if let Some(blocker) = self.active_blocking_child(ctx) { + ctx.focus(&blocker); + } else { + ctx.focus(&self.input_view); + } + } + + fn focus_current_owner_if_active(&self, ctx: &mut ViewContext) { + if self.is_focused_session(ctx) { + self.focus_current_owner(ctx); + } + } + + fn focus_input_if_active(&self, ctx: &mut ViewContext) { + if self.is_focused_session(ctx) { + ctx.focus(&self.input_view); } } fn resume_after_user_controlled_command( @@ -399,7 +412,7 @@ impl TuiTerminalSessionView { transcript.detach_cli_subagent(initial_requested_command_action_id, view.id(), ctx); }); } - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); } fn handle_cli_subagent_event(&mut self, event: &CLISubagentEvent, ctx: &mut ViewContext) { match event { @@ -1014,11 +1027,6 @@ impl TuiTerminalSessionView { ); ctx.spawn_stream_local(terminal_resize_rx, Self::handle_terminal_resize, |_, _| {}); - // Focus the input view so the keymap responder chain is - // [root, session, input]: input bindings win for keys they define, - // and unbound keys (ctrl-c) fall through to the session/root bindings. - ctx.focus(&input_view); - Self { transcript, input_view, @@ -1060,6 +1068,19 @@ impl TuiTerminalSessionView { self.transcript.as_ref(ctx).active_blocking_child(ctx) } + /// Activates this session after the registry has made it authoritative. + pub(crate) fn activate(&mut self, ctx: &mut ViewContext) { + self.focus_current_owner(ctx); + self.write_exit_summary(ctx); + } + + /// Whether this view projects the focused session. + fn is_focused_session(&self, ctx: &AppContext) -> bool { + TuiSessions::as_ref(ctx) + .focused_session_id() + .is_some_and(|id| id.surface_id() == self.terminal_surface_id) + } + /// Reconciles focus with the derived blocker: a newly active blocker is /// focused (handing off directly between consecutive blockers with no /// intermediate editable input), and focus returns to the input when the @@ -1069,16 +1090,8 @@ impl TuiTerminalSessionView { let blocker = self.active_blocking_child(ctx); let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { - // A foreground process owns the rendered pane and keyboard. Defer - // both the focus handoff and its completion marker until the - // process releases input, so the transition remains retryable. - if !self.process_owns_input() { - match &blocker { - Some(child) => ctx.focus(child), - None => ctx.focus(&self.input_view), - } - self.active_blocker_view_id = blocker_view_id; - } + self.active_blocker_view_id = blocker_view_id; + self.focus_current_owner_if_active(ctx); } ctx.notify(); } @@ -1244,7 +1257,7 @@ impl TuiTerminalSessionView { self.conversation_restore_state = ConversationRestoreState::Idle; self.refresh_exit_summary(ctx); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); ctx.notify(); } @@ -1275,7 +1288,7 @@ impl TuiTerminalSessionView { future.abort(); } self.next_restore_request_id = self.next_restore_request_id.wrapping_add(1); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); ctx.notify(); true } @@ -1303,13 +1316,20 @@ impl TuiTerminalSessionView { TuiConversationRestoreOrigin::ConversationList => { self.conversation_restore_state = ConversationRestoreState::Idle; self.show_transient_hint(message, ctx); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); } } ctx.notify(); } fn refresh_exit_summary(&self, ctx: &AppContext) { + if !self.is_focused_session(ctx) { + return; + } + self.write_exit_summary(ctx); + } + + fn write_exit_summary(&self, ctx: &AppContext) { let token = self .conversation_selection .as_ref(ctx) diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index ac4d793d6ad..956391b14d0 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -1,15 +1,35 @@ //! Shared fixtures for `warp_tui` unit tests. +use std::any::Any; use std::sync::Arc; use parking_lot::FairMutex; use warp::tui_export::{ ActiveSession, BlocklistAIActionModel, BlocklistAIHistoryModel, GetRelevantFilesController, - ModelEventDispatcher, Sessions, TerminalModel, + ModelEventDispatcher, Sessions, TerminalManagerTrait, TerminalModel, TerminalSurfaceInit, }; use warp_core::semantic_selection::SemanticSelection; use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; use warpui_core::elements::tui::{TuiElement, TuiText}; -use warpui_core::{AppContext, Entity, TuiView, TypedActionView}; +use warpui_core::{AppContext, Entity, TuiView, TypedActionView, ViewHandle, WindowId}; + +use crate::resume::TuiExitSummaryHandle; +use crate::terminal_session_view::TuiTerminalSessionView; + +struct TestTerminalManager(Arc>); + +impl TerminalManagerTrait for TestTerminalManager { + fn model(&self) -> Arc> { + self.0.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} /// A trivial typed-action root view for tests that need a TUI window whose /// real subject is a non-root child view. @@ -75,3 +95,24 @@ pub(crate) fn add_test_action_model_and_events( }); (action_model, dispatcher) } + +/// Builds a full session view against mock terminal plumbing. +pub(crate) fn add_test_terminal_session( + app: &mut App, + window_id: WindowId, +) -> ( + ViewHandle, + ModelHandle>, +) { + app.update(|ctx| { + let surface_init = TerminalSurfaceInit::new_for_test(ctx); + let terminal_model = surface_init.model.clone(); + let view = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new(surface_init, TuiExitSummaryHandle::default(), false, ctx) + }); + let manager = ctx.add_model(|_| { + Box::new(TestTerminalManager(terminal_model)) as Box + }); + (view, manager) + }) +} diff --git a/specs/code-1822-tui-multi-session/TECH.md b/specs/code-1822-tui-multi-session/TECH.md new file mode 100644 index 00000000000..7eb4f11c83e --- /dev/null +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -0,0 +1,72 @@ +# TECH: `TuiSessions` full-view multi-session container + +First PR in the TUI local-orchestration stack above the orchestration-card +branch. The follow-up adds `TuiOrchestrationModel` and materializes native +local children as background sessions. + +## Context + +The TUI currently retains one terminal manager in a singleton and one +`TuiTerminalSessionView` in `RootTuiView`. The shared AI layer already supports +multiple surfaces keyed by `terminal_surface_id`, but the TUI has no container +for multiple live terminal surfaces or a model-level notion of focus. + +Every TUI session needs a full view. `LocalTtyTerminalManager` requires a +terminal surface synchronously, and background orchestration children use the +same view-backed terminal machinery as the focused session. This follows the +GUI's hidden-pane model: background sessions retain complete views, while only +the focused view participates in rendering and input routing. + +## Proposed changes + +### New: `crates/warp_tui/src/session_registry.rs` + +- Add `TuiSessionId(EntityId)`, using the eagerly-created view's entity id as + both session identity and shared-model `terminal_surface_id`. +- Add `TuiSessions`, a singleton retaining each session's + `TuiTerminalSessionView` and terminal manager (the manager kept only to tie + the PTY and event loop to the session's lifetime), plus the window and exit + summary context needed to construct additional session views. +- Track `focused_session_id` and emit `SessionAdded` and `FocusChanged` + events. All session creation paths register here so orchestration can + subscribe to every session, including future nested children. +- Session removal (with a `SessionRemoved` event and focus fallback) lands + with the orchestration PR that first needs it. + +### Changed: `crates/warp_tui/src/root_view.rs` + +- Replace the single authenticated child with projection of + `TuiSessions::focused_session()`. +- Subscribe to session events for redraws. +- Session creation does not flow through the root; it only projects sessions. +- Return only the focused view from `child_view_ids()`, keeping background + views out of rendering and the responder chain. + +### Changed: `crates/warp_tui/src/session.rs` + +- Replace the single-session singleton with `TuiSessions`. +- Create the full `TuiTerminalSessionView` synchronously inside the terminal + manager's surface callback, then register the view and its returned manager + with `TuiSessions` so the container owns the session lifetime. +- The login bootstrap registers the first session focused. + +### Changed: `crates/warp_tui/src/terminal_session_view.rs` +- Keep construction focus-neutral. When `TuiSessions` activates a session, the + view focuses its current input owner and refreshes the exit summary. +- Route later blocker, process, CLI-subagent, and conversation-restoration + focus requests through focused-session guards so background views cannot + steal focus or replace the focused session's resume token. + +## Non-goals + +- Session navigation UI or keybindings. +- Session persistence; `TuiSessionId` is process-local. +- Remote or CLI-harness child materialization. + +## Testing and validation + +- Unit-test add/focus behavior and event emission on `TuiSessions`. +- Construct two full session views and verify the root projects only the + focused view, background registration does not steal focus, and focus + changes reuse retained views. +- Run `cargo nextest run -p warp_tui --no-fail-fast` and `./script/format`. \ No newline at end of file