From 45fa3d56296423f0d3c4f5295d4cafe909c15ef0 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:36:08 -0400 Subject: [PATCH 1/6] Add TuiSessions multi-session container + focused-session root view TuiSessions (SingletonEntity) owns all session cores with insertion order, focus, and Added/Removed/FocusChanged events; all session creation flows through it. RootTuiView becomes a projection: a lazy, retained view cache keyed by session id, rendering and routing input to only the focused session. The login bootstrap registers session 0 focused, and the terminal manager handle moves onto the session core. Co-Authored-By: Oz --- app/Cargo.toml | 8 +- app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 4 +- .../terminal/local_tty/terminal_manager.rs | 28 +++ app/src/tui/mcp.rs | 12 ++ app/src/tui_export.rs | 162 ++++++++++++++++ app/src/user_config/mod.rs | 4 +- app/src/warp_managed_paths_watcher.rs | 2 +- app/src/workspaces/user_workspaces.rs | 2 +- crates/warp_tui/src/lib.rs | 1 + .../warp_tui/src/orchestration_block_tests.rs | 4 +- crates/warp_tui/src/root_view.rs | 95 +++------ crates/warp_tui/src/root_view_tests.rs | 67 +++++++ crates/warp_tui/src/session.rs | 100 ++++------ crates/warp_tui/src/sessions.rs | 180 ++++++++++++++++++ crates/warp_tui/src/sessions_tests.rs | 92 +++++++++ crates/warp_tui/src/terminal_session_view.rs | 36 +++- crates/warp_tui/src/test_fixtures.rs | 83 +++++++- specs/code-1822-tui-multi-session/TECH.md | 71 +++++++ 24 files changed, 822 insertions(+), 153 deletions(-) create mode 100644 crates/warp_tui/src/root_view_tests.rs create mode 100644 crates/warp_tui/src/sessions.rs create mode 100644 crates/warp_tui/src/sessions_tests.rs create mode 100644 specs/code-1822-tui-multi-session/TECH.md 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..4b59ca9627a 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, 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..70a1227098c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,7 +115,9 @@ impl AuthManager { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] 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..2cb902d5655 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, 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..c1fd2ab19ec 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,7 +648,9 @@ impl LaunchMode { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] 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..8ff728ce47b 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,7 +449,9 @@ impl ServerApi { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1287,7 +1289,9 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] 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..f563ad475cb 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,7 +356,9 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] 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..1750ba4322c 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(test)] { + if #[cfg(any(test, feature = "test-util"))] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -448,7 +448,7 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(test)] +#[cfg(any(test, feature = "test-util"))] pub fn init_and_register_user_preferences(ctx: &mut AppContext) { let (public_prefs, _parse_error) = init_public_user_preferences(); ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs)); diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 4b8d5af0031..d78733ead7d 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, 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..226653f92a2 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, 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..9d5108c6b48 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,12 +2,20 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; +#[cfg(any(test, feature = "test-util"))] +use ai::api_keys::ApiKeyManager; +#[cfg(any(test, feature = "test-util"))] +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; +#[cfg(any(test, feature = "test-util"))] +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, @@ -56,6 +64,12 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::orchestration_events::OrchestrationEventService; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, @@ -64,6 +78,10 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::{BlocklistAIPermissions, QueuedQueryModel}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -71,12 +89,16 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -89,21 +111,39 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::auth_manager::AuthManager; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; +#[cfg(any(test, feature = "test-util"))] +use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +#[cfg(any(test, feature = "test-util"))] +use crate::server::server_api::ServerApiProvider; +#[cfg(any(test, feature = "test-util"))] +use crate::server::sync_queue::SyncQueue; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; +#[cfg(any(test, feature = "test-util"))] +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ prepare_conversation_block_restoration, ConversationBlockRestorationPlan, @@ -162,8 +202,14 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; +#[cfg(any(test, feature = "test-util"))] +use crate::workspaces::user_workspaces::UserWorkspaces; +#[cfg(any(test, feature = "test-util"))] +use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -223,3 +269,119 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } + +/// Registers the minimal singleton set needed to construct, render, and +/// accept the TUI orchestration (`RunAgents`) card against real app models: +/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the +/// auth/server/cloud-object singletons the catalog models read, and the +/// catalog + permission models the card's snapshot builders and accept-path +/// permission checks use. Intended for `warp_tui` tests (via the `test-util` +/// feature) and this crate's own unit tests. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_orchestration_test_singletons(app: &mut warpui::App) { + // Settings machinery required by CloudAgentSettings/AISettings reads. + 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| { + // No-op secure storage backs ApiKeyManager in tests. + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + // Secure-storage-backed; LLMPreferences subscribes to it. + app.add_singleton_model(ApiKeyManager::new); + + // Auth / server / cloud-object singletons the catalog models read. + 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| { + // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), + // so back the mock with the test ServerApi's clients instead. + 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()); + + // Catalog + permission singletons read by the card's construction, + // snapshot builders, and accept path. + 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) + }); + // Plan publication during the accept path reads the document model. + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); +} + +/// Registers the singleton set needed to construct a full TUI session view's +/// AI stack in tests, on top of +/// [`register_orchestration_test_singletons`]. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_tui_session_test_singletons(app: &mut warpui::App) { + register_orchestration_test_singletons(app); + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + 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); +} + +/// [`register_tui_session_test_singletons`] plus the remaining singletons a +/// full `TuiTerminalSessionView` subscribes to. +#[cfg(any(test, feature = "test-util"))] +pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { + register_tui_session_test_singletons(app); + 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()); + // The TUI auto-updater (which the session view subscribes to) reads its + // enablement setting at registration. + app.update(crate::settings::TuiAutoupdateSettings::register); + // Settings groups the editor-backed input view and transcript read. + 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); + // Filesystem-watcher singletons the workflow/skill sources read. + 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..5d22b60794c 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,7 +107,9 @@ 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, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] 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..eaae43a09ad 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, 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..2f24191f015 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, 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..343494492c9 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 sessions; 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..a938827bf43 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -444,8 +444,8 @@ fn blocked_accept_invalidates_card_layout() { TuiOrchestrationBlockEvent::BlockingStateChanged => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } - TuiOrchestrationBlockEvent::RejectRequested => {} - TuiOrchestrationBlockEvent::LayoutInvalidated => {} + TuiOrchestrationBlockEvent::RejectRequested + | TuiOrchestrationBlockEvent::LayoutInvalidated => {} }); }); diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index ccf5dbe39f7..deade2556ad 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -1,50 +1,31 @@ //! [`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::sessions::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. -pub struct RootTuiView { - state: RootTuiState, - exit_summary: TuiExitSummaryHandle, -} +/// The app-level TUI shell, projecting only the focused full session view. +pub struct RootTuiView; -/// 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 +36,19 @@ pub fn init(app: &mut AppContext) { } impl RootTuiView { - pub(crate) fn new(exit_summary: TuiExitSummaryHandle) -> Self { - Self { - state: RootTuiState::Auth, - exit_summary, - } + /// Creates the login-gated root view. + pub(crate) fn new() -> Self { + Self } - /// 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(); + + 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,21 +61,16 @@ 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()], - RootTuiState::Auth => Vec::new(), - } + fn child_view_ids(&self, ctx: &AppContext) -> Vec { + 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() - } - RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { + match self.focused_session_view(ctx) { + Some(view) => TuiChildView::new(&view).finish(), + None => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { verification_uri, @@ -122,7 +82,6 @@ impl TuiView for RootTuiView { } 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 +97,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..ac60fd73b5f --- /dev/null +++ b/crates/warp_tui/src/root_view_tests.rs @@ -0,0 +1,67 @@ +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::sessions::TuiSessions; +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(window_id)); + root.update(&mut app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + }); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let second_view_id = second.id(); + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + }); + let focused_window_view = app.read(|ctx| ctx.focused_view_id(window_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]); + }); + 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]); + }); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index f487a195d4a..17988e781fe 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::sessions::TuiSessions; 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,39 +113,32 @@ 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, window_id, exit_summary, resume_token) + }); + root.update(ctx, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| 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, ctx); } else { // Otherwise wait for login to complete and create it then. - let session_for_login = session.clone(); - let root_for_login = root.clone(); - let banner_for_login = banner.clone(); + let sessions_for_login = sessions.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, - &root_for_login, - &banner_for_login, - ctx, - ), + TuiLoginEvent::LoggedIn => { + create_terminal_session_after_login(&sessions_for_login, ctx) + } }); } } @@ -171,24 +150,16 @@ fn init( } } -/// Creates and retains the terminal manager after login. -fn create_terminal_session_after_login( - session: &ModelHandle, - root: &ViewHandle, - banner: &ModelHandle, - ctx: &mut AppContext, -) { - if session.read(ctx, |session, _| session.manager.is_some()) { +/// Creates the focused bootstrap session after login. +fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: &mut AppContext) { + 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, 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 +172,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,10 +199,8 @@ 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); }); } diff --git a/crates/warp_tui/src/sessions.rs b/crates/warp_tui/src/sessions.rs new file mode 100644 index 00000000000..551110d795f --- /dev/null +++ b/crates/warp_tui/src/sessions.rs @@ -0,0 +1,180 @@ +//! [`TuiSessions`]: the TUI's multi-session container. +//! +//! 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, WindowId}; + +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, + window_id: WindowId, + 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, + window_id: WindowId, + exit_summary: TuiExitSummaryHandle, + resume_token: Option, + ) -> Self { + let keyboard_enhancement_supported = driver.keyboard_enhancement_supported(); + Self { + _driver: Some(driver), + keyboard_enhancement_supported, + window_id, + 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(window_id: WindowId) -> Self { + Self { + _driver: None, + keyboard_enhancement_supported: false, + window_id, + 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 window and exit-summary handle used to create session views. + pub(crate) fn surface_context(&self) -> (WindowId, TuiExitSummaryHandle, bool) { + ( + self.window_id, + 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); + 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 = "sessions_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/sessions_tests.rs b/crates/warp_tui/src/sessions_tests.rs new file mode 100644 index 00000000000..dd44830d2f5 --- /dev/null +++ b/crates/warp_tui/src/sessions_tests.rs @@ -0,0 +1,92 @@ +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(window_id)); + let events = capture_events(&mut app); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let second_view_id = second.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_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![ + TuiSessionsEvent::SessionAdded(first_id), + TuiSessionsEvent::FocusChanged(first_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_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_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..adebfb4bdd2 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::sessions::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -345,6 +346,9 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { + if !self.is_focused_session(ctx) { + return; + } if self.process_owns_input() { if !ctx.is_self_focused() { ctx.focus_self(); @@ -1017,7 +1021,16 @@ impl TuiTerminalSessionView { // 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); + // Background session views (e.g. orchestration children) must not + // steal window focus from the focused session at construction. + let is_focused_session = !ctx.has_singleton_model::() + || TuiSessions::as_ref(ctx) + .focused_session_id() + .is_none_or(|id| id.surface_id() == terminal_surface_id); + + if is_focused_session { + ctx.focus(&input_view); + } Self { transcript, @@ -1060,6 +1073,16 @@ impl TuiTerminalSessionView { self.transcript.as_ref(ctx).active_blocking_child(ctx) } + /// Whether this view projects the focused session. Background session + /// views must not claim window focus or write the exit summary. Absent + /// container state (unit tests) counts as focused. + fn is_focused_session(&self, ctx: &AppContext) -> bool { + !ctx.has_singleton_model::() + || TuiSessions::as_ref(ctx) + .focused_session_id() + .is_none_or(|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 @@ -1071,8 +1094,10 @@ impl TuiTerminalSessionView { 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() { + // process releases input. Background-session blockers likewise + // remain pending rather than stealing focus from the foreground + // session, so either transition remains retryable. + if !self.process_owns_input() && self.is_focused_session(ctx) { match &blocker { Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), @@ -1310,6 +1335,11 @@ impl TuiTerminalSessionView { } fn refresh_exit_summary(&self, ctx: &AppContext) { + // The exit summary's resume hint tracks the focused session only; + // background children must not overwrite it with their own tokens. + if !self.is_focused_session(ctx) { + return; + } 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..8b8b2448abd 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::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; 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. @@ -48,6 +68,21 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, +) { + let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); + (action_model, dispatcher) +} + +/// [`add_test_action_model_and_events`] plus the terminal-surface id the +/// action model was built with, so tests can register an active conversation +/// for that surface in the history model (required by +/// `BlocklistAIActionModel::get_pending_action`). +pub(crate) fn add_test_action_model_with_surface( + app: &mut App, +) -> ( + ModelHandle, + ModelHandle, + EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -73,5 +108,45 @@ pub(crate) fn add_test_action_model_and_events( ctx, ) }); - (action_model, dispatcher) + (action_model, dispatcher, terminal_surface_id) +} + +/// 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) + }) +} + +/// Creates a live, active conversation for `terminal_surface_id` in the +/// history model, returning its id. Combined with +/// `BlocklistAIActionModel::queue_pending_action_for_test`, this drives an +/// action into `Blocked` status for confirmation-flow tests. +#[allow(dead_code)] +pub(crate) fn add_active_test_conversation( + app: &mut App, + terminal_surface_id: EntityId, +) -> warp::tui_export::AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = + history.start_new_conversation(terminal_surface_id, false, false, false, ctx); + history.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); + conversation_id + }) + }) } 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..2543cea45bf --- /dev/null +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -0,0 +1,71 @@ +# 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/sessions.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` + +- Guard constructor focus, blocker-driven focus changes, and exit-summary + updates 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 From ccdc9382fabe1823721c022e82d9c030cfd5f67b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 11:29:09 -0400 Subject: [PATCH 2/6] address comments --- app/src/lib.rs | 2 + app/src/tui_export.rs | 162 +----------------- app/src/tui_test_support.rs | 114 ++++++++++++ crates/warp_tui/src/lib.rs | 2 +- crates/warp_tui/src/root_view.rs | 2 +- crates/warp_tui/src/root_view_tests.rs | 11 +- crates/warp_tui/src/session.rs | 2 +- .../src/{sessions.rs => session_registry.rs} | 10 +- ...ons_tests.rs => session_registry_tests.rs} | 20 ++- crates/warp_tui/src/terminal_session_view.rs | 94 +++++----- specs/code-1822-tui-multi-session/TECH.md | 11 +- 11 files changed, 202 insertions(+), 228 deletions(-) create mode 100644 app/src/tui_test_support.rs rename crates/warp_tui/src/{sessions.rs => session_registry.rs} (94%) rename crates/warp_tui/src/{sessions_tests.rs => session_registry_tests.rs} (81%) diff --git a/app/src/lib.rs b/app/src/lib.rs index c1fd2ab19ec..dc5341211b8 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; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 9d5108c6b48..79f53709438 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,20 +2,12 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; -#[cfg(any(test, feature = "test-util"))] -use ai::api_keys::ApiKeyManager; -#[cfg(any(test, feature = "test-util"))] -use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; -#[cfg(any(test, feature = "test-util"))] -use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, @@ -64,12 +56,6 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::orchestration_events::OrchestrationEventService; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, @@ -78,10 +64,6 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::{BlocklistAIPermissions, QueuedQueryModel}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -89,16 +71,12 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -111,39 +89,21 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::auth_manager::AuthManager; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; -#[cfg(any(test, feature = "test-util"))] -use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -#[cfg(any(test, feature = "test-util"))] -use crate::server::server_api::ServerApiProvider; -#[cfg(any(test, feature = "test-util"))] -use crate::server::sync_queue::SyncQueue; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; -#[cfg(any(test, feature = "test-util"))] -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ prepare_conversation_block_restoration, ConversationBlockRestorationPlan, @@ -203,13 +163,9 @@ pub use crate::tui::{ TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; #[cfg(any(test, feature = "test-util"))] -use crate::user_config::WarpConfig; +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; -#[cfg(any(test, feature = "test-util"))] -use crate::workspaces::user_workspaces::UserWorkspaces; -#[cfg(any(test, feature = "test-util"))] -use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -269,119 +225,3 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } - -/// Registers the minimal singleton set needed to construct, render, and -/// accept the TUI orchestration (`RunAgents`) card against real app models: -/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the -/// auth/server/cloud-object singletons the catalog models read, and the -/// catalog + permission models the card's snapshot builders and accept-path -/// permission checks use. Intended for `warp_tui` tests (via the `test-util` -/// feature) and this crate's own unit tests. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_orchestration_test_singletons(app: &mut warpui::App) { - // Settings machinery required by CloudAgentSettings/AISettings reads. - 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| { - // No-op secure storage backs ApiKeyManager in tests. - warpui_extras::secure_storage::register_noop("test", ctx); - }); - app.update(AISettings::register_and_subscribe_to_events); - CloudAgentSettings::register(app); - // Secure-storage-backed; LLMPreferences subscribes to it. - app.add_singleton_model(ApiKeyManager::new); - - // Auth / server / cloud-object singletons the catalog models read. - 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| { - // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), - // so back the mock with the test ServerApi's clients instead. - 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()); - - // Catalog + permission singletons read by the card's construction, - // snapshot builders, and accept path. - 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) - }); - // Plan publication during the accept path reads the document model. - app.add_singleton_model(|_| { - crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() - }); -} - -/// Registers the singleton set needed to construct a full TUI session view's -/// AI stack in tests, on top of -/// [`register_orchestration_test_singletons`]. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_tui_session_test_singletons(app: &mut warpui::App) { - register_orchestration_test_singletons(app); - app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); - // QueuedQueryModel subscribes to history events; register after the - // history model is in place. - 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); -} - -/// [`register_tui_session_test_singletons`] plus the remaining singletons a -/// full `TuiTerminalSessionView` subscribes to. -#[cfg(any(test, feature = "test-util"))] -pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { - register_tui_session_test_singletons(app); - 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()); - // The TUI auto-updater (which the session view subscribes to) reads its - // enablement setting at registration. - app.update(crate::settings::TuiAutoupdateSettings::register); - // Settings groups the editor-backed input view and transcript read. - 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); - // Filesystem-watcher singletons the workflow/skill sources read. - 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/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/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 343494492c9..6543e718d54 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -35,7 +35,7 @@ mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; -mod sessions; +mod session_registry; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index deade2556ad..7bc9a87f9f8 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -11,7 +11,7 @@ use warpui_core::{ }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::terminal_session_view::TuiTerminalSessionView; use crate::ui::{login_failed, login_placeholder, terminal_starting}; diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index ac60fd73b5f..ebd6979ad9a 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -4,7 +4,7 @@ use warpui::{AddWindowOptions, UpdateModel}; use warpui_core::{App, TuiView as _, WindowId}; use super::RootTuiView; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; fn add_root(app: &mut App) -> (WindowId, warpui_core::ViewHandle) { @@ -32,16 +32,17 @@ fn root_projects_only_the_focused_retained_session_view() { }); let (first, first_manager) = add_test_terminal_session(&mut app, window_id); - let (second, second_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let second_view_id = second.id(); let first_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(first, first_manager, true, 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) @@ -56,12 +57,16 @@ fn root_projects_only_the_focused_retained_session_view() { }); 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 17988e781fe..d7917bbbf6b 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -25,7 +25,7 @@ use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ diff --git a/crates/warp_tui/src/sessions.rs b/crates/warp_tui/src/session_registry.rs similarity index 94% rename from crates/warp_tui/src/sessions.rs rename to crates/warp_tui/src/session_registry.rs index 551110d795f..0a52755227d 100644 --- a/crates/warp_tui/src/sessions.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -1,4 +1,4 @@ -//! [`TuiSessions`]: the TUI's multi-session container. +//! [`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 @@ -144,6 +144,12 @@ impl TuiSessions { 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 @@ -176,5 +182,5 @@ impl TuiSessions { } #[cfg(test)] -#[path = "sessions_tests.rs"] +#[path = "session_registry_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/sessions_tests.rs b/crates/warp_tui/src/session_registry_tests.rs similarity index 81% rename from crates/warp_tui/src/sessions_tests.rs rename to crates/warp_tui/src/session_registry_tests.rs index dd44830d2f5..dc74596418e 100644 --- a/crates/warp_tui/src/sessions_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -43,14 +43,13 @@ fn add_and_focus_drive_events() { let events = capture_events(&mut app); let (first, first_manager) = add_test_terminal_session(&mut app, window_id); - let (second, second_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let second_view_id = second.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![ @@ -58,6 +57,13 @@ fn add_and_focus_drive_events() { 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) @@ -76,6 +82,11 @@ fn add_and_focus_drive_events() { 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)], @@ -84,6 +95,11 @@ fn add_and_focus_drive_events() { 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 adebfb4bdd2..76ca9d80c32 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -69,7 +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::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -346,19 +346,28 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { - if !self.is_focused_session(ctx) { - return; - } + 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( @@ -403,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 { @@ -1018,20 +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. - // Background session views (e.g. orchestration children) must not - // steal window focus from the focused session at construction. - let is_focused_session = !ctx.has_singleton_model::() - || TuiSessions::as_ref(ctx) - .focused_session_id() - .is_none_or(|id| id.surface_id() == terminal_surface_id); - - if is_focused_session { - ctx.focus(&input_view); - } - Self { transcript, input_view, @@ -1073,14 +1068,17 @@ impl TuiTerminalSessionView { self.transcript.as_ref(ctx).active_blocking_child(ctx) } - /// Whether this view projects the focused session. Background session - /// views must not claim window focus or write the exit summary. Absent - /// container state (unit tests) counts as focused. + /// 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 { - !ctx.has_singleton_model::() - || TuiSessions::as_ref(ctx) - .focused_session_id() - .is_none_or(|id| id.surface_id() == self.terminal_surface_id) + 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 @@ -1092,18 +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. Background-session blockers likewise - // remain pending rather than stealing focus from the foreground - // session, so either transition remains retryable. - if !self.process_owns_input() && self.is_focused_session(ctx) { - 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(); } @@ -1269,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(); } @@ -1300,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 } @@ -1328,18 +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) { - // The exit summary's resume hint tracks the focused session only; - // background children must not overwrite it with their own tokens. 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/specs/code-1822-tui-multi-session/TECH.md b/specs/code-1822-tui-multi-session/TECH.md index 2543cea45bf..7eb4f11c83e 100644 --- a/specs/code-1822-tui-multi-session/TECH.md +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -19,7 +19,7 @@ the focused view participates in rendering and input routing. ## Proposed changes -### New: `crates/warp_tui/src/sessions.rs` +### 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`. @@ -51,10 +51,11 @@ the focused view participates in rendering and input routing. - The login bootstrap registers the first session focused. ### Changed: `crates/warp_tui/src/terminal_session_view.rs` - -- Guard constructor focus, blocker-driven focus changes, and exit-summary - updates so background views cannot steal focus or replace the focused - session's resume token. +- 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 From be0b33974aa8504890e6c5e484512f19ebcf75a0 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 18:09:31 -0400 Subject: [PATCH 3/6] add back explicit state enum --- crates/warp_tui/src/root_view.rs | 39 ++++++++++++++++++++------ crates/warp_tui/src/root_view_tests.rs | 7 +++++ crates/warp_tui/src/session.rs | 18 ++++++++---- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index 7bc9a87f9f8..35f06c77fdf 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -22,8 +22,16 @@ pub enum RootTuiAction { ExitApp, } +/// 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; +pub struct RootTuiView { + state: RootTuiState, +} /// Registers the root view's keybindings. pub fn init(app: &mut AppContext) { @@ -38,7 +46,15 @@ pub fn init(app: &mut AppContext) { impl RootTuiView { /// Creates the login-gated root view. pub(crate) fn new() -> Self { - Self + Self { + state: RootTuiState::Auth, + } + } + + /// 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> { @@ -62,15 +78,18 @@ impl TuiView for RootTuiView { } fn child_view_ids(&self, ctx: &AppContext) -> Vec { - self.focused_session_view(ctx) - .map(|view| vec![view.id()]) - .unwrap_or_default() + 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.focused_session_view(ctx) { - Some(view) => TuiChildView::new(&view).finish(), - None => match TuiLoginModel::as_ref(ctx).phase() { + match self.state { + RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { verification_uri, @@ -78,6 +97,10 @@ 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), } } diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index ebd6979ad9a..60ccb677aac 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -30,12 +30,19 @@ fn root_projects_only_the_focused_retained_session_view() { root.update(&mut app, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, _, ctx| 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)); diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index d7917bbbf6b..9d3d2b1a462 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -130,15 +130,18 @@ fn init( }); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { // Already authenticated at mount: create the first session now. - create_terminal_session_after_login(&sessions, ctx); + create_terminal_session_after_login(&sessions, &root, ctx); } else { // Otherwise wait for login to complete and create it then. let sessions_for_login = sessions.clone(); + let root_for_login = root.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(&sessions_for_login, ctx) - } + TuiLoginEvent::LoggedIn => create_terminal_session_after_login( + &sessions_for_login, + &root_for_login, + ctx, + ), }); } } @@ -151,7 +154,11 @@ fn init( } /// Creates the focused bootstrap session after login. -fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: &mut AppContext) { +fn create_terminal_session_after_login( + sessions: &ModelHandle, + root: &ViewHandle, + ctx: &mut AppContext, +) { if sessions.read(ctx, |sessions, _| !sessions.is_empty()) { return; } @@ -202,6 +209,7 @@ fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: 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)] From 714dbbcb28ca667cca2eda1a82540c66071c612b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 11:42:21 -0400 Subject: [PATCH 4/6] get rid of window id --- crates/warp_tui/src/root_view_tests.rs | 9 ++++++--- crates/warp_tui/src/session.rs | 15 +++++++++------ crates/warp_tui/src/session_registry.rs | 13 ++++--------- crates/warp_tui/src/session_registry_tests.rs | 2 +- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index 60ccb677aac..195f2c942c5 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -4,7 +4,7 @@ use warpui::{AddWindowOptions, UpdateModel}; use warpui_core::{App, TuiView as _, WindowId}; use super::RootTuiView; -use crate::session_registry::TuiSessions; +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) { @@ -26,9 +26,12 @@ fn root_projects_only_the_focused_retained_session_view() { 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(window_id)); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); root.update(&mut app, |_, ctx| { - ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + 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()); diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 9d3d2b1a462..6ca994e4fb4 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -25,7 +25,7 @@ use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::session_registry::TuiSessions; +use crate::session_registry::{TuiSessions, TuiSessionsEvent}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -122,11 +122,13 @@ fn init( ); match spawn_tui_driver(ctx, window_id, root.clone()) { Ok(driver) => { - let sessions = ctx.add_singleton_model(|_| { - TuiSessions::new(driver, window_id, exit_summary, resume_token) - }); + let sessions = + ctx.add_singleton_model(|_| TuiSessions::new(driver, exit_summary, resume_token)); root.update(ctx, |_, ctx| { - ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + 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 first session now. @@ -164,7 +166,8 @@ fn create_terminal_session_after_login( } let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); - let (window_id, exit_summary, keyboard_enhancement_supported) = + 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( diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 0a52755227d..18043d346ed 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -7,7 +7,7 @@ use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; -use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle, WindowId}; +use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::terminal_session_view::TuiTerminalSessionView; @@ -56,7 +56,6 @@ pub(crate) struct TuiSessions { /// drop, so the app-lifetime session singleton must retain it. _driver: Option, keyboard_enhancement_supported: bool, - window_id: WindowId, exit_summary: TuiExitSummaryHandle, sessions: Vec, focused_session_id: Option, @@ -73,7 +72,6 @@ impl TuiSessions { /// Creates the app's session container. pub(crate) fn new( driver: TuiDriverHandle, - window_id: WindowId, exit_summary: TuiExitSummaryHandle, resume_token: Option, ) -> Self { @@ -81,7 +79,6 @@ impl TuiSessions { Self { _driver: Some(driver), keyboard_enhancement_supported, - window_id, exit_summary, sessions: Vec::new(), focused_session_id: None, @@ -91,11 +88,10 @@ impl TuiSessions { /// Creates a driverless container for unit tests. #[cfg(test)] - pub(crate) fn new_for_test(window_id: WindowId) -> Self { + pub(crate) fn new_for_test() -> Self { Self { _driver: None, keyboard_enhancement_supported: false, - window_id, exit_summary: TuiExitSummaryHandle::default(), sessions: Vec::new(), focused_session_id: None, @@ -129,10 +125,9 @@ impl TuiSessions { id } - /// Returns the window and exit-summary handle used to create session views. - pub(crate) fn surface_context(&self) -> (WindowId, TuiExitSummaryHandle, bool) { + /// Returns the process-level context used to create session views. + pub(crate) fn surface_context(&self) -> (TuiExitSummaryHandle, bool) { ( - self.window_id, self.exit_summary.clone(), self.keyboard_enhancement_supported, ) diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs index dc74596418e..4e45d9010bf 100644 --- a/crates/warp_tui/src/session_registry_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -39,7 +39,7 @@ fn add_and_focus_drive_events() { ) .0 }); - let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + 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); From 8ce2681dbeb80123e50d4d22b56aeaaad40c46a8 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 12:22:24 -0400 Subject: [PATCH 5/6] fix test issue --- crates/warp_tui/src/orchestration_block_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index a938827bf43..f97acda89e0 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, From ffe2da8a034031ea9e22c24392e6ffab1490900d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 13:47:46 -0400 Subject: [PATCH 6/6] fix test issues --- app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +--- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 11 ++++-- .../terminal/local_tty/terminal_manager.rs | 2 +- app/src/tui/mcp.rs | 2 +- app/src/user_config/mod.rs | 4 +- app/src/warp_managed_paths_watcher.rs | 2 +- app/src/workspaces/user_workspaces.rs | 2 +- .../warp_tui/src/orchestration_block_tests.rs | 4 +- crates/warp_tui/src/test_fixtures.rs | 38 +------------------ 14 files changed, 23 insertions(+), 66 deletions(-) diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index 4b59ca9627a..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(any(test, feature = "test-util"))] + #[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 70a1227098c..ca76df1bfdf 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,9 +115,7 @@ impl AuthManager { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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 2cb902d5655..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(any(test, feature = "test-util"))] + #[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 dc5341211b8..010e9871e38 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -650,9 +650,7 @@ impl LaunchMode { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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 8ff728ce47b..87d951709f9 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,9 +449,7 @@ impl ServerApi { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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()); @@ -1289,9 +1287,7 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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 f563ad475cb..39859eb4a22 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,9 +356,7 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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 1750ba4322c..870697b03dc 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(any(test, feature = "test-util"))] { + if #[cfg(test)] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -448,11 +448,14 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(any(test, feature = "test-util"))] +#[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 d78733ead7d..9746e0292f8 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -123,7 +123,7 @@ pub struct TerminalSurfaceInit { pub inactive_pty_reads_rx: InactiveReceiver>>, } -#[cfg(any(test, feature = "test-util"))] +#[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 { diff --git a/app/src/tui/mcp.rs b/app/src/tui/mcp.rs index 226653f92a2..3e7634540f5 100644 --- a/app/src/tui/mcp.rs +++ b/app/src/tui/mcp.rs @@ -83,7 +83,7 @@ pub struct TuiMcpManager { impl TuiMcpManager { /// Creates an empty MCP aggregate for frontend tests. - #[cfg(any(test, feature = "test-util"))] + #[cfg(any(test, all(feature = "tui", feature = "test-util")))] pub(crate) fn new_for_test(_ctx: &mut ModelContext) -> Self { Self { snapshot: TuiMcpSnapshot { diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index 5d22b60794c..57f97ad96a7 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,9 +107,7 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[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 eaae43a09ad..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(any(test, feature = "test-util"))] + #[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 2f24191f015..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(any(test, feature = "test-util"))] + #[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/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index f97acda89e0..4ed26bf5360 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -445,8 +445,8 @@ fn blocked_accept_invalidates_card_layout() { TuiOrchestrationBlockEvent::BlockingStateChanged => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } - TuiOrchestrationBlockEvent::RejectRequested - | TuiOrchestrationBlockEvent::LayoutInvalidated => {} + TuiOrchestrationBlockEvent::RejectRequested => {} + TuiOrchestrationBlockEvent::LayoutInvalidated => {} }); }); diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 8b8b2448abd..956391b14d0 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -8,7 +8,7 @@ use warp::tui_export::{ ModelEventDispatcher, Sessions, TerminalManagerTrait, TerminalModel, TerminalSurfaceInit, }; use warp_core::semantic_selection::SemanticSelection; -use warpui::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; +use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; use warpui_core::elements::tui::{TuiElement, TuiText}; use warpui_core::{AppContext, Entity, TuiView, TypedActionView, ViewHandle, WindowId}; @@ -68,21 +68,6 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, -) { - let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); - (action_model, dispatcher) -} - -/// [`add_test_action_model_and_events`] plus the terminal-surface id the -/// action model was built with, so tests can register an active conversation -/// for that surface in the history model (required by -/// `BlocklistAIActionModel::get_pending_action`). -pub(crate) fn add_test_action_model_with_surface( - app: &mut App, -) -> ( - ModelHandle, - ModelHandle, - EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -108,7 +93,7 @@ pub(crate) fn add_test_action_model_with_surface( ctx, ) }); - (action_model, dispatcher, terminal_surface_id) + (action_model, dispatcher) } /// Builds a full session view against mock terminal plumbing. @@ -131,22 +116,3 @@ pub(crate) fn add_test_terminal_session( (view, manager) }) } - -/// Creates a live, active conversation for `terminal_surface_id` in the -/// history model, returning its id. Combined with -/// `BlocklistAIActionModel::queue_pending_action_for_test`, this drives an -/// action into `Blocked` status for confirmation-flow tests. -#[allow(dead_code)] -pub(crate) fn add_active_test_conversation( - app: &mut App, - terminal_surface_id: EntityId, -) -> warp::tui_export::AIConversationId { - app.update(|ctx| { - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - let conversation_id = - history.start_new_conversation(terminal_surface_id, false, false, false, ctx); - history.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); - conversation_id - }) - }) -}